Skip to content

Commit 6fdffb7

Browse files
committed
fix(automation): recover the seeded row id from the bag, not just record.id
A non-default recordIdField makes the seeded row id a column other than id, so an action's declared recordIdParam gave that value a third key the record does not carry: not a column, not record.id, not a derivable name. It read as caller-supplied and the screen was SKIPPED, not paused, on a launch that supplied nothing. The dispatcher seeds the same row id under every id key it knows, so params.recordId (and the camelCase alias) recover it without knowing the action-level name. Also states the accepted cost: a screen field named recordId or the object-id alias is always collected interactively. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
1 parent 3ef5e6c commit 6fdffb7

4 files changed

Lines changed: 67 additions & 12 deletions

File tree

.changeset/screen-flow-headless-satisfaction.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Two independent halves:
1212
- **A screen the caller already answered no longer pauses.** When the caller named at least one of the screen's own fields and every `required` one has a value from that caller, there is nothing left to collect and the run continues. Optional fields may come from anywhere (including a declared `defaultValue`).
1313
- **`list_actions` publishes a flow action's inputs.** A `type: 'flow'` action's contract is its target flow's `isInput` variables, not `action.params`; those are now surfaced in declaration order with the `label`, `type`, `required` and select `options` of the screen field that collects each one. An action that declares its own `params[]` keeps them — the flow is read only where the action declared nothing.
1414

15-
**Interactive runs are unchanged.** A console launch carries the record it was launched from and that record's id — never a value for the screen's own fields — so the form renders exactly as before. That covers both shapes a launch actually supplies: a subject-record column named like one of the screen's fields, and a field named like one of the row-id keys the dispatch doors seed (`recordId`, the camelCase `<object>Id` alias, an action's declared `recordIdParam`), none of which counts as the caller answering the screen. Two screens never take the new path, because they declare nothing to satisfy and must not be answered vacuously: a message-only screen (no fields), and any screen whose author wrote `waitForInput: true`. `waitForInput: false` remains the wrong tool for the headless case — it skips the form for interactive users too.
15+
**Interactive runs are unchanged.** A console launch carries the record it was launched from and that record's id — never a value for the screen's own fields — so the form renders exactly as before. That covers both shapes a launch actually supplies: a subject-record column named like one of the screen's fields, and a field named like one of the row-id keys the dispatch doors seed (`recordId`, the camelCase `<object>Id` alias, an action's declared `recordIdParam`), none of which counts as the caller answering the screen — the two fixed names outright, and any value equal to the launched row's id whatever key carries it. One accepted cost of that: **a screen field named `recordId` or `<object>Id` is always collected interactively**, even from a headless caller. Two screens never take the new path, because they declare nothing to satisfy and must not be answered vacuously: a message-only screen (no fields), and any screen whose author wrote `waitForInput: true`. `waitForInput: false` remains the wrong tool for the headless case — it skips the form for interactive users too.
1616

1717
⚠️ One known gap, on the trigger-record leg only: a run continued from the **durable** suspended-run store judges against a JSON copy of its context, so a later wizard screen whose field collides with a **non-scalar** column (an array or object) of the trigger record can read as caller-supplied and be skipped. Scalar columns are unaffected, as is any run that has not been through a pause.
1818

