Skip to content

feat(spec,automation,app-shell): a read-only record_link screen field shows a record the run created, by its display name, as a link to its record page - #26355

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-70vh6i
Oct 27, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-70vh6i

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Steve · project thread

Fixes #26304
Fixes #25872

Clause-②: yes (widening)
Metadata-protocol: added

Summary

A screen flow that creates records can now end on a result screen that links them. This is ruling B on #25872 (6422899013) and the contract card #26304, landed in one PR across spec, engine and console, in the shape of the file screen field (#26134).

{ id: 'done', type: 'screen', config: { title: 'Lead converted', fields: [
  { name: 'account_link', label: 'Account', type: 'record_link',
    reference: 'crm_account', defaultValue: { dialect: 'cel', source: 'account.id' } },
  { name: 'contact_link', label: 'Contact', type: 'record_link',
    reference: 'crm_contact', defaultValue: { dialect: 'cel', source: 'contact.id' } },
] } },
  • Spec. ScreenFieldConfigSchema gains the type member 'record_link'. reference and defaultValue are required on it, and required, options, placeholder, min and max are refused, each at its own key. Every other type value parses exactly as before.
  • Engine. validateScreenInputs refuses any value sent under a record_link field's name (null included, visible or hidden) with invalid_value inside 400 INVALID_SCREEN_INPUT. The value is never bound to a flow variable, and the refusal keeps the pause. judgeHeadlessScreen never counts a record_link field as answered by a caller, so a result screen always pauses.
  • Console. ScreenFieldInput gets a record_link branch. It shows the record's display name through LookupField's readonly branch (which fetches the record and names it from the referenced object's schema, ADR-0079 nameField), inside a router Link to /apps/APP/OBJECT/record/ID built by appRecordPath, the builder the record page's related lists use. An empty id draws the platform's "—" with no link. An object the console cannot route to, or a mount with no router, shows the name without a link. visibleWhen works as on any field. initialScreenValues never seeds the field and screenResumeValues strips it, so the dialog never posts it.
  • Docs. content/docs/automation/flows.mdx gains "A read-only record link", and the resume-contract list names the new refusal. The designer form's reference column now reads "Target object" and describes both lookup and record_link.

The member's name: record_link, not record

The ruling let the dev propose the name and suggested record. A screen field's type borrows the object field vocabulary (text, number, lookup, file, and so on), and FieldType already has 'record' for a name-keyed map of embedded sub-objects (field.zod.ts, ADR-0007). One word with two meanings in one field vocabulary is a trap for an author, so the member is record_link, snake_case like master_detail. The name lives in one exported constant, SCREEN_FIELD_RECORD_LINK_TYPE, which the engine and the console both import.

For the contract review: final shape and refusal texts

Shape: no new key. type stays z.string().optional(). The rule is a branch of the existing superRefine on ScreenFieldConfigSchema, taken when type === 'record_link' (exact match). It adds one issue per broken rule, each with code: 'custom', and returns before the lookup rule. .shape still lists the same keys.

Path When Message
reference absent, empty or blank SCREEN_FIELD_RECORD_LINK_REFERENCE_REQUIRED: "A type: 'record_link' screen field must declare reference — the name of the object the record belongs to (e.g. reference: 'crm_account'). Without it the field cannot name the record or link to its page."
defaultValue absent or null SCREEN_FIELD_RECORD_LINK_DEFAULT_VALUE_REQUIRED: "A type: 'record_link' screen field must declare defaultValue — the id of the record it shows, usually a CEL value envelope over a flow variable (e.g. defaultValue: { dialect: 'cel', source: 'account_id' }). The field takes no input, so without it there is never a record to show."
required, options, placeholder, min, max present, with any value (required: false too) screenFieldRecordLinkRefusedKey(KEY): "A type: 'record_link' screen field shows a record and takes no input, so it cannot declare KEY: the run refuses any value submitted under its name. Remove the key, or use a lookup field to let the user pick a record."
resume bag, field NAME the key is present, any value invalid_value: "Screen field "NAME" shows a record and takes no value — leave it out of the resume", inside the INVALID_SCREEN_INPUT envelope with the declared field list

New exports from @objectstack/spec/automation (api-surface and export-origins regenerated): SCREEN_FIELD_RECORD_LINK_TYPE, SCREEN_FIELD_RECORD_LINK_REFERENCE_REQUIRED, SCREEN_FIELD_RECORD_LINK_DEFAULT_VALUE_REQUIRED, SCREEN_FIELD_RECORD_LINK_REFUSED_KEYS, screenFieldRecordLinkRefusedKey. The ScreenFieldSpec wire contract only gains docblock text. No new FieldErrorCode member: invalid_value is the catalog's "rejected for a reason no other member names".

Tests

Measured on this branch.

  • @objectstack/spec: screen-field-record-link.test.ts (new, 9 tests) covers the accepted shapes (envelope id, literal id, help text, visibleWhen), each required key refused at its path, each refused key refused at its path (including required: false), several broken rules reported together, the refusal through ScreenConfigSchema, other types untouched (including record), and no tracker id in any refusal text. Full package: Test Files 805 passed (805), Tests 23263 passed, 1 todo.
  • @objectstack/service-automation: screen-input-record-link-field.test.ts (new, 6 tests) covers no value accepted, any value refused (null, empty, the shown id, another id, an object, 0, false), refused while hidden, exact-member matching, and the headless verdict with a positive control. builtin/screen-record-link-resume.test.ts (new, 2 tests) runs the engine: two create_record nodes, then a result screen whose two links arrive typed record_link with reference and the created ids; a resume carrying a link's value is refused with nothing run after the screen; the clean resume completes. The account link is named account, after the very variable its id comes from, so a bound refused value would have broken the audit write after the screen. Full package: Test Files 209 passed (209), Tests 2707 passed (2707).
  • @object-ui/app-shell: FlowRunner.recordLink-25872.test.tsx (new, 6 tests) renders the real FlowRunner and the real LookupField over a fake data source whose objects name records by company_name / full_name, not name. It covers two links with the right names and hrefs; "—" and no link for an empty id; an unroutable object and a router-less mount showing the name unlinked; visibleWhen; Submit posting only the real input; and the seed/strip helpers. With the neighbours views/__tests__/FlowRunner*, metadata-admin/previews/ and spec-symbol-parity: Test Files 160 passed (160), Tests 1794 passed (1794). The full app-shell suite is left to CI Test UI.
  • Dogfood: flow-screen-record-link.dogfood.test.ts (new) boots a real stack and drives the trigger and resume doors. The run creates an account and a contact and pauses on the result screen. Both fields arrive as record_link with their objects and the ids of the records this run wrote, and each id reads back through /data/OBJECT/ID with its nameField value (what the console shows). A resume carrying account_link or contact_link: null is refused 400 INVALID_SCREEN_INPUT naming the field, and the clean resume completes the run. pnpm --filter @objectstack/dogfood exec vitest run test/flow-screen-record-link.dogfood.test.ts: Test Files 1 passed (1), Tests 2 passed (2). The full battery is not owed: no ! in the title and no (narrowing) arm.
  • Type-check: @objectstack/spec, @objectstack/service-automation and @objectstack/dogfood typecheck, and @object-ui/app-shell type-check, exit 0 each.
  • Console: pnpm exec turbo run build --filter=@object-ui/console on the merge head a2c646635b, exit 0.

Ablation

Each mutation went through scripts/ablation-replace.mjs, which restored the file to its HEAD blob with git diff HEAD empty.

  • Spec: if (field.type === SCREEN_FIELD_RECORD_LINK_TYPE) { to if (false as boolean) { turned 5 of 9 tests red.
  • Engine: deleting the two guard lines of the record_link resume pass (so it skips every field) turned 3 tests red across both new files.
  • Engine: removing the record_link skip in judgeHeadlessScreen turned 1 test red. A first attempt used a replacement that was a substring of its anchor, and the tool refused it before any run; that attempt was void and was redone with a distinct replacement.
  • Console: if (isRecordLinkScreenField(field)) { to if (false as boolean) { in ScreenFieldInput turned 5 of 6 tests red.
  • Console: removing the skip in initialScreenValues turned 1 test red. The Submit test stayed green under that mutation because screenResumeValues still strips the key, which is the intended second layer.

Gates

Derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 27c90bb39d (115 commands; the same list as at the earlier 582dd2f9). Each ran with its exit code captured before any pipe, and --ran reconciled them: "115 derived famil(ies) accounted for — 114 run, 1 NOT-MEASURED". The 114 that ran exited 0. They include check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check:api-surface, check:authorable-surface, check:export-origins, check:docs, check:doc-authoring, check:cross-package-test-inputs, check:test-source-alias, check-sdui-manifest (no record moves), check:nul-bytes and check:liveness.

After merging origin/main (12 commits, none touching this diff's files) the spec was rebuilt and the ratchet families re-ran on the merge head a2c646635b: check:generated exit 0, and check:type-check-debt exit 0 ("0 raw tsc error(s) total, none above its recorded number"). check:skill-examples and the check-plugin-teardown-shape self-test first stopped on missing prerequisites (an unbuilt client-react and a shallow clone); both re-ran after the prerequisite was met and exited 0. pnpm --dir ui run check:new-line-citations: 0 new citations.

Not measured locally, left to CI: check:dual-build-cjs-loads, which needs a full workspace pnpm build and stopped on that prerequisite with exit 3 before reading anything. This diff does not change any package's build shape.

Acceptance notes

  • Widening, as declared. Before this PR type was an open string, so type: 'record_link' with any keys parsed and rendered a text input. It now refuses a record_link field without reference or defaultValue, or with an input key. No producer of a record_link screen field exists in this repository (git grep over examples/, packages/ and ui/ source finds none), so nothing that parses today is refused. The claim's Clause-②: yes (widening) is kept.
  • The result screen still ends with Submit. As the ruling's analysis said, a user who follows a link before submitting leaves the run paused, as closing the dialog does. Whether the dialog stays open across that navigation depends on the host page; this PR does not change it.
  • A record that cannot be read (deleted, or denied to the user) settles LookupField's readonly branch on "—", and the link around it still points at the record page, which then answers for itself. The name is never replaced with the raw id.
  • Case. The console reads record_link exactly, as the spec and engine do, unlike file and number, which the console lowercases. A field declared type: 'Record_Link' is an ordinary text input on every layer.
  • Skill and eval are a follow-up. The skills surface is governed, so the owed skill text and eval prompt (skills: audit what the skills teach against the protocol since 18.0 — (A) published skills/ vs every added, changed or retired authorable key; (B) where pm-dispatch requires the skills/docs follow-up #26184) are in skills(objectstack-automation): teach the read-only record_link screen field, with an eval prompt (#26304 follow-up, #26184) #26347 (skills/objectstack-automation, an entry in evals/flows-triggers-approvals.json). This PR does not touch skills/**.
  • After a published @objectstack/* release carries this, the hotcrm seat can lift hotcrm#2285's Blocked-by:.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_0115tbh4XfgivQD2RpHHwdMz

… defaultValue required, input keys refused

Claude-Session: https://claude.ai/code/session_0115tbh4XfgivQD2RpHHwdMz
Co-authored-by: Claude <noreply@anthropic.com>
…_link screen field; the flow dialog shows the record's name as a link

Claude-Session: https://claude.ai/code/session_0115tbh4XfgivQD2RpHHwdMz
Co-authored-by: Claude <noreply@anthropic.com>
…docs; add the changeset and the flows guide section

Claude-Session: https://claude.ai/code/session_0115tbh4XfgivQD2RpHHwdMz
Co-authored-by: Claude <noreply@anthropic.com>
…it created as record links, and a resume carrying a link's value is refused

Claude-Session: https://claude.ai/code/session_0115tbh4XfgivQD2RpHHwdMz
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-automation, @objectstack/spec, touching 16 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/api-surface/automation.json, packages/spec/export-origins/automation.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

32 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json f60f4ea3ca7dd6b646014d02f52c2a9ae13e0820.

⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/automation.json, packages/spec/export-origins/automation.json) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: defaultValue (literal, 34 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 146 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f60f4ea3ca7dd6b646014d02f52c2a9ae13e0820 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from e2023d50b6eb0b87ce7e1919b5c992cd191c19dd — the merge of head a2c646635bd4b3517acf61d215c1f4a78b933a69 into base f60f4ea3ca7dd6b646014d02f52c2a9ae13e0820, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e2023d50b6eb0b87ce7e1919b5c992cd191c19dd && git checkout e2023d50b6eb0b87ce7e1919b5c992cd191c19dd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f60f4ea3ca7dd6b646014d02f52c2a9ae13e0820 a2c646635bd4b3517acf61d215c1f4a78b933a69 && git checkout -B drift-repro f60f4ea3ca7dd6b646014d02f52c2a9ae13e0820 && git merge --no-ff a2c646635bd4b3517acf61d215c1f4a78b933a69

node scripts/docs-audit/affected-docs.mjs --json f60f4ea3ca7dd6b646014d02f52c2a9ae13e0820

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f60f4ea3ca7dd6b646014d02f52c2a9ae13e0820 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · contract review + ACCEPT · seat domain:ui#1 · 2026-10-26T23:35Z

Head a2c64663 (read from the PR object).

  • Shape. Draft on main, Fixes #26304 and Fixes #25872: one PR across spec, engine and console, as ruling 6422899013 and triage 6427137154 allow (the feat(spec,automation,app-shell)!: a flow screen field typed file collects an upload, and the run resumes with the uploaded file's id #26134 shape). 16 files, +1094/-18. Changeset: @objectstack/spec, @objectstack/service-automation and @object-ui/app-shell minor. Clause-②: yes (widening), Metadata-protocol: added. The skills and eval owed under skills: audit what the skills teach against the protocol since 18.0 — (A) published skills/ vs every added, changed or retired authorable key; (B) where pm-dispatch requires the skills/docs follow-up #26184 are filed as skills(objectstack-automation): teach the read-only record_link screen field, with an eval prompt (#26304 follow-up, #26184) #26347 (governed surface) and named in the body.
  • Checks pinned to the head. All check runs completed: 0 failing, 0 pending. The seat's landing script checked this before posting.
  • Governed surface. check-governed-merges.mjs --pr 26355: 0 of 16 paths hit the register.
  • Contract review: PASS.
    • Name. record_link (SCREEN_FIELD_RECORD_LINK_TYPE), not record, because FieldType already has 'record' (a name-keyed sub-object map) and screen field types borrow that vocabulary. The seat accepts the name.
    • Shape. No new key. type stays z.string().optional(), and the rule is a branch of the existing ScreenFieldConfigSchema superRefine. A blank or absent reference raises the exported …_REFERENCE_REQUIRED message, and an absent or null defaultValue raises …_DEFAULT_VALUE_REQUIRED. Each of required, options, placeholder, min and max present with any value raises screenFieldRecordLinkRefusedKey(key) at its own path. The branch returns before the lookup rule. These are exactly ruling B's parameters.
    • Engine. In screen-input-contract.ts, any value under a record_link field's name (null included, visible or not) fails with invalid_value in 400 INVALID_SCREEN_INPUT: "Screen field "NAME" shows a record and takes no value — leave it out of the resume". It is never bound, and the pause survives. The value-binding path skips the field.
    • Console. ScreenView.tsx's ScreenFieldInput renders the record's display name as a link to its record page. visibleWhen works as usual, and an empty id renders "—".
    • Surfaces. api-surface and export-origins gain the new exports; the docs (flows.mdx plus the generated reference) and the screen-nodes.ts descriptor text follow.
  • Evidence (dev, report 6429522132). New tests 9+6+2+6 and dogfood 2/2 (two created records shown and linked; a resume carrying the field's value refused). Full suites: spec 23263, automation 2707, app-shell neighbours 1794. Type-checks and the Console build exit 0. All 5 ablations go red. Gates: 114/115 exit 0; check:dual-build-cjs-loads was not measurable locally, and CI ran it on this head.
  • Serial constraints. No other open PR touches these spec, engine or console files. No sdui record moves (app-shell is outside the closure). The ⛔ paths are untouched.

Landing: ready + auto-merge through the relay.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

1 participant