Repository navigation
Commit 4548ba3
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)
<!-- claude-projects-attribution: {"github_login":"os-steve"} -->
_Requested by **Steve** · [project
thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9VyLmRWa1EMU9YudB6tnf1C)_
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).
```ts
{ 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 (#26184) are in #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](https://claude.ai/code/session_0115tbh4XfgivQD2RpHHwdMz)_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_0115tbh4XfgivQD2RpHHwdMz
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent c19ad22 commit 4548ba3
16 files changed
Lines changed: 1094 additions & 18 deletions
File tree
- .changeset
- content/docs
- automation
- references/automation
- packages
- qa/dogfood/test
- fixtures
- services/service-automation/src
- builtin
- spec
- api-surface
- export-origins
- src
- automation
- contracts
- ui/packages/app-shell/src/views
- __tests__
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
818 | 818 | | |
819 | 819 | | |
820 | 820 | | |
| 821 | + | |
| 822 | + | |
| 823 | + | |
| 824 | + | |
| 825 | + | |
| 826 | + | |
| 827 | + | |
| 828 | + | |
| 829 | + | |
| 830 | + | |
| 831 | + | |
| 832 | + | |
| 833 | + | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
| 838 | + | |
| 839 | + | |
| 840 | + | |
| 841 | + | |
| 842 | + | |
| 843 | + | |
| 844 | + | |
| 845 | + | |
| 846 | + | |
| 847 | + | |
| 848 | + | |
| 849 | + | |
| 850 | + | |
| 851 | + | |
| 852 | + | |
| 853 | + | |
| 854 | + | |
821 | 855 | | |
822 | 856 | | |
823 | 857 | | |
| |||
1255 | 1289 | | |
1256 | 1290 | | |
1257 | 1291 | | |
1258 | | - | |
1259 | | - | |
| 1292 | + | |
| 1293 | + | |
| 1294 | + | |
| 1295 | + | |
1260 | 1296 | | |
1261 | 1297 | | |
1262 | 1298 | | |
| 1299 | + | |
1263 | 1300 | | |
1264 | 1301 | | |
1265 | 1302 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
278 | 278 | | |
279 | 279 | | |
280 | 280 | | |
281 | | - | |
| 281 | + | |
282 | 282 | | |
283 | 283 | | |
284 | 284 | | |
| |||
287 | 287 | | |
288 | 288 | | |
289 | 289 | | |
290 | | - | |
| 290 | + | |
291 | 291 | | |
292 | 292 | | |
293 | 293 | | |
| |||
300 | 300 | | |
301 | 301 | | |
302 | 302 | | |
303 | | - | |
| 303 | + | |
304 | 304 | | |
305 | 305 | | |
306 | 306 | | |
| |||
309 | 309 | | |
310 | 310 | | |
311 | 311 | | |
312 | | - | |
| 312 | + | |
313 | 313 | | |
314 | 314 | | |
315 | 315 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
0 commit comments