content/docs/automation/flows.mdx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -753,7 +753,11 @@ The screen is treated as answered only when **all** of these hold:
753753
row id a launch carries does **not** count as naming a field: both dispatch
754754
doors seed it into the params bag under `recordId` and the camelCase
755755
`<object>Id` alias (and under an action's declared `recordIdParam`), and none
756-
of those is the caller supplying a screen value;
756+
of those is the caller supplying a screen value. The two fixed names are
757+
refused outright, and any value equal to the launched row's id is refused
758+
whatever key carries it — so **a screen field named `recordId` or `<object>Id`
759+
is always collected interactively**, even from a headless caller. Name a field
760+
you want a headless caller to fill something else;
757761
- every `required` field has a value (an empty or blank string is no value),
758762
supplied by that caller. A value that came from the trigger record, from an
759763
earlier node or from a declared `defaultValue` does not answer a `required`

packages/services/service-automation/src/builtin/screen-headless-satisfaction.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,40 @@ describe('screen headless satisfaction (#15705)', () => {
293293
expect(res.status).toBe('paused');
294294
});
295295

296+
/**
297+
* The shape review round 2 drove, and the one this file's author had
298+
* characterised as pause-only when it in fact SKIPPED.
299+
*
300+
* `recordIdField: 'token'` makes the seeded row id the record's `token`
301+
* column rather than its `id`, and `recordIdParam: 'sessionToken'` gives
302+
* that value a THIRD key the record does not carry. So: not a column (the
303+
* record leg cannot see it), not `record.id` (the row-id value leg, as
304+
* first written, could not see it either), and not a derivable name. It
305+
* read as caller-supplied on a launch that supplied nothing, and the run
306+
* completed with `{ sessionToken: 'tok_9' }`.
307+
*
308+
* What closes it: the dispatcher seeds the SAME row id under every one of
309+
* its id keys, so `params.recordId` still carries it and the value is
310+
* recoverable from the bag itself, without knowing the action-level name.
311+
*/
312+
it('CONTROL — a non-id `recordIdField` seeded under a third `recordIdParam` name does not satisfy it', async () => {
313+
const flow: any = followupFlow();
314+
flow.nodes[1].config.fields = [{ name: 'sessionToken', label: 'Session', type: 'text', required: true }];
315+
flow.variables = [{ name: 'sessionToken', type: 'text', isInput: true, isOutput: true }];
316+
register({}, flow);
317+
// The authentic `seedFlowActionParams` bag for
318+
// `recordIdField: 'token'` + `recordIdParam: 'sessionToken'`: the row
319+
// id is `record.token`, seeded under all three id keys.
320+
const session = { id: 'sess_1', token: 'tok_9', label: 'Web session' };
321+
const res = await engine.execute('schedule_followup', {
322+
record: session,
323+
object: 'crm_lead',
324+
params: { ...session, recordId: 'tok_9', crmLeadId: 'tok_9', sessionToken: 'tok_9' },
325+
} as AutomationContext);
326+
expect(res.status).toBe('paused');
327+
expect(res.screen?.nodeId).toBe('screen_1');
328+
});
329+
296330
it('trigger door: a genuine caller param still satisfies the screen', async () => {
297331
register();
298332
const res = await engine.execute('schedule_followup', triggerDoorContext('lead_1', {

packages/services/service-automation/src/screen-input-contract.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -221,20 +221,37 @@ function callerSupplied(
221221
if (!params || params[name] === undefined) return false;
222222
// Row-id seeds first: neither leg below can disprove them, because the
223223
// trigger door sets no record and none of these names is a column.
224-
if (name === 'recordId') return false;
225224
const objectName = typeof context?.object === 'string' ? context.object.trim() : '';
226225
// The camelCase alias both doors seed, derived the same way they derive it.
227-
if (objectName && name === `${objectName.replace(/_([a-z])/g, (_m: string, c: string) => c.toUpperCase())}Id`) {
228-
return false;
229-
}
226+
const aliasKey = objectName
227+
? `${objectName.replace(/_([a-z])/g, (_m: string, c: string) => c.toUpperCase())}Id`
228+
: undefined;
229+
if (name === 'recordId' || (aliasKey !== undefined && name === aliasKey)) return false;
230+
230231
const record = context?.record;
231-
// The action's declared `recordIdParam` may seed a THIRD name this executor
232-
// cannot know, always with the row id as its value — so the value is what
233-
// refuses it. A caller who genuinely sends the row id as a screen value only
234-
// loses the skip, which is this module's standing failure direction.
235-
if (record?.id !== undefined && Object.is(params[name], record.id)) return false;
232+
const value = params[name];
233+
// The action's declared `recordIdParam` seeds a THIRD name this executor
234+
// cannot know — action-level metadata is not on the context — so its VALUE
235+
// is what refuses it. The dispatcher seeds the SAME row id under every id
236+
// key it knows, which makes the id recoverable from the bag itself:
237+
//
238+
// - `params.recordId`, always seeded, and the only candidate that survives
239+
// a NON-DEFAULT `recordIdField`. That case is why this leg exists: with
240+
// `recordIdField: 'token'` the row id is `record.token`, so comparing
241+
// against `record.id` alone missed it and the screen was SKIPPED, not
242+
// paused — measured, then pinned.
243+
// - the camelCase alias's value, seeded the same way, as a second reading
244+
// of the same id for the case where a record column shadows `recordId`;
245+
// - `record.id`, which covers the record-bearing doors directly.
246+
//
247+
// A caller who genuinely sends the row id as a screen value only loses the
248+
// skip, which is this module's standing failure direction.
249+
const seededRowIds: unknown[] = [params.recordId, record?.id];
250+
if (aliasKey !== undefined) seededRowIds.push(params[aliasKey]);
251+
if (seededRowIds.some((id) => id !== undefined && Object.is(value, id))) return false;
252+
236253
if (!record || !Object.prototype.hasOwnProperty.call(record, name)) return true;
237-
return !Object.is(params[name], record[name]);
254+
return !Object.is(value, record[name]);
238255
}
239256

240257
/**

0 commit comments

Comments
 (0)