From 36585e7aeb69252bc65255df1200e5fffdfc91c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 17:07:45 +0000 Subject: [PATCH 1/6] wip(runtime): a declared record-id start input is loaded at every flow start door Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Utp6CbNMpDS92ifzhDw9vf --- packages/runtime/src/action-execution.ts | 6 +- packages/runtime/src/flow-start-admission.ts | 33 ++- .../flow-start-declared-record-reads.test.ts | 208 +++++++++++++++++- 3 files changed, 237 insertions(+), 10 deletions(-) diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index 71024cfd0c5..b60af4b9325 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -3858,9 +3858,9 @@ export type FlowStartRecordKeys = ReadonlyArray | undefined { * input as a record id of a FIXED object in these places, and only these, * across every region of the flow (`collectFlowGraphs`): * + * - the input's own declaration, `objectName` on the variable (#29425 ruling + * A): one id of that object, whatever the rest of the definition does with + * it. It is ADDED to the reads below, never in place of them: a run that + * also reads the input from another object reads that row too, so the + * caller must be able to read both. Being the author's word about the + * caller's value, it holds even where a `loop` or `map` later rebinds the + * name, and a callee's declaration is followed through the hand-offs below + * like any other callee read; * - a `get_record`, `update_record` or `delete_record` node with a literal * `objectName` whose `filter.id` is the input (one id), or whose * `filter.id.$eq` / `filter.id.$in` is (one id / a list of ids); @@ -283,8 +295,22 @@ async function declaredReadsOf( const flow = (await getFlow(flowName)) as FlowParsed | null | undefined; if (!flow || typeof flow !== 'object') return reads; const inputs = new Set(); + const add = (input: string, read: FlowDeclaredRecordRead): void => { + const list = reads.get(input) ?? []; + if (!list.some((r) => r.object === read.object && r.many === read.many)) list.push(read); + reads.set(input, list); + }; for (const variable of Array.isArray(flow.variables) ? flow.variables : []) { - if (variable && variable.isInput === true && typeof variable.name === 'string') inputs.add(variable.name); + if (!variable || variable.isInput !== true || typeof variable.name !== 'string') continue; + inputs.add(variable.name); + // The author's own word (`objectName` on the input, #29425 ruling A): + // the value is the id of one record of that object. It reads the + // caller's value, so a `loop` or `map` that rebinds the name further + // down does not withdraw it, and it sits beside every read the + // definition makes in plain sight, never in place of one. + if (typeof variable.objectName === 'string' && variable.objectName !== '') { + add(variable.name, { object: variable.objectName, many: false }); + } } const graphs = collectFlowGraphs(flow); // A `loop` or `map` node binds its item (and index) variable over the run's @@ -302,11 +328,6 @@ async function declaredReadsOf( } } if (inputs.size === 0) return reads; - const add = (input: string, read: FlowDeclaredRecordRead): void => { - const list = reads.get(input) ?? []; - if (!list.some((r) => r.object === read.object && r.many === read.many)) list.push(read); - reads.set(input, list); - }; const inputOf = (slot: unknown): string | undefined => { const name = handedOnVariable(slot); return name !== undefined && inputs.has(name) ? name : undefined; diff --git a/packages/runtime/src/flow-start-declared-record-reads.test.ts b/packages/runtime/src/flow-start-declared-record-reads.test.ts index 25f8d70656c..a8aedb3c897 100644 --- a/packages/runtime/src/flow-start-declared-record-reads.test.ts +++ b/packages/runtime/src/flow-start-declared-record-reads.test.ts @@ -24,6 +24,10 @@ * 6. **What stays as it was**: a readable id starts the run (the firing * control for every zero above), a read the definition does not declare, * the system principal. + * 7. **An input the author declares a record id** (`objectName` on the + * variable): loaded from the declared object at every door, read in plain + * sight or not, beside any visible read of another object, followed into a + * callee; the same input undeclared keeps today's rule (the control). * * The data double is ROW-SCOPED per object: each row is visible to its owner * and to nobody else, and no other id exists, so the real @@ -84,6 +88,18 @@ const BY_COMPUTED = 'contract_by_computed'; const SCREEN_EDIT = 'contract_screen_edit'; const SUBFLOW_PARENT = 'contract_subflow_parent'; const MAP_PARENT = 'contract_map_parent'; +const DECLARED = 'contract_declared'; +const UNDECLARED = 'contract_undeclared'; +const DECLARED_AND_READ = 'case_declared_contract_read'; +const DECLARED_CALLEE_PARENT = 'contract_declared_callee_parent'; + +/** A flow whose start input `name` the author declares a record id of `object` (`objectName`). */ +function declaring(def: FlowDef, input: string, object: string): FlowDef { + return { + ...def, + variables: (def.variables as Array>).map((v) => (v.name === input ? { ...v, objectName: object } : v)), + }; +} const FLOWS: Record = { [BY_RECORD]: flow(BY_RECORD, ['recordId'], [getRow(CONTRACT, { id: cel('recordId') })], { runAs: 'system' }), @@ -99,6 +115,19 @@ const FLOWS: Record = { [MAP_PARENT]: flow(MAP_PARENT, ['contractIds'], [ { id: 'each', type: 'map', label: 'Each', config: { collection: cel('contractIds'), flowName: BY_CUSTOM, input: { contractId: cel('item') } } }, ]), + // The declared input is read by no node: only the declaration says it is a record id. + [DECLARED]: declaring(flow(DECLARED, ['contractId'], [], { runAs: 'system' }), 'contractId', CONTRACT), + // The control: the same flow without the declaration. + [UNDECLARED]: flow(UNDECLARED, ['contractId'], [], { runAs: 'system' }), + // Declared an id of `crm_case`, and read in plain sight as an id of `crm_contract`. + [DECLARED_AND_READ]: declaring( + flow(DECLARED_AND_READ, ['subjectId'], [getRow(CONTRACT, { id: cel('subjectId') })], { runAs: 'system' }), + 'subjectId', CASE, + ), + // Hands its own undeclared input to the declared input of a callee. + [DECLARED_CALLEE_PARENT]: flow(DECLARED_CALLEE_PARENT, ['target'], [ + { id: 'sub', type: 'subflow', label: 'Sub', config: { flowName: DECLARED, input: { contractId: cel('target') } } }, + ]), }; /** The acting principal, as `resolveExecutionContext` builds one. */ @@ -115,7 +144,13 @@ const CUSTOM_ACTION = { params: [{ name: 'contractId', label: 'Contract', type: 'text' }], }; const SCRIPT_ACTION = { name: 'touch_case', label: 'Touch', objectName: CASE, type: 'script', execute: 'return 1' }; -const CASE_DEF = { name: CASE, actions: [CONTRACT_ACTION, CUSTOM_ACTION, SCRIPT_ACTION] }; +const DECLARED_ACTION = { + name: 'open_declared_contract', label: 'Open declared contract', objectName: CASE, type: 'flow', target: DECLARED, + params: [{ name: 'contractId', label: 'Contract', type: 'text' }], + ai: { exposed: true, description: 'Open the declared contract.' }, +}; +const UNDECLARED_ACTION = { ...DECLARED_ACTION, name: 'open_undeclared_contract', label: 'Open undeclared contract', target: UNDECLARED }; +const CASE_DEF = { name: CASE, actions: [CONTRACT_ACTION, CUSTOM_ACTION, SCRIPT_ACTION, DECLARED_ACTION, UNDECLARED_ACTION] }; const CONTRACT_DEF = { name: CONTRACT, actions: [] }; const schemaOf = (n: string) => (n === CASE ? CASE_DEF : n === CONTRACT ? CONTRACT_DEF : undefined); @@ -574,3 +609,174 @@ describe('the REST action door refuses a routed record that is not one id, befor expect(reads(h).length).toBeGreaterThan(0); }); }); + +// ─────────────────────────────────────────────────────────────────────────── +// 7. An input the author declares a record id (`objectName`, #29425 ruling A) +// ─────────────────────────────────────────────────────────────────────────── + +describe('a start input declared a record id is loaded from the declared object at every door, before the run exists', () => { + const readsOf = (name: string) => + flowDeclaredRecordReads({ getFlow: async (n: string) => FLOWS[n] ?? null }, name).then((m) => Object.fromEntries(m)); + + /** The declared `type: 'flow'` endpoint door, on the real `callData`. */ + async function endpoint(target: string, body: unknown) { + const declared = ApiEndpointSchema.parse({ + name: `${target}_open`, path: `/api/v1/apps/${target}/open`, method: 'POST', type: 'flow', target, + }); + const ql = makeQl(); + const automation = makeAutomation(); + const deps: any = { resolveService: async () => undefined, getObjectQL: async () => ql }; + const protocolContext: any = { request: {}, environmentId: 'platform', executionContext: ec(MEMBER) }; + const answer = await executeEndpointTarget( + buildEndpointExecutionContext({ + request: { method: 'POST', path: declared.path, query: {}, headers: {}, body }, + match: { endpoint: declared, params: {} }, + executionContext: ec(MEMBER) as any, + environmentId: 'platform', + }), + { + automationService: automation, + callData: (action, params, dataDriver, scopeId, execCtx) => + callData(deps, protocolContext, action, params, dataDriver, scopeId, execCtx), + }, + ); + return { status: answer.status, error: (answer.body as any)?.error ?? {}, ql, automation }; + } + + /** + * One start of `flowName` with `contractId` set to `value`, at each door that + * starts a flow by name, read back as status + error + what was loaded + runs. + */ + const DOORS: Record Promise<{ status: unknown; error: any; ql: any; automation: any }>> = { + 'POST /automation/:name/trigger': async (flowName, value) => { + const h = boot(ec(MEMBER)); + const res = await h.trigger(flowName, { params: { contractId: value } }); + return { status: statusOf(res), error: errorOf(res), ql: h.ql, automation: h.automation }; + }, + 'legacy POST /automation/trigger/:name': async (flowName, value) => { + const h = boot(ec(MEMBER)); + const res = await h.trigger(flowName, { params: { contractId: value } }, true); + return { status: statusOf(res), error: errorOf(res), ql: h.ql, automation: h.automation }; + }, + 'a declared `type: flow` endpoint': (flowName, value) => endpoint(flowName, { params: { contractId: value } }), + 'REST POST /actions/:object/:action': async (flowName, value) => { + const h = boot(ec(MEMBER)); + const action = flowName === DECLARED ? DECLARED_ACTION : UNDECLARED_ACTION; + const res = await h.actions(`/${CASE}/${action.name}/${BOTH}`, { params: { contractId: value } }); + return { status: statusOf(res), error: errorOf(res), ql: h.ql, automation: h.automation }; + }, + 'MCP run_action': async (flowName, value) => { + const h = boot(ec(MEMBER)); + const action = flowName === DECLARED ? DECLARED_ACTION : UNDECLARED_ACTION; + try { + await h.runAction(action.name, { objectName: CASE, recordId: BOTH, params: { contractId: value } }); + return { status: 200, error: {}, ql: h.ql, automation: h.automation }; + } catch (err: any) { + return { status: err?.status, error: err ?? {}, ql: h.ql, automation: h.automation }; + } + }, + }; + /** Every `crm_contract` row the start loaded. */ + const contractReads = (ql: any) => ql.find.mock.calls.filter((c: any[]) => c[0] === CONTRACT).map((c: any[]) => c[1]?.where?.id); + + it('the reading: a declared input is one id of the declared object, read by no node', async () => { + expect(await readsOf(DECLARED)).toEqual({ contractId: [{ object: CONTRACT, many: false }] }); + expect(await readsOf(UNDECLARED)).toEqual({}); + }); + + it('the reading: a declaration sits beside a read in plain sight of another object, never in place of it', async () => { + expect(await readsOf(DECLARED_AND_READ)).toEqual({ + subjectId: [{ object: CASE, many: false }, { object: CONTRACT, many: false }], + }); + }); + + it('the reading: a callee\'s declared input is followed through the hand-off', async () => { + expect(await readsOf(DECLARED_CALLEE_PARENT)).toEqual({ target: [{ object: CONTRACT, many: false }] }); + }); + + it('the reading: a loop that rebinds the name does not withdraw the declaration', async () => { + const looped = declaring(flow('looped', ['x'], [ + { id: 'loop', type: 'loop', label: 'Loop', config: { collection: [1], iteratorVariable: 'x', body: { nodes: [getRow(CASE, { id: cel('x') })], edges: [] } } }, + ]), 'x', CONTRACT); + const got = await flowDeclaredRecordReads({ getFlow: async () => looped }, 'looped'); + expect(Object.fromEntries(got)).toEqual({ x: [{ object: CONTRACT, many: false }] }); + }); + + for (const [door, start] of Object.entries(DOORS)) { + describe(door, () => { + it('a declared input naming a record the caller can read: the run starts, the firing control', async () => { + const got = await start(DECLARED, BOTH); + + expect(got.status, JSON.stringify(got.error)).toBe(200); + expect(got.automation.execute).toHaveBeenCalledTimes(1); + expect(contractReads(got.ql)).toContain(BOTH); + }); + + it.each([ + ['the caller cannot read', CONTRACT_OTHER], + ['does not exist', GHOST], + ])('a declared input naming a record the caller %s: 404 RECORD_NOT_FOUND, no run', async (_label, id) => { + const got = await start(DECLARED, id); + + expect(got.status).toBe(404); + expect(got.error.code).toBe('RECORD_NOT_FOUND'); + expect(contractReads(got.ql)).toContain(id); + expect(got.automation.execute).not.toHaveBeenCalled(); + }); + + it.each([ + ['an object', { id: BOTH }], + ['a list', [BOTH]], + ['a boolean', true], + ])('a declared input given %s: 400 VALIDATION_FAILED naming the input, nothing loaded from the declared object, no run', async (_label, value) => { + const got = await start(DECLARED, value); + + expect(got.status).toBe(400); + expect(got.error.code).toBe('VALIDATION_FAILED'); + expect(String(got.error.message)).toContain('`contractId`'); + expect(contractReads(got.ql)).toHaveLength(0); + expect(got.automation.execute).not.toHaveBeenCalled(); + }); + + it('the same input undeclared, read by no node: not judged, the run starts on an id the caller cannot read (today\'s rule)', async () => { + const got = await start(UNDECLARED, CONTRACT_OTHER); + + expect(got.status, JSON.stringify(got.error)).toBe(200); + expect(got.automation.execute).toHaveBeenCalledTimes(1); + expect(contractReads(got.ql)).toHaveLength(0); + }); + }); + } + + it('declared one object and read from another: the caller must read the row in both', async () => { + const inCaseOnly = boot(ec(MEMBER)); + const refused = await inCaseOnly.trigger(DECLARED_AND_READ, { params: { subjectId: CASE_ONLY } }); + expect(statusOf(refused)).toBe(404); + expect(errorOf(refused).code).toBe('RECORD_NOT_FOUND'); + expect(reads(inCaseOnly)).toEqual(expect.arrayContaining([[CASE, CASE_ONLY], [CONTRACT, CASE_ONLY]])); + expect(inCaseOnly.automation.execute).not.toHaveBeenCalled(); + + const inBoth = boot(ec(MEMBER)); + const started = await inBoth.trigger(DECLARED_AND_READ, { params: { subjectId: BOTH } }); + expect(statusOf(started), JSON.stringify(started?.response?.body)).toBe(200); + expect(inBoth.automation.execute).toHaveBeenCalledTimes(1); + }); + + it('a parent that hands its input to a callee\'s declared input is judged by the callee\'s declaration', async () => { + const h = boot(ec(MEMBER)); + const res = await h.trigger(DECLARED_CALLEE_PARENT, { params: { target: CONTRACT_OTHER } }); + + expect(statusOf(res)).toBe(404); + expect(errorOf(res).code).toBe('RECORD_NOT_FOUND'); + expect(h.automation.execute).not.toHaveBeenCalled(); + }); + + it('the system principal starts on any declared id, nothing loaded', async () => { + const h = boot({ isSystem: true }); + const res = await h.trigger(DECLARED, { params: { contractId: CONTRACT_OTHER } }); + + expect(statusOf(res)).toBe(200); + expect(h.automation.execute).toHaveBeenCalledTimes(1); + expect(reads(h)).toHaveLength(0); + }); +}); From 30c3ee08fbe4d1bff348d4deb0b088421a12a085 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 17:15:22 +0000 Subject: [PATCH 2/6] wip: flip the objectName liveness row, docs and changeset Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Utp6CbNMpDS92ifzhDw9vf --- .changeset/29425-declared-record-input.md | 23 ++++++++++++ content/docs/automation/flows.mdx | 36 ++++++++++--------- .../flow-start-declared-record-reads.test.ts | 15 ++++++-- packages/spec/liveness/flow.json | 10 +++--- packages/spec/src/automation/flow.zod.ts | 36 ++++++++----------- 5 files changed, 73 insertions(+), 47 deletions(-) create mode 100644 .changeset/29425-declared-record-input.md diff --git a/.changeset/29425-declared-record-input.md b/.changeset/29425-declared-record-input.md new file mode 100644 index 00000000000..475bc4af1aa --- /dev/null +++ b/.changeset/29425-declared-record-input.md @@ -0,0 +1,23 @@ +--- +'@objectstack/runtime': patch +'@objectstack/spec': patch +--- + +fix(runtime): a flow start input declared a record id (`objectName`) is loaded in the caller's own scope from the declared object at every door that starts the flow by name, before the run exists + +Clause-②: yes (narrowing) — a start that ran before is now refused when it hands a declared record-id input an id the caller cannot read in the declared object, an id that names no record there, or a value that is not one id. No flow shipped in this repository declares the key, and an input without the declaration answers exactly as before. +Metadata-protocol: none + + + +**The rule.** A flow start input may declare that its value is the id of a record of a named object, with `objectName` on the variable (`{ name: 'accountId', type: 'text', isInput: true, objectName: 'crm_account' }`). Every door that starts the flow by name now loads such an input from the declared object in the caller's own scope before the run exists, whether or not the flow's definition reads the input in plain sight. A record the caller cannot read there and an id that names no record get the same answer, `404 RECORD_NOT_FOUND` (the action door's own refusal), and no run is created. A value that is not one id (a string or a number) is refused `400` naming the input: `VALIDATION_FAILED` from the start check, or, at an action whose param declares a type, `VALIDATION_ERROR` from that param's own contract, which answers first. The system principal is not judged. + +**Which doors.** `POST /api/v1/automation/:name/trigger` and the legacy `POST /api/v1/automation/trigger/:name`; a declared endpoint of `type: 'flow'`; an action of `type: 'flow'` over the REST action route and the MCP `run_action` tool. All of them ask the one check they already shared for inputs a definition reads in plain sight. + +**Beside the definition's own reads, never in place of them.** An input declared a record id of one object and also read by the definition as an id of another (a `get_record` node's `filter.id`, say) is loaded from both, so the caller must be able to read both rows. A parent flow that hands its input on, unchanged, to a `subflow` or `map` callee whose input carries the declaration is judged by that declaration too. A `loop` or `map` that later rebinds the name does not withdraw the declaration: it is the author's word about the value the caller sent. + +**What stays as it was.** An input without the declaration keeps the visible-read rule: it is judged only where the definition reads it as a record id. A start whose caller can read the declared record starts as before. + +**If a start now answers 404 or 400.** Hand the declared input the id of a record the caller can read in the object the input names, or start the flow as the system principal. + +`@objectstack/spec`: the `objectName` describe on a flow variable no longer says that no door applies it, and its liveness row is `live`, so `os validate` no longer warns `liveness-planned-property` on a flow that declares it. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index af5190ecebf..49fd0f96830 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -2184,23 +2184,25 @@ export const CloseAccount = defineFlow({ }); ``` - -**Planned — no door applies this yet.** The declaration parses, is refused on a -variable that is not an input, and is linted, but no start door loads a -declared input today: a start that hands it an id the caller cannot read still -runs. `os validate` warns on every flow that writes it -(`liveness-planned-property`). The contract below is what the start doors will -enforce. - - -The contract: every door that starts the flow by name (the trigger route, an -action of `type: 'flow'` over REST and MCP `run_action`, a declared endpoint of +Every door that starts the flow by name (the trigger route, an action of +`type: 'flow'` over REST and MCP `run_action`, a declared endpoint of `type: 'flow'`) loads a declared input from the named object in the caller's -own scope before the run exists. An id the caller cannot read there is refused -`404` `RECORD_NOT_FOUND`; a value that is not an id is refused `400` -`VALIDATION_FAILED` naming the input. An input without the declaration is -judged as a record only where the flow's own definition reads it as a record id -(see the `404` row under [running a flow via the API](#run-a-flow-via-api)). +own scope before the run exists. An id the caller cannot read there, or one +that names no record, is refused `404` `RECORD_NOT_FOUND`, and no run is +created; a value that is not an id is refused `400` naming the input +(`VALIDATION_FAILED`; at an action, a param's own declared type answers first +with `VALIDATION_ERROR`). An input without the declaration is judged as a +record only where the flow's own definition reads it as a record id (see the +`404` row under [running a flow via the API](#run-a-flow-via-api)). The system +principal is not judged. + +- **Beside the definition's own reads, never in place of them.** When the flow + also reads the input as an id of another object (a `get_record` node's + `filter.id`, say), the door loads it from both, and the caller must be able + to read both rows. +- **Followed into sub-flows.** A parent that hands its input on, unchanged, to + a `subflow` or `map` callee whose input carries the declaration is judged by + that declaration too. - **Inputs only.** A variable that is not `isInput: true` receives nothing from a caller, so `objectName` there is refused at parse. @@ -2606,7 +2608,7 @@ door's own admission check, made before anything is dispatched: | `404` | — | No flow by that name in this deployment | Check the name; nothing was dispatched | | `403` | `PERMISSION_DENIED` | The caller is not the system principal, and the flow is declared `runAs: 'system'` with a self-triggered `type` (`autolaunched`, `record_change`, `schedule`). The same answer at every door that starts a flow by name: this route, a `type: 'flow'` action (REST and MCP `run_action`) and a declared `type: 'flow'` endpoint | Let it run on its own trigger, or call it from a parent flow's `subflow` node. If members are meant to start it, declare it `type: 'screen'` (or `'api'` for a signed inbound hook) | | `403` | `PERMISSION_DENIED` | The flow declares `requiredPermissions` and the caller, not the system principal, does not hold every capability it names. Checked after the row above, at the same doors; the message names the declared and the missing capabilities | Grant the capability through a permission set's `systemPermissions`, or start the flow as someone who holds it | -| `404` | `RECORD_NOT_FOUND` | The start names a record (`recordId`, or the object's id alias such as `crmLeadId`) that the caller, not the system principal, cannot read in its own scope, or that does not exist: the two get one answer, the one the action door gives. Checked after the two rows above, at the same doors. A record key that is not one id, or a record named with no `objectName` for a flow declared `runAs: 'system'`, is refused `400` `VALIDATION_FAILED` naming the key. Where the flow's own definition reads a start input as the id of a record of a fixed object (a `get_record`, `update_record` or `delete_record` node with a literal `objectName` whose `filter.id` or `filter.id.$eq` is the input, or whose `filter.id.$in` is; a `screen` node with a literal `objectName` whose `recordId` is the input; a `subflow` or `map` node that hands the input to a callee input the callee reads so), that input is also loaded in the caller's own scope from THAT object, whatever object the start names, or none, and whatever the flow runs as: a value the caller cannot read there gets this `404`, and a value that is not one id (or a list of ids, where the definition reads a list) is refused `400` `VALIDATION_FAILED` naming the input | Start the flow on a record the caller can read, and name the record's object (`objectName`) beside `recordId`. Hand an input the flow reads as a record id the id of a record the caller can read in the object the flow reads it from, in the shape the flow reads (one id, or a list of ids) | +| `404` | `RECORD_NOT_FOUND` | The start names a record (`recordId`, or the object's id alias such as `crmLeadId`) that the caller, not the system principal, cannot read in its own scope, or that does not exist: the two get one answer, the one the action door gives. Checked after the two rows above, at the same doors. A record key that is not one id, or a record named with no `objectName` for a flow declared `runAs: 'system'`, is refused `400` `VALIDATION_FAILED` naming the key. Where the flow's own definition reads a start input as the id of a record of a fixed object (a `get_record`, `update_record` or `delete_record` node with a literal `objectName` whose `filter.id` or `filter.id.$eq` is the input, or whose `filter.id.$in` is; a `screen` node with a literal `objectName` whose `recordId` is the input; a `subflow` or `map` node that hands the input to a callee input the callee reads so), that input is also loaded in the caller's own scope from THAT object, whatever object the start names, or none, and whatever the flow runs as: a value the caller cannot read there gets this `404`, and a value that is not one id (or a list of ids, where the definition reads a list) is refused `400` `VALIDATION_FAILED` naming the input. An input the flow declares a record id (`objectName` on the variable, [above](#a-start-input-that-is-a-record-id)) is loaded from the declared object the same way, whether or not the definition reads it | Start the flow on a record the caller can read, and name the record's object (`objectName`) beside `recordId`. Hand an input the flow reads as a record id the id of a record the caller can read in the object the flow reads it from, in the shape the flow reads (one id, or a list of ids) | | `409` | `FLOW_DISABLED` | The flow exists but is switched off | Enable it — the identical request then succeeds | | `422` | `FLOW_NO_START_NODE` | The stored definition has no `start` node | Fix the flow; retrying cannot help | | `422` | `FLOW_INPUT_SCHEMA_INVALID` | A node's `config` violates the `inputSchema` its own definition declares | Fix the flow; the refusal is non-retryable — even under `errorHandling.strategy: 'retry'` the engine refuses once and never enters the retry loop | diff --git a/packages/runtime/src/flow-start-declared-record-reads.test.ts b/packages/runtime/src/flow-start-declared-record-reads.test.ts index a8aedb3c897..a95e7e3c84d 100644 --- a/packages/runtime/src/flow-start-declared-record-reads.test.ts +++ b/packages/runtime/src/flow-start-declared-record-reads.test.ts @@ -676,6 +676,15 @@ describe('a start input declared a record id is loaded from the declared object } }, }; + /** + * The code a value that is not an id is refused with, per door. The trigger + * routes and the endpoint hand every param to the start check, which answers + * `VALIDATION_FAILED`. The action doors type each declared param first + * (ADR-0104 D2), so a `text` param holding an object, a list or a boolean + * is refused there, `400 VALIDATION_ERROR` naming the same param, before + * the start check is reached: one more gate in front, never a gap. + */ + const NOT_AN_ID_CODE = (door: string): string => (door.includes('action') ? 'VALIDATION_ERROR' : 'VALIDATION_FAILED'); /** Every `crm_contract` row the start loaded. */ const contractReads = (ql: any) => ql.find.mock.calls.filter((c: any[]) => c[0] === CONTRACT).map((c: any[]) => c[1]?.where?.id); @@ -728,12 +737,12 @@ describe('a start input declared a record id is loaded from the declared object ['an object', { id: BOTH }], ['a list', [BOTH]], ['a boolean', true], - ])('a declared input given %s: 400 VALIDATION_FAILED naming the input, nothing loaded from the declared object, no run', async (_label, value) => { + ])('a declared input given %s: 400 naming the input, nothing loaded from the declared object, no run', async (_label, value) => { const got = await start(DECLARED, value); expect(got.status).toBe(400); - expect(got.error.code).toBe('VALIDATION_FAILED'); - expect(String(got.error.message)).toContain('`contractId`'); + expect(got.error.code).toBe(NOT_AN_ID_CODE(door)); + expect(String(got.error.message)).toContain('contractId'); expect(contractReads(got.ql)).toHaveLength(0); expect(got.automation.execute).not.toHaveBeenCalled(); }); diff --git a/packages/spec/liveness/flow.json b/packages/spec/liveness/flow.json index 9668e6a5893..0291e4bc1ee 100644 --- a/packages/spec/liveness/flow.json +++ b/packages/spec/liveness/flow.json @@ -91,12 +91,10 @@ "evidence": "packages/services/service-automation/src/dag-traversal.ts#seedDeclaredVariables (bound when no param supplies the name, #4697)" }, "objectName": { - "status": "planned", - "verifiedAt": "2026-11-02", - "evidence": "packages/spec/src/automation/flow.zod.ts#FlowVariableSchema (the superRefine that refuses `objectName` on a variable that is not an input — the one place the key is read today)", - "authorWarn": true, - "authorHint": "Keep it, but nothing reads it yet: no start door loads a declared input from its object until the start doors ship that check, so an id the caller cannot read still starts a run.", - "note": "PLANNED RECORD (#29442, the spec half of #29425 ruling A 6580257050): an input's declaration that its value is the id of one record of the named object. Parsed, refused on a non-input (the evidence above) and linted (`flow-input-unknown-object` in packages/lint/src/validate-flow-trigger-readiness.ts). No door reads it: the start doors' third question (packages/runtime/src/flow-start-admission.ts, `flowDeclaredRecordReads` / `refuseUnreadableFlowStartRecords` in action-execution.ts) judges only inputs the definition reads in plain sight. Carrier: the #29425 server half, Blocked-by #29442 (every start door loads a declared input in the caller's scope, 404 RECORD_NOT_FOUND / 400 VALIDATION_FAILED). Flip to `live` on the commit that cites that reader, drop `authorWarn` / `authorHint` and the describe's planned sentence in that same edit." + "status": "live", + "verifiedAt": "2026-11-05", + "evidence": "packages/runtime/src/flow-start-admission.ts#flowDeclaredRecordReads (a declared input is one read of the declared object, asked by `refuseUnreadableFlowStartRecords` in action-execution.ts at every door that starts a flow by name: loaded in the caller's scope before the run exists, 404 RECORD_NOT_FOUND / 400 naming the input)", + "note": "#29425 ruling A: an input's declaration that its value is the id of one record of the named object. Added beside every read the definition makes in plain sight, never in place of one, and followed into a callee through subflow / map hand-offs. Also refused on a non-input (packages/spec/src/automation/flow.zod.ts#FlowVariableSchema) and linted (`flow-input-unknown-object` in packages/lint/src/validate-flow-trigger-readiness.ts)." } } }, diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index f94e4bb945c..3219078af4c 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -233,23 +233,17 @@ export const FLOW_PAUSE_CAPABLE_NODE_TYPES: readonly string[] = [ * * ## `objectName` — a start input that is a record id (#29425 ruling A) * - * [planned] An input may declare that its value is the id of a record of a - * named object. The contract the declaration states: every door that starts - * the flow by name judges the input as a record, whether or not the definition - * reads it as one in plain sight — the door loads it from the named object in - * the caller's own scope before the run exists, refusing an unreadable id - * `404 RECORD_NOT_FOUND` and a value that is not an id - * `400 VALIDATION_FAILED` naming the input. An input without the declaration - * keeps the visible-read rule (`flowDeclaredRecordReads` in - * `packages/runtime/src/flow-start-admission.ts`). - * - * ⛔ No door applies it yet. This is the spec half; the start doors read the - * declaration in the ruling's server half (Blocked-by #29442). Until then the - * key is parsed, refused on a non-input (below) and linted - * (`flow-input-unknown-object`), and its liveness row is `planned` with an - * author warning (`packages/spec/liveness/flow.json` › `variables.objectName`), - * so an author who writes it is told. Flip the row, drop the warning and the - * describe's planned sentence in the commit that lands the reader. + * An input may declare that its value is the id of a record of a named + * object. Every door that starts the flow by name judges the input as a + * record, whether or not the definition reads it as one in plain sight: the + * door loads it from the named object in the caller's own scope before the + * run exists, refusing an unreadable id `404 RECORD_NOT_FOUND` and a value + * that is not an id `400` naming the input. The declaration adds that load + * beside any read the definition makes in plain sight, never in place of one. + * An input without the declaration keeps the visible-read rule. The reader is + * `flowDeclaredRecordReads` in `packages/runtime/src/flow-start-admission.ts`; + * the key is also refused on a non-input (below) and linted + * (`flow-input-unknown-object`). * * The spelling is `objectName`, the word the CRUD nodes already use for the * object they read, and ⛔ not a `type: 'record'` beside it: `type` already @@ -299,14 +293,14 @@ export const FlowVariableSchema = lazySchema(() => strictObject( .meta({ title: 'Default Value' }), objectName: z.string().regex(/^[a-z_][a-z0-9_]*$/).optional() .describe( - '[planned] Declares this input a record reference: its value is the id of one record of the named object ' + + 'Declares this input a record reference: its value is the id of one record of the named object ' + '(the object\'s snake_case name, e.g. `crm_account`). Only on an input (`isInput: true`). The contract: ' + 'every door that starts the flow by name (the trigger route, an action of type flow over REST and MCP ' + 'run_action, a declared endpoint of type flow) loads a declared input from this object in the caller\'s ' + 'own scope before the run exists; an id the caller cannot read there is refused 404 RECORD_NOT_FOUND, and ' + - 'a value that is not an id is refused 400 VALIDATION_FAILED naming the input. No door applies it yet: ' + - 'today the key is parsed and checked, and a start with an id the caller cannot read still runs. An input ' + - 'without this declaration is judged as a record only where the flow\'s own definition reads it as a ' + + 'a value that is not an id is refused 400 naming the input. The load is added beside any read the ' + + 'flow\'s own definition makes of the input, never in place of one. An input without this declaration ' + + 'is judged as a record only where the flow\'s own definition reads it as a ' + 'record id. `type` stays the data type: the variable holds the id the caller sent.', ) .meta({ title: 'Record Object' }), From 4541d7c337ca9d2162669b9fdc6f43f8695764db Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 17:19:43 +0000 Subject: [PATCH 3/6] wip: regenerate the reference docs and liveness counts Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Utp6CbNMpDS92ifzhDw9vf --- content/docs/references/api/automation-api.mdx | 2 +- content/docs/references/automation/flow.mdx | 4 ++-- packages/spec/liveness/state-counts/flow.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index c1c9db65dc9..868ec94b982 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -200,7 +200,7 @@ const result = AutomationApiErrorCode.parse(data); | **isInput** | `boolean` | optional (default: `false`) | Is input parameter | | **isOutput** | `boolean` | optional (default: `false`) | Is output parameter | | **defaultValue** | `any` | optional | Value bound at run start when no parameter supplies one — this is what makes a declared variable always bound. An explicitly supplied param wins, including `false` and `null`; the boundary is `params[name] !== undefined`. | -| **objectName** | `string` | optional | [planned] Declares this input a record reference: its value is the id of one record of the named object (the object's snake_case name, e.g. `crm_account`). Only on an input (`isInput: true`). The contract: every door that starts the flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow) loads a declared input from this object in the caller's own scope before the run exists; an id the caller cannot read there is refused 404 RECORD_NOT_FOUND, and a value that is not an id is refused 400 VALIDATION_FAILED naming the input. No door applies it yet: today the key is parsed and checked, and a start with an id the caller cannot read still runs. An input without this declaration is judged as a record only where the flow's own definition reads it as a record id. `type` stays the data type: the variable holds the id the caller sent. | +| **objectName** | `string` | optional | Declares this input a record reference: its value is the id of one record of the named object (the object's snake_case name, e.g. `crm_account`). Only on an input (`isInput: true`). The contract: every door that starts the flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow) loads a declared input from this object in the caller's own scope before the run exists; an id the caller cannot read there is refused 404 RECORD_NOT_FOUND, and a value that is not an id is refused 400 naming the input. The load is added beside any read the flow's own definition makes of the input, never in place of one. An input without this declaration is judged as a record only where the flow's own definition reads it as a record id. `type` stays the data type: the variable holds the id the caller sent. | ### Nested Shape: `CreateFlowRequest.nodes[number]` diff --git a/content/docs/references/automation/flow.mdx b/content/docs/references/automation/flow.mdx index ab57f330d9a..9a72e517446 100644 --- a/content/docs/references/automation/flow.mdx +++ b/content/docs/references/automation/flow.mdx @@ -61,7 +61,7 @@ const result = FlowSchema.parse(data); | **isInput** | `boolean` | optional (default: `false`) | Is input parameter | | **isOutput** | `boolean` | optional (default: `false`) | Is output parameter | | **defaultValue** | `any` | optional | Value bound at run start when no parameter supplies one — this is what makes a declared variable always bound. An explicitly supplied param wins, including `false` and `null`; the boundary is `params[name] !== undefined`. | -| **objectName** | `string` | optional | [planned] Declares this input a record reference: its value is the id of one record of the named object (the object's snake_case name, e.g. `crm_account`). Only on an input (`isInput: true`). The contract: every door that starts the flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow) loads a declared input from this object in the caller's own scope before the run exists; an id the caller cannot read there is refused 404 RECORD_NOT_FOUND, and a value that is not an id is refused 400 VALIDATION_FAILED naming the input. No door applies it yet: today the key is parsed and checked, and a start with an id the caller cannot read still runs. An input without this declaration is judged as a record only where the flow's own definition reads it as a record id. `type` stays the data type: the variable holds the id the caller sent. | +| **objectName** | `string` | optional | Declares this input a record reference: its value is the id of one record of the named object (the object's snake_case name, e.g. `crm_account`). Only on an input (`isInput: true`). The contract: every door that starts the flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow) loads a declared input from this object in the caller's own scope before the run exists; an id the caller cannot read there is refused 404 RECORD_NOT_FOUND, and a value that is not an id is refused 400 naming the input. The load is added beside any read the flow's own definition makes of the input, never in place of one. An input without this declaration is judged as a record only where the flow's own definition reads it as a record id. `type` stays the data type: the variable holds the id the caller sent. | ### Nested Shape: `Flow.nodes[number]` @@ -231,7 +231,7 @@ const result = FlowSchema.parse(data); | **isInput** | `boolean` | optional (default: `false`) | Is input parameter | | **isOutput** | `boolean` | optional (default: `false`) | Is output parameter | | **defaultValue** | `any` | optional | Value bound at run start when no parameter supplies one — this is what makes a declared variable always bound. An explicitly supplied param wins, including `false` and `null`; the boundary is `params[name] !== undefined`. | -| **objectName** | `string` | optional | [planned] Declares this input a record reference: its value is the id of one record of the named object (the object's snake_case name, e.g. `crm_account`). Only on an input (`isInput: true`). The contract: every door that starts the flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow) loads a declared input from this object in the caller's own scope before the run exists; an id the caller cannot read there is refused 404 RECORD_NOT_FOUND, and a value that is not an id is refused 400 VALIDATION_FAILED naming the input. No door applies it yet: today the key is parsed and checked, and a start with an id the caller cannot read still runs. An input without this declaration is judged as a record only where the flow's own definition reads it as a record id. `type` stays the data type: the variable holds the id the caller sent. | +| **objectName** | `string` | optional | Declares this input a record reference: its value is the id of one record of the named object (the object's snake_case name, e.g. `crm_account`). Only on an input (`isInput: true`). The contract: every door that starts the flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow) loads a declared input from this object in the caller's own scope before the run exists; an id the caller cannot read there is refused 404 RECORD_NOT_FOUND, and a value that is not an id is refused 400 naming the input. The load is added beside any read the flow's own definition makes of the input, never in place of one. An input without this declaration is judged as a record only where the flow's own definition reads it as a record id. `type` stays the data type: the variable holds the id the caller sent. | --- diff --git a/packages/spec/liveness/state-counts/flow.md b/packages/spec/liveness/state-counts/flow.md index b2a583a8654..b1518465765 100644 --- a/packages/spec/liveness/state-counts/flow.md +++ b/packages/spec/liveness/state-counts/flow.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `flow` | 40 | 0 | 0 | 0 | 3 | 43 | +| `flow` | 41 | 0 | 0 | 0 | 2 | 43 | From 96c505a16487988d4fa6e124c41e38ac50b69b37 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 17:20:46 +0000 Subject: [PATCH 4/6] wip(lint): the declared record input row is live, so the lint goes quiet on it Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Utp6CbNMpDS92ifzhDw9vf --- .../lint/src/lint-liveness-properties.test.ts | 48 +++++++++++-------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index 2792c5aa0ee..74be56ffb0a 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -158,11 +158,26 @@ describe('lintLivenessProperties', () => { expect(ruleOf(findings, 'nodes.outputSchema')).toBeUndefined(); }); - // #29442 — `flow.variables[].objectName` is declared before any start door - // reads it, so its row is `planned` + `authorWarn`. Pinned through the SHIPPED + /** + * A stack authoring a drilled row the shipped ledger still holds `planned` + + * `authorWarn` (an app navigation entry's `dataSource`): the control that + * the walk below reaches a planned row at all. + */ + const PLANNED_NAV_DATA_SOURCE = { + apps: [{ + name: 'crm', + label: 'CRM', + navigation: [{ id: 'accounts', type: 'object', label: 'Accounts', objectName: 'crm_account', dataSource: { provider: 'api', read: { url: '/api/v1/crm/summary' } } }], + }], + }; + + // #29442 declared `flow.variables[].objectName` before any start door read + // it (`planned` + `authorWarn`, pinned here as a warning). #29425 gave it its + // reader, every door that starts a flow by name, and flipped the drilled row + // to `live`, so the SAME input now draws nothing. Pinned through the SHIPPED // ledger and an array of variables, so the drill (`variables.objectName`) - // and the array fan-out both have to hold for the author to be told. - it('warns on a flow input declared a record reference — planned, nothing reads it yet', () => { + // and the array fan-out both have to hold. + it('no longer warns on a flow input declared a record reference — live since the start doors read it (#29425)', () => { const findings = lintLivenessProperties({ flows: [{ name: 'close_account', @@ -173,18 +188,16 @@ describe('lintLivenessProperties', () => { nodes: [{ id: 'start', type: 'start' }], }], }); - const warned = findings.filter((f) => f.message.includes('sets `variables.objectName`')); - expect(warned).toHaveLength(1); - expect(warned[0].rule).toBe('liveness-planned-property'); - expect(warned[0].hint).toContain('nothing reads it yet'); - // The live siblings the drill graded stay silent. + expect(findings.filter((f) => f.message.includes('variables.objectName'))).toEqual([]); + expect(ruleOf(findings, 'variables.objectName')).toBeUndefined(); + // The live siblings the drill graded stay silent too. for (const key of ['name', 'type', 'isInput', 'isOutput', 'defaultValue']) { expect(ruleOf(findings, `variables.${key}`), key).toBeUndefined(); } - // And a flow that writes no `objectName` draws nothing about it. - expect(lintLivenessProperties({ - flows: [{ name: 'f2', variables: [{ name: 'note', type: 'text', isInput: true }], nodes: [] }], - }).filter((f) => f.message.includes('objectName'))).toEqual([]); + // CONTROL: the same walk still warns on a drilled `planned` + authorWarn + // row elsewhere in the shipped ledger, so the silence above is this row's + // verdict, not a walk that reaches nothing. + expect(ruleOf(lintLivenessProperties(PLANNED_NAV_DATA_SOURCE), 'navigation.dataSource')).toBe('liveness-planned-property'); }); // #30259 declared `action.params[].widget` before any reader (`planned` + @@ -211,14 +224,7 @@ describe('lintLivenessProperties', () => { // CONTROL: the same walk still warns on a drilled `planned` + authorWarn // row elsewhere in the shipped ledger, so the silence above is the // widget row's verdict, not a walk that reaches nothing. - const flow = lintLivenessProperties({ - flows: [{ - name: 'close_account', - variables: [{ name: 'accountId', type: 'text', isInput: true, objectName: 'crm_account' }], - nodes: [{ id: 'start', type: 'start' }], - }], - }); - expect(ruleOf(flow, 'variables.objectName')).toBe('liveness-planned-property'); + expect(ruleOf(lintLivenessProperties(PLANNED_NAV_DATA_SOURCE), 'navigation.dataSource')).toBe('liveness-planned-property'); }); it('warns on an experimental prop with no authorWarn of its own (tool.outputSchema)', () => { From c55e80479cd255a10f869b9b9eb1c8604f333465 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 17:21:48 +0000 Subject: [PATCH 5/6] wip(runtime): pin titles Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Utp6CbNMpDS92ifzhDw9vf --- .../runtime/src/flow-start-declared-record-reads.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/flow-start-declared-record-reads.test.ts b/packages/runtime/src/flow-start-declared-record-reads.test.ts index a95e7e3c84d..b24163a449a 100644 --- a/packages/runtime/src/flow-start-declared-record-reads.test.ts +++ b/packages/runtime/src/flow-start-declared-record-reads.test.ts @@ -722,9 +722,9 @@ describe('a start input declared a record id is loaded from the declared object }); it.each([ - ['the caller cannot read', CONTRACT_OTHER], - ['does not exist', GHOST], - ])('a declared input naming a record the caller %s: 404 RECORD_NOT_FOUND, no run', async (_label, id) => { + ['a record the caller cannot read', CONTRACT_OTHER], + ['an id that names no record', GHOST], + ])('a declared input naming %s: 404 RECORD_NOT_FOUND, no run', async (_label, id) => { const got = await start(DECLARED, id); expect(got.status).toBe(404); From 469106e8f23c17cb8c314be413d44032d234c0ef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 18:06:00 +0000 Subject: [PATCH 6/6] chore(spec): regenerate the automation API reference after merging main Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Utp6CbNMpDS92ifzhDw9vf --- content/docs/references/api/automation-api.mdx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 868ec94b982..4b7ba23023b 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -417,7 +417,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +357 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +360 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against | | **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds | ### Allowed Values: `FlowInnerRefusal.code` @@ -705,6 +705,9 @@ const result = AutomationApiErrorCode.parse(data); * `REQUEST_NOT_FOUND` * `RESEED_NO_ROWS` * `RESEED_SKIPPED` +* `RESTORE_PACKAGE_UNINSTALLED` +* `RESTORE_PARENT_PURGED` +* `RESTORE_UNIQUE_VALUE_TAKEN` * `RESUME_CHILD_PENDING` * `RESUME_FAILED` * `RESUME_IN_PROGRESS`