From f24b9916911dada0bab09fcb029c982a7f1ab8aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 21:55:31 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(spec):=20a=20read-only=20record=5Flink?= =?UTF-8?q?=20screen=20field=20type=20=E2=80=94=20reference=20and=20defaul?= =?UTF-8?q?tValue=20required,=20input=20keys=20refused?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_0115tbh4XfgivQD2RpHHwdMz Co-authored-by: Claude --- .../src/automation/builtin-node-config.zod.ts | 85 +++++++++++- .../screen-field-record-link.test.ts | 128 ++++++++++++++++++ .../spec/src/contracts/automation-service.ts | 12 +- 3 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 packages/spec/src/automation/screen-field-record-link.test.ts diff --git a/packages/spec/src/automation/builtin-node-config.zod.ts b/packages/spec/src/automation/builtin-node-config.zod.ts index b6e955ac98f..4f9b45b9978 100644 --- a/packages/spec/src/automation/builtin-node-config.zod.ts +++ b/packages/spec/src/automation/builtin-node-config.zod.ts @@ -801,6 +801,57 @@ export const SCREEN_FIELD_LOOKUP_REFERENCE_REQUIRED = + "offers (e.g. `reference: 'crm_knowledge_article'`). Without it the field renders a picker with nothing to " + 'resolve.'; +/** + * The read-only record-link member of a screen field's `type` (ruling B on the + * screen-flow result screen): the field SHOWS a record the run already holds the + * id of — by its display name (ADR-0079 `nameField`), as a link to the record's + * page — and submits nothing. + * + * Named `record_link`, not `record`: a screen field's `type` borrows the object + * field vocabulary (`text`, `number`, `lookup`, `file`, …), and `FieldType` + * already spells `'record'` for a name-keyed map of embedded sub-objects. One + * word with two meanings across one field vocabulary is the collision the + * snake_case name avoids, in the shape `master_detail` already has. + */ +export const SCREEN_FIELD_RECORD_LINK_TYPE = 'record_link'; + +/** + * What a `type: 'record_link'` screen field with no target object is told. It + * names the key and what the link cannot do without it, as the lookup refusal + * does. ⛔ No tracker id in this text (`check:doc-authoring`). + */ +export const 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."; + +/** + * What a `type: 'record_link'` screen field with no `defaultValue` is told: the + * field shows the record whose id its `defaultValue` evaluates to, so without + * one there is never a record to show. + */ +export const 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.'; + +/** + * The keys a `type: 'record_link'` screen field refuses, each because it + * describes an INPUT and this field takes none: the resume refuses any value + * under its name, so `required` could never be met, and `options`, + * `placeholder`, `min` and `max` would constrain or decorate a control that is + * never drawn. + */ +export const SCREEN_FIELD_RECORD_LINK_REFUSED_KEYS = ['required', 'options', 'placeholder', 'min', 'max'] as const; + +/** The refusal of one {@link SCREEN_FIELD_RECORD_LINK_REFUSED_KEYS} key on a `type: 'record_link'` screen field. */ +export function screenFieldRecordLinkRefusedKey(key: string): string { + return ( + `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.' + ); +} + /** * The sentence every refusal of two colliding screen-field options leads with. * @@ -909,10 +960,21 @@ export const ScreenFieldConfigSchema = lazySchema(() => strictObject({ * asks (#25692); a `runAs: 'system'` run's write is not judged, as no system * write is. So a wizard collects its file before the record that will own it * exists, and writes everything after its last screen. + * + * One member is the opposite of an input: `'record_link'` + * ({@link SCREEN_FIELD_RECORD_LINK_TYPE}). It shows the `reference` record + * whose id `defaultValue` evaluates to — by its display name (ADR-0079 + * `nameField`), as a link to the record's page — and submits nothing. Both + * keys are required on it and the input keys are refused (the refinement + * below); the resume refuses any value under its name, so the id it shows is + * never written back into a flow variable. It is how a screen flow ends on a + * result screen that links the records the run created. */ type: z.string().optional().describe( 'Input type — an open widget hint (text, number, select, …); `file` renders the platform upload widget and ' - + "the screen resumes with the uploaded file's id, which a later node writes into a `file` field", + + "the screen resumes with the uploaded file's id, which a later node writes into a `file` field; " + + '`record_link` shows the `reference` record whose id `defaultValue` evaluates to, by its display name, as a ' + + 'link to its record page, and takes no input', ), /** Whether the runner requires a value before resume. */ required: z.boolean().optional().describe('Whether a value is required to submit'), @@ -1000,7 +1062,7 @@ export const ScreenFieldConfigSchema = lazySchema(() => strictObject({ * the flow and the field), ⛔ never a D2 conversion that would have to invent * a target. */ - reference: z.string().optional().describe("Target object name (snake_case) whose records a `type: 'lookup'` field picks from; REQUIRED when `type` is `lookup`"), + reference: z.string().optional().describe("Target object name (snake_case) whose records a `type: 'lookup'` field picks from, or that a `type: 'record_link'` field's record belongs to; REQUIRED when `type` is `lookup` or `record_link`"), }).superRefine((field, ctx) => { // Two options whose values read the same as text are ONE option to every // reader that addresses them as text — the label translation key and the @@ -1022,6 +1084,25 @@ export const ScreenFieldConfigSchema = lazySchema(() => strictObject({ }); }); } + // The read-only record link: it shows the record whose id `defaultValue` + // evaluates to, of the `reference` object, and takes no input. Both keys it + // reads are required, and every key that describes an input is refused at + // that key — each one either could never be met (the resume refuses a value + // under this field's name, so `required` would dead-end the run) or would + // decorate a control that is never drawn. + if (field.type === SCREEN_FIELD_RECORD_LINK_TYPE) { + if (typeof field.reference !== 'string' || field.reference.trim() === '') { + ctx.addIssue({ code: 'custom', path: ['reference'], message: SCREEN_FIELD_RECORD_LINK_REFERENCE_REQUIRED }); + } + if (field.defaultValue === undefined || field.defaultValue === null) { + ctx.addIssue({ code: 'custom', path: ['defaultValue'], message: SCREEN_FIELD_RECORD_LINK_DEFAULT_VALUE_REQUIRED }); + } + for (const key of SCREEN_FIELD_RECORD_LINK_REFUSED_KEYS) { + if (field[key] === undefined) continue; + ctx.addIssue({ code: 'custom', path: [key], message: screenFieldRecordLinkRefusedKey(key) }); + } + return; + } // Conditional requirement, not a shape change: the key set is unchanged and // `.shape` still enumerates twelve keys. `type` is an open widget hint, so // this reads ONE member of it — the member whose whole meaning is "resolve diff --git a/packages/spec/src/automation/screen-field-record-link.test.ts b/packages/spec/src/automation/screen-field-record-link.test.ts new file mode 100644 index 00000000000..161d0b69494 --- /dev/null +++ b/packages/spec/src/automation/screen-field-record-link.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The read-only record-link screen field (`type: 'record_link'`), ruling B on + * the screen-flow result screen. + * + * A screen field that SHOWS a record the run holds the id of and submits + * nothing. The contract is a conditional refinement over the open `type` hint: + * on this member `reference` and `defaultValue` are required, and the keys that + * describe an input (`required`, `options`, `placeholder`, `min`, `max`) are + * refused, each at its own key. Every other member is untouched. + */ + +import { describe, expect, it } from 'vitest'; + +import { + SCREEN_FIELD_RECORD_LINK_DEFAULT_VALUE_REQUIRED, + SCREEN_FIELD_RECORD_LINK_REFERENCE_REQUIRED, + SCREEN_FIELD_RECORD_LINK_REFUSED_KEYS, + SCREEN_FIELD_RECORD_LINK_TYPE, + ScreenConfigSchema, + ScreenFieldConfigSchema, + screenFieldRecordLinkRefusedKey, +} from './builtin-node-config.zod.js'; + +const LINK = { + name: 'created_account', + label: 'Account', + type: 'record_link', + reference: 'crm_account', + defaultValue: { dialect: 'cel', source: 'account_id' }, +} as const; + +/** The issues a parse addressed to `key`, or `[]` when it parsed. */ +function issuesAt(value: unknown, key: string) { + const r = ScreenFieldConfigSchema.safeParse(value); + return r.success ? [] : r.error.issues.filter((i) => i.path[0] === key); +} + +describe("screen field `type: 'record_link'` — a read-only link to a record", () => { + it('is spelled `record_link`, not the `record` the object field vocabulary already uses', () => { + expect(SCREEN_FIELD_RECORD_LINK_TYPE).toBe('record_link'); + }); + + it('accepts the declared shape — a CEL envelope id, a literal id, and the presentation keys', () => { + const accepted = [ + LINK, + { ...LINK, defaultValue: 'acc_0001' }, + { ...LINK, inlineHelpText: 'Opens the account the conversion created.' }, + { ...LINK, visibleWhen: 'create_opportunity == true' }, + ]; + for (const value of accepted) { + const r = ScreenFieldConfigSchema.safeParse(value); + expect(r.success, JSON.stringify(r.success ? null : r.error.issues)).toBe(true); + } + }); + + it('refuses a missing or blank `reference`, addressed to the key', () => { + for (const reference of [undefined, '', ' ']) { + const value = { ...LINK, reference }; + const issues = issuesAt(value, 'reference'); + expect(issues.map((i) => i.message), `reference: ${JSON.stringify(reference)}`) + .toEqual([SCREEN_FIELD_RECORD_LINK_REFERENCE_REQUIRED]); + } + }); + + it('refuses a missing or null `defaultValue`, addressed to the key', () => { + const { defaultValue: _omit, ...withoutDefault } = LINK; + for (const value of [withoutDefault, { ...LINK, defaultValue: null }]) { + expect(issuesAt(value, 'defaultValue').map((i) => i.message)) + .toEqual([SCREEN_FIELD_RECORD_LINK_DEFAULT_VALUE_REQUIRED]); + } + }); + + it('refuses every input key at that key — `required: false` included, since the key itself is the error', () => { + expect([...SCREEN_FIELD_RECORD_LINK_REFUSED_KEYS]).toEqual(['required', 'options', 'placeholder', 'min', 'max']); + const values: Record<(typeof SCREEN_FIELD_RECORD_LINK_REFUSED_KEYS)[number], unknown[]> = { + required: [true, false], + options: [[{ value: 'a', label: 'A' }]], + placeholder: ['Pick one'], + min: [0], + max: [10], + }; + for (const key of SCREEN_FIELD_RECORD_LINK_REFUSED_KEYS) { + for (const v of values[key]) { + const issues = issuesAt({ ...LINK, [key]: v }, key); + expect(issues.map((i) => i.message), `${key}: ${JSON.stringify(v)}`) + .toEqual([screenFieldRecordLinkRefusedKey(key)]); + expect(issues[0]!.code).toBe('custom'); + } + } + }); + + it('reports every broken rule of one field at once', () => { + const r = ScreenFieldConfigSchema.safeParse({ name: 'x', type: 'record_link', required: true, placeholder: 'p' }); + expect(r.success).toBe(false); + expect(r.error!.issues.map((i) => i.path[0]).sort()).toEqual(['defaultValue', 'placeholder', 'reference', 'required']); + }); + + it('is refused through the screen node config too, at the field', () => { + const r = ScreenConfigSchema.safeParse({ fields: [{ ...LINK, required: true }] }); + expect(r.success).toBe(false); + expect(r.error!.issues.some((i) => i.path.join('.') === 'fields.0.required')).toBe(true); + expect(ScreenConfigSchema.safeParse({ fields: [LINK] }).success).toBe(true); + }); + + it('leaves every other widget hint alone — the input keys still parse on them', () => { + for (const type of [undefined, 'text', 'number', 'select', 'record']) { + const r = ScreenFieldConfigSchema.safeParse({ + name: 'x', ...(type ? { type } : {}), required: true, placeholder: 'p', min: 0, max: 1, + options: [{ value: 'a', label: 'A' }], + }); + expect(r.success, `type: ${String(type)}`).toBe(true); + } + }); + + it('prints no tracker id at the author, and names the key it refuses', () => { + const texts = [ + SCREEN_FIELD_RECORD_LINK_REFERENCE_REQUIRED, + SCREEN_FIELD_RECORD_LINK_DEFAULT_VALUE_REQUIRED, + ...SCREEN_FIELD_RECORD_LINK_REFUSED_KEYS.map(screenFieldRecordLinkRefusedKey), + ]; + for (const text of texts) expect(text).not.toMatch(/#\d{3,5}\b/); + expect(SCREEN_FIELD_RECORD_LINK_REFERENCE_REQUIRED).toContain('`reference`'); + expect(SCREEN_FIELD_RECORD_LINK_DEFAULT_VALUE_REQUIRED).toContain('`defaultValue`'); + expect(screenFieldRecordLinkRefusedKey('min')).toContain('`min`'); + }); +}); diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 7ac943fce57..10e36dd70f0 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -279,6 +279,12 @@ export interface ScreenFieldSpec { * `'file'` (#25801) takes the id of an uploaded `sys_file` — the client * renders the platform upload widget and answers with the id the upload * minted, never the expanded `{ id, name, url, … }` read form. + * + * One member is not an input at all: `'record_link'` shows the + * {@link reference} record whose id {@link defaultValue} carries (served + * already evaluated) by its display name, as a link to its record page. + * The client submits nothing for it, and the server refuses any value sent + * under its name on resume, so the id is never bound to a flow variable. */ type?: string; required?: boolean; @@ -309,8 +315,10 @@ export interface ScreenFieldSpec { */ inlineHelpText?: string; /** - * Object whose records a `type: 'lookup'` field picks from (#17306) — - * `FieldSchema.reference`'s spelling. + * Object whose records a `type: 'lookup'` field picks from (#17306), or + * that a `type: 'record_link'` field's record belongs to — + * `FieldSchema.reference`'s spelling. Required on both by + * `ScreenFieldConfigSchema`. * * Optional HERE and required on the authoring side: `ScreenFieldConfigSchema` * refuses a `type: 'lookup'` field that declares no `reference` (maintainer From f10d535d8830ddbc5143e8b99b07cb615e4f33bd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 22:01:40 +0000 Subject: [PATCH 2/5] feat(automation,app-shell): the resume refuses a value under a record_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 --- content/docs/automation/flows.mdx | 41 ++++- .../src/builtin/screen-nodes.ts | 4 +- .../builtin/screen-record-link-resume.test.ts | 118 +++++++++++++ .../src/screen-input-contract.ts | 43 ++++- .../screen-input-record-link-field.test.ts | 77 ++++++++ .../app-shell/src/views/ScreenView.tsx | 148 +++++++++++++++- .../FlowRunner.recordLink-25872.test.tsx | 166 ++++++++++++++++++ 7 files changed, 587 insertions(+), 10 deletions(-) create mode 100644 packages/services/service-automation/src/builtin/screen-record-link-resume.test.ts create mode 100644 packages/services/service-automation/src/screen-input-record-link-field.test.ts create mode 100644 ui/packages/app-shell/src/views/__tests__/FlowRunner.recordLink-25872.test.tsx diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index ad03ff2059c..e25eaafb082 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -818,6 +818,40 @@ wizard can collect everything first and write once, after its last screen: The target `file` field's own `accept` / `maxSize` still apply at the write that claims the file. +#### A read-only record link + +`type: 'record_link'` shows a record the run already holds the id of, so a +screen flow can end on a result screen that links the records it created: + +```typescript +{ id: 'create_account', type: 'create_record', config: { objectName: 'crm_account', + fields: { name: { dialect: 'cel', source: 'company' } }, outputVariable: 'account' } }, +{ id: 'create_contact', type: 'create_record', config: { objectName: 'crm_contact', + fields: { last_name: { dialect: 'cel', source: 'last_name' } }, outputVariable: 'contact' } }, +{ 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' } }, +] } }, +``` + +- **The console shows the record's name as a link.** The flow dialog fetches the + record and shows its display name (the object's `nameField`), as a link to the + record's page. An empty id shows "—". `visibleWhen` works as on any field. +- **`reference` and `defaultValue` are required.** `reference` names the + record's object. `defaultValue` is a value slot that evaluates to the record's + id when the screen is served, usually a CEL envelope over a flow variable. +- **It takes no input.** `required`, `options`, `placeholder`, `min` and `max` + are refused on it at publish. The console posts nothing for it, and a resume + that carries any value under its name is refused with `invalid_value` inside + the run's `INVALID_SCREEN_INPUT`. The value is never bound to a flow variable. +- **The screen still ends with Submit.** A result screen is an ordinary screen: + the run completes when the user submits it. A user who follows a link first + leaves the run paused, as closing the dialog does. +- **Why not `record`?** That name is already an object field type (a name-keyed + map of sub-objects), and a screen field's `type` uses the same vocabulary. + **Screen (object form):** Set `config.objectName` @@ -1255,11 +1289,14 @@ POST /api/v1/automation/{flow}/runs/{runId}/resume — declared fields: 'kind', 'escalation_reason' ``` -Two conditions are refused, both reported at once and both with -`code: 'INVALID_SCREEN_INPUT'`: +These conditions are refused, all reported at once and all with +`code: 'INVALID_SCREEN_INPUT'` (the value shapes of `type: 'number'` and +`type: 'file'`, and the `min` / `max` bounds, are described with those fields +above): - a **`required` field the caller was actually asked for** is missing (an empty or blank string counts as missing); +- a value was sent under a **`type: 'record_link'` field**, which takes none; - a key the screen **never declared** was sent. `visibleWhen` is evaluated against the **submitted values** first, so a hidden diff --git a/packages/services/service-automation/src/builtin/screen-nodes.ts b/packages/services/service-automation/src/builtin/screen-nodes.ts index 12c84f17a62..ecb18c3fd89 100644 --- a/packages/services/service-automation/src/builtin/screen-nodes.ts +++ b/packages/services/service-automation/src/builtin/screen-nodes.ts @@ -68,9 +68,9 @@ import { judgeHeadlessScreen } from '../screen-input-contract.js'; */ const LOOKUP_TARGET_COLUMN = { type: 'string', - title: 'Lookup object', + title: 'Target object', xRef: { kind: 'object' }, - description: "Object whose records a `lookup` field picks from. Required on a `lookup` field — a picker with no target object resolves nothing.", + description: "Object whose records a `lookup` field picks from, or that a `record_link` field's record belongs to. Required on both — a picker with no target object resolves nothing, and a link with none cannot name its record.", }; /** How a refusal names a value that is not a record id — `null`, a number (`42`), a map. */ diff --git a/packages/services/service-automation/src/builtin/screen-record-link-resume.test.ts b/packages/services/service-automation/src/builtin/screen-record-link-resume.test.ts new file mode 100644 index 00000000000..6b9f3587958 --- /dev/null +++ b/packages/services/service-automation/src/builtin/screen-record-link-resume.test.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A screen flow ends on a result screen that links the records it created + * (ruling B on the screen-flow result screen). + * + * The flow writes two records, then pauses on a screen of two + * `type: 'record_link'` fields whose `defaultValue` envelopes read the created + * records' ids. Pinned here, at the engine: + * - the paused screen carries each field typed `record_link`, with its + * `reference` and its `defaultValue` already evaluated to the id; + * - a resume carrying a value under a link field's name is refused with + * `INVALID_SCREEN_INPUT`, nothing after the screen runs, and the pause + * survives; + * - the resume that leaves the links out completes, and the flow variable the + * id came from is the record the run created — the account link is named + * after that very variable (`account`), so a bound refused value would have + * replaced the record with a string and the audit write after the screen + * would not read the created id. + */ + +import { describe, it, expect } from 'vitest'; +import { AutomationEngine } from '../engine.js'; +import { installBuiltinNodes } from './index.js'; + +function silentLogger(): any { + const l: any = { info() {}, warn() {}, error() {}, debug() {} }; + l.child = () => l; + return l; +} + +/** A data engine that mints `_` ids and records every insert. */ +function fakeData() { + const inserts: Array<{ obj: string; fields: Record }> = []; + const data: any = { + async insert(obj: string, fields: Record) { + inserts.push({ obj, fields }); + return { id: `${obj}_${inserts.length}`, ...fields }; + }, + }; + return { data, inserts }; +} + +const CEL = (source: string) => ({ dialect: 'cel', source }); + +/** start → create account → create contact → result screen (two links) → audit write → end. */ +function convertFlow() { + return { + name: 'convert_with_result', + label: 'Convert with result screen', + type: 'screen', + status: 'active', + runAs: 'system', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'acc', type: 'create_record', label: 'Account', config: { objectName: 'crm_account', fields: { name: 'Acme' }, outputVariable: 'account' } }, + { id: 'con', type: 'create_record', label: 'Contact', config: { objectName: 'crm_contact', fields: { name: 'Ada' }, outputVariable: 'contact' } }, + { + id: 'result', type: 'screen', label: 'Created', + config: { + fields: [ + { name: 'account', label: 'Account', type: 'record_link', reference: 'crm_account', defaultValue: CEL('account.id') }, + { name: 'contact_link', label: 'Contact', type: 'record_link', reference: 'crm_contact', defaultValue: CEL('contact.id') }, + ], + }, + }, + { id: 'audit', type: 'create_record', label: 'Audit', config: { objectName: 'audit', fields: { account: CEL('account.id') } } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'acc' }, + { id: 'e2', source: 'acc', target: 'con' }, + { id: 'e3', source: 'con', target: 'result' }, + { id: 'e4', source: 'result', target: 'audit' }, + { id: 'e5', source: 'audit', target: 'end' }, + ], + }; +} + +function makeEngine() { + const { data, inserts } = fakeData(); + const engine = new AutomationEngine(silentLogger()); + installBuiltinNodes(engine, { logger: silentLogger(), getService: (n: string) => (n === 'data' ? data : undefined) } as any); + engine.registerFlow('convert_with_result', convertFlow() as any); + return { engine, inserts }; +} + +describe('a screen flow ends on a screen that links the records it created', () => { + it('pauses with both fields typed `record_link`, each carrying its object and the created id', async () => { + const { engine, inserts } = makeEngine(); + const started = await engine.execute('convert_with_result', {} as any); + expect(started.status).toBe('paused'); + expect(started.screen?.fields).toEqual([ + expect.objectContaining({ name: 'account', type: 'record_link', reference: 'crm_account', defaultValue: 'crm_account_1' }), + expect.objectContaining({ name: 'contact_link', type: 'record_link', reference: 'crm_contact', defaultValue: 'crm_contact_2' }), + ]); + expect(started.screen?.fields.every((f) => f.required === false)).toBe(true); + expect(inserts.map((i) => i.obj)).toEqual(['crm_account', 'crm_contact']); + }); + + it('refuses a resume carrying a link field\'s value, runs nothing after the screen, and keeps the pause', async () => { + const { engine, inserts } = makeEngine(); + const { runId } = await engine.execute('convert_with_result', {} as any); + + const refused = await engine.resume(runId!, { variables: { account: 'crm_account_999' } }); + expect(refused.success).toBe(false); + expect(refused.code).toBe('INVALID_SCREEN_INPUT'); + expect(refused.error).toContain('Screen field "account" shows a record and takes no value'); + expect(inserts.map((i) => i.obj)).toEqual(['crm_account', 'crm_contact']); + + // The refusal consumed nothing, and bound nothing: the clean resume + // completes and the next node reads `account.id` — the record the run + // created, not the string the refused resume sent under the same name. + const resumed = await engine.resume(runId!, { variables: {} }); + expect(resumed.success, resumed.error).toBe(true); + expect(inserts[2]).toEqual({ obj: 'audit', fields: { account: 'crm_account_1' } }); + }); +}); diff --git a/packages/services/service-automation/src/screen-input-contract.ts b/packages/services/service-automation/src/screen-input-contract.ts index 753e36a8734..95952de6646 100644 --- a/packages/services/service-automation/src/screen-input-contract.ts +++ b/packages/services/service-automation/src/screen-input-contract.ts @@ -25,6 +25,7 @@ import { isDeepStrictEqual } from 'node:util'; import type { ScreenFieldSpec, ScreenSpec } from '@objectstack/spec/contracts'; import type { FieldErrorCode } from '@objectstack/spec/api'; +import { SCREEN_FIELD_RECORD_LINK_TYPE } from '@objectstack/spec/automation'; import { isFileIdToken } from '@objectstack/spec/data'; /** One violation of a screen's declared field contract. */ @@ -33,8 +34,8 @@ export interface ScreenInputIssue { field: string; /** * Which constraint the bag violated, from the field-level catalog - * (ADR-0114 D2) — `required`, `invalid_type`, `min_value`, `max_value` and - * `unknown_field`. Typed as `FieldErrorCode` rather than a local literal + * (ADR-0114 D2) — `required`, `invalid_type`, `invalid_value`, `min_value`, + * `max_value` and `unknown_field`. Typed as `FieldErrorCode` rather than a local literal * union for the reason `ActionParamIssue` gives: a screen field, an action * param and a record column must not drift into three vocabularies for the * same conditions. ⛔ Every member here is one the catalog ALREADY declares — @@ -73,6 +74,15 @@ const SCREEN_FIELD_VALUE_DOMAINS: ReadonlyMap