From 5778e3f2839941ea12c6a00e1b79a9a5281b2fed Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 17:47:12 +0000 Subject: [PATCH 1/8] feat(spec): a flow start input can declare that it is the id of a record of a named object WIP before the spec build and gate sweep. Claude-Session: https://claude.ai/code/session_01JQwxbaiSvS6eyjEknCGKao Co-authored-by: Claude --- .changeset/29442-flow-start-input-record.md | 16 ++++ content/docs/automation/flows.mdx | 42 +++++++++++ .../docs/deployment/validating-metadata.mdx | 3 +- packages/lint/src/index.ts | 1 + .../validate-flow-trigger-readiness.test.ts | 74 ++++++++++++++++++- .../src/validate-flow-trigger-readiness.ts | 61 ++++++++++++++- packages/spec/liveness/flow.json | 2 +- packages/spec/src/automation/flow.form.ts | 1 + packages/spec/src/automation/flow.test.ts | 66 +++++++++++++++++ packages/spec/src/automation/flow.zod.ts | 58 ++++++++++++++- 10 files changed, 316 insertions(+), 8 deletions(-) create mode 100644 .changeset/29442-flow-start-input-record.md diff --git a/.changeset/29442-flow-start-input-record.md b/.changeset/29442-flow-start-input-record.md new file mode 100644 index 00000000000..b07c961dffd --- /dev/null +++ b/.changeset/29442-flow-start-input-record.md @@ -0,0 +1,16 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +--- + +feat(spec): a flow start input can declare that it is the id of a record of a named object (`objectName`) + +Clause-②: yes (widening) +Metadata-protocol: added + +`FlowVariableSchema` declares one optional key, `objectName`: on an input (`isInput: true`), it says the input's value is the id of one record of the named object, e.g. `{ name: 'accountId', type: 'text', isInput: true, objectName: 'crm_account' }`. The rule it states: 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 that 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. An input without the declaration keeps the visible-read rule: it is judged as a record only where the flow's own definition reads it as a record id. + +- **Spec.** `objectName` is an object name (snake_case) and is refused on a variable that is not an input, since no caller's value reaches one. `type` stays the data type: the variable holds the id. A `reference` or `object` key on a flow variable is still refused, and the refusal now names `objectName`. The flow form's variables repeater gains the column. +- **Lint.** `flow-input-unknown-object`: a declared object the stack does not define is a warning (it may come from another installed package), and an error at the runtime publish gate when the write's package closure does not reach it, the verdict `flow-trigger-unknown-object` gives a trigger object. A `sys_` name is not judged. + +This is the contract half: the start doors read the declaration in a later change, and until then no door loads a declared input. Nothing to do for existing flows. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index c649ce6a703..c6795c2cf1a 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -2029,6 +2029,7 @@ variables: [ | `isInput` | `boolean` | Available as input parameter | | `isOutput` | `boolean` | Available as output parameter | | `defaultValue` | `unknown` | Bound at run start when no parameter supplies one — this is what makes the declaration a guarantee. Optional | +| `objectName` | `string` | On an input only: the input is the id of a record of this object. See [a start input that is a record id](#a-start-input-that-is-a-record-id). Optional | ### `defaultValue` — declaring a variable is not the same as binding it @@ -2098,6 +2099,47 @@ The value is **not** checked against the declared `type` — `type` is an open string, so there is no vocabulary to check against. It behaves like every other `defaultValue` on the authoring surface. +### A start input that is a record id + +An input may declare that its value is the id of a record of a named object, +with `objectName` — the word the CRUD nodes use for the object they read: + +{/* os:check */} +```typescript +import { defineFlow } from '@objectstack/spec/automation'; + +export const CloseAccount = defineFlow({ + name: 'close_account', + label: 'Close Account', + type: 'autolaunched', + variables: [ + { name: 'accountId', type: 'text', isInput: true, objectName: 'crm_account' }, + ], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], +}); +``` + +The rule: 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)). + +- **Inputs only.** A variable that is not `isInput: true` receives nothing from + a caller, so `objectName` there is refused at parse. +- **`type` stays the data type.** The variable holds the id the caller sent, so + it is `'text'`; `type: 'record'` already means a variable that holds a whole + record (a `get_record` node's `outputVariable`), and is not the declaration. +- **The name is checked.** `os validate` warns when the stack defines no object + of that name (`flow-input-unknown-object`); a `sys_` name is not judged. + **Reading a variable that might be unbound: only the `vars.`-scoped guard works.** Conditions are strict CEL, and `has()` is not the escape hatch it looks diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index e69eda4f3b6..8a79fd469be 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -740,7 +740,8 @@ no package provenance (#9612); a provenance it cannot read narrows nothing at al That changes what a rule can **resolve**, never which rules run, and nothing at this door walks `packages[]`. Two rules are told the closure is in force. A flow whose trigger object is outside it is refused with `flow-trigger-unknown-object` -instead of advised (#24669), and a list +instead of advised (#24669), as is a flow with a start input declared a record of +an object outside it, with `flow-input-unknown-object` (#29442), and a list view bound to an object outside it is refused with `list-view-object-outside-closure` instead of having its field references skipped (#24563). With no closure in force — a write that names no package — diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 397856accd4..da701e70c76 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -138,6 +138,7 @@ export { validateFlowTriggerReadiness, validateFlowApiTriggerSecret, FLOW_TRIGGER_UNKNOWN_OBJECT, + FLOW_INPUT_UNKNOWN_OBJECT, FLOW_DRAFT_STATUS_AMBIGUOUS, FLOW_TRIGGER_UNKNOWN_EVENT, FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID, diff --git a/packages/lint/src/validate-flow-trigger-readiness.test.ts b/packages/lint/src/validate-flow-trigger-readiness.test.ts index 48c2e8c76bb..9d4a69c979d 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.test.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.test.ts @@ -5,6 +5,7 @@ import { TimeRelativeTriggerSchema } from '@objectstack/spec/automation'; import { validateFlowTriggerReadiness as validateFlowTriggerReadinessUnrecorded, FLOW_TRIGGER_UNKNOWN_OBJECT, + FLOW_INPUT_UNKNOWN_OBJECT, FLOW_DRAFT_STATUS_AMBIGUOUS, FLOW_TRIGGER_UNKNOWN_EVENT, FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID, @@ -129,6 +130,60 @@ describe('validateFlowTriggerReadiness', () => { expect(findings).toEqual([]); }); + // #29442 (#29425 ruling A) — a start input declared a record reference + // names the object every start door loads it from. + describe('a start input declared a record reference (variables[].objectName)', () => { + const inputFlow = (variables: unknown[]) => + recordFlow({ name: 'close_candidate', type: 'screen', status: 'active', variables, nodes: [ + { id: 'start', type: 'start' }, + { id: 'end', type: 'end' }, + ] }); + + it('warns when the declared object is not defined in the stack, at the entry the author wrote', () => { + const findings = validateFlowTriggerReadiness({ + objects: [candidateObject], + flows: [inputFlow([ + { name: 'note', type: 'text', isInput: true }, + { name: 'candidateId', type: 'text', isInput: true, objectName: 'app_candidat' }, + ])], + }); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(FLOW_INPUT_UNKNOWN_OBJECT); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].path).toBe('flows[0].variables[1].objectName'); + expect(findings[0].where).toBe('flow "close_candidate" › input "candidateId"'); + expect(findings[0].message).toContain("'app_candidat'"); + expect(findings[0].message).toContain('Did you mean "app_candidate"?'); + expect(findings[0].hint).toMatch(/another installed package/); + }); + + it('is silent on a declared object the stack defines, a sys_* object, and an undeclared input', () => { + expect(validateFlowTriggerReadiness({ + objects: [candidateObject], + flows: [inputFlow([ + { name: 'candidateId', type: 'text', isInput: true, objectName: 'app_candidate' }, + { name: 'ownerId', type: 'text', isInput: true, objectName: 'sys_user' }, + { name: 'note', type: 'text', isInput: true }, + ])], + })).toEqual([]); + }); + + it('refuses under a stated package closure, naming the dependency remedy', () => { + const findings = validateFlowTriggerReadiness( + { + objects: [candidateObject], + flows: [inputFlow([{ name: 'invoiceId', type: 'text', isInput: true, objectName: 'billing_invoice' }])], + }, + { packageScope: { packageId: 'app_hr', dependencies: [] } }, + ); + expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([ + [FLOW_INPUT_UNKNOWN_OBJECT, 'error', 'flows[0].variables[0].objectName'], + ]); + expect(findings[0].message).toContain("declares input 'invoiceId' a record of object 'billing_invoice'"); + expect(findings[0].hint).toContain("set the input's objectName to an object inside that closure"); + }); + }); + it('warns when an auto-triggered flow has no explicit status (defaults to draft)', () => { const findings = validateFlowTriggerReadiness({ objects: [candidateObject], @@ -1474,6 +1529,23 @@ describe('validateFlowTriggerReadiness', () => { ], }, ], + // [#29442] The start-input declaration shares the trigger object's hedge. + [ + FLOW_INPUT_UNKNOWN_OBJECT, + 'warning', + { + objects: [candidateObject], + flows: [ + { + name: 'other_package_input', + type: 'screen', + status: 'active', + variables: [{ name: 'invoiceId', type: 'text', isInput: true, objectName: 'billing_invoice' }], + nodes: [{ id: 'start', type: 'start' }], + }, + ], + }, + ], [ FLOW_DRAFT_STATUS_AMBIGUOUS, 'warning', @@ -1500,7 +1572,7 @@ describe('validateFlowTriggerReadiness', () => { // what earns `flow-trigger-unknown-object` its warning. for (const [rule, severity, stack] of provoke) { for (const f of cliFlowFamily(stack).filter((x) => x.rule === rule)) { - if (severity === 'warning' && rule === FLOW_TRIGGER_UNKNOWN_OBJECT) { + if (severity === 'warning' && (rule === FLOW_TRIGGER_UNKNOWN_OBJECT || rule === FLOW_INPUT_UNKNOWN_OBJECT)) { expect(f.hint, rule).toMatch(/another installed package/); } if (severity === 'error') { diff --git a/packages/lint/src/validate-flow-trigger-readiness.ts b/packages/lint/src/validate-flow-trigger-readiness.ts index ff275cb509a..c15bdde0968 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.ts @@ -146,6 +146,11 @@ // unjudged under a closure too, exactly as without one. The CLI commands // and a write that names no package state no closure and keep the advisory // unchanged. +// - `warning` / `error` — `flow-input-unknown-object` (#29442), on the same +// question and the same closure split as `flow-trigger-unknown-object`: a +// start input declared a record reference (`variables[].objectName`) names +// an object this stack does not define. It is not a never-fire verdict — +// a start that omits the input still runs — so it sits with the hedged id. // - `warning` — `flow-draft-status-ambiguous`. Draft flows DO fire, so this is // an ambiguity of intent, not a dead flow. Gating it would refuse a stack // whose flows all work. @@ -194,6 +199,15 @@ export interface FlowTriggerReadinessFinding { // Rule ids (registry entries). export const FLOW_TRIGGER_UNKNOWN_OBJECT = 'flow-trigger-unknown-object'; +/** + * #29442 (#29425 ruling A) — a start input declared a record reference + * (`variables[].objectName`) names an object this stack does not define. Every + * door that starts the flow by name loads such an input from that object, so a + * wrong name refuses every start that supplies the input. The same hedge and + * the same closure verdict as `flow-trigger-unknown-object`: advisory with no + * package closure, an error under one. + */ +export const FLOW_INPUT_UNKNOWN_OBJECT = 'flow-input-unknown-object'; export const FLOW_DRAFT_STATUS_AMBIGUOUS = 'flow-draft-status-ambiguous'; export const FLOW_TRIGGER_UNKNOWN_EVENT = 'flow-trigger-unknown-event'; /** @@ -475,12 +489,15 @@ export function validateFlowTriggerReadiness( objectName: string, at: { verb: string; key: string }, advisory: { message: string; hint: string }, + // [#29442] The start-input declaration shares this verdict under its own + // id and position; the trigger's two halves take the defaults. + position: { rule: string; where: string } = { rule: FLOW_TRIGGER_UNKNOWN_OBJECT, where: 'start node' }, ): FlowTriggerReadinessFinding => { if (!closure) { return { severity: 'warning', - rule: FLOW_TRIGGER_UNKNOWN_OBJECT, - where: `flow "${flowName}" › start node`, + rule: position.rule, + where: `flow "${flowName}" › ${position.where}`, path, ...advisory, }; @@ -488,8 +505,8 @@ export function validateFlowTriggerReadiness( const pkg = closure.packageId; return { severity: 'error', - rule: FLOW_TRIGGER_UNKNOWN_OBJECT, - where: `flow "${flowName}" › start node`, + rule: position.rule, + where: `flow "${flowName}" › ${position.where}`, path, message: `${at.verb} object '${objectName}', which is outside the closure of package '${pkg}' (its own ` + @@ -917,6 +934,42 @@ export function validateFlowTriggerReadiness( // restored-credential positions (#20611), an input nothing else in this // family has a use for. + // 1i. #29442 (#29425 ruling A) — a start input declared a record reference + // names an object this stack does not define. Every door that starts + // the flow by name loads the input from exactly that name in the + // caller's scope, so a wrong name refuses every start that supplies + // the input. Judged like the trigger's object (1 and 1b-i): a `sys_` + // name is a platform object and is not judged, and the verdict is the + // closure's when one is stated. The schema owns the declaration's + // shape (input-only, an object name); this rule reads only the name. + // Indexed over the authored array itself, so the path names the entry the + // author wrote even beside a malformed sibling the schema refuses. + (Array.isArray(flow.variables) ? (flow.variables as unknown[]) : []).forEach((entry, variableIndex) => { + if (!entry || typeof entry !== 'object') return; + const variable = entry as AnyRec; + const objectName = typeof variable.objectName === 'string' ? variable.objectName : undefined; + if (!objectName || objectNames.has(objectName) || objectName.startsWith('sys_')) return; + const variableName = typeof variable.name === 'string' ? variable.name : `#${variableIndex}`; + findings.push( + unknownTriggerObject( + flowName, + `flows[${flowIndex}].variables[${variableIndex}].objectName`, + objectName, + { verb: `declares input '${variableName}' a record of`, key: `the input's objectName` }, + { + message: + `declares input '${variableName}' the id of a record of object '${objectName}', which this ` + + `stack does not define — if the name is wrong, every start that supplies the input is refused.` + + suggestName(objectName, objectNames), + hint: + `Set the input's objectName to the object's exact registered name, or ignore this if another ` + + `installed package declares it`, + }, + { rule: FLOW_INPUT_UNKNOWN_OBJECT, where: `input "${variableName}"` }, + ), + ); + }); + // 2. Auto-triggered flow whose status is 'draft' — authored or defaulted // (defineFlow parses at definition time, so the two are the same here). if (isAutoTriggered && (flow.status == null || flow.status === 'draft')) { diff --git a/packages/spec/liveness/flow.json b/packages/spec/liveness/flow.json index 0ec231b2a9e..fb303198853 100644 --- a/packages/spec/liveness/flow.json +++ b/packages/spec/liveness/flow.json @@ -62,7 +62,7 @@ "variables": { "status": "live", "evidence": "packages/services/service-automation/src/engine.ts", - "note": "{name,isInput,isOutput} wired." + "note": "{name,isInput,isOutput} wired. `objectName` (#29425 ruling A, declared by #29442): an input's declaration that its value is the id of a record of the named object. PLANNED consumer, not yet wired: the start doors in packages/runtime/src/flow-start-admission.ts load a declared input from that object in the caller's scope before the run exists — the server half, Blocked-by #29442. Until it lands the key is parsed, validated (input-only, an object name) and linted (flow-input-unknown-object), and no door reads it. `variables` is an undrilled container (scripts/liveness/undrilled-containers.baseline.json), so the key has no row of its own and this note is its record." }, "edges": { "status": "live", diff --git a/packages/spec/src/automation/flow.form.ts b/packages/spec/src/automation/flow.form.ts index 390b340eb08..649ed53a917 100644 --- a/packages/spec/src/automation/flow.form.ts +++ b/packages/spec/src/automation/flow.form.ts @@ -85,6 +85,7 @@ export const flowForm = defineForm({ { field: 'isInput', label: 'Input' }, { field: 'isOutput', label: 'Output' }, { field: 'defaultValue', label: 'Default Value' }, + { field: 'objectName', label: 'Record Object' }, ], }, ], diff --git a/packages/spec/src/automation/flow.test.ts b/packages/spec/src/automation/flow.test.ts index a216b20f119..09721f08eea 100644 --- a/packages/spec/src/automation/flow.test.ts +++ b/packages/spec/src/automation/flow.test.ts @@ -152,6 +152,63 @@ describe('FlowVariableSchema', () => { expect(flow.variables?.map(v => v.defaultValue)).toEqual([false, 0]); }); }); + + // #29425 ruling A — a start input may declare that it is the id of a record + // of a named object. The start doors that load it are the server half; this + // is the authorable surface they read. + describe('objectName — a start input declared a record reference', () => { + it('accepts the declaration on an input, and keeps it as authored', () => { + const parsed = FlowVariableSchema.parse({ + name: 'accountId', type: 'text', isInput: true, objectName: 'crm_account', + }); + expect(parsed.objectName).toBe('crm_account'); + expect(parsed.isInput).toBe(true); + }); + + it('is OPTIONAL — an input without it parses exactly as before, and reads back absent', () => { + const parsed = FlowVariableSchema.parse({ name: 'accountId', type: 'text', isInput: true }); + expect('objectName' in parsed).toBe(false); + }); + + it('refuses it on a variable that is not an input, at the key, naming the fix', () => { + for (const variable of [ + { name: 'accountId', type: 'text', objectName: 'crm_account' }, + { name: 'accountId', type: 'text', isInput: false, isOutput: true, objectName: 'crm_account' }, + ]) { + const result = FlowVariableSchema.safeParse(variable); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'objectName'); + expect(issue?.code).toBe('custom'); + expect(issue?.message).toContain('Set `isInput: true`'); + } + }); + + it('refuses a name that is not an object name', () => { + for (const objectName of ['CrmAccount', 'crm-account', '', 'crm.account']) { + const result = FlowVariableSchema.safeParse({ name: 'accountId', type: 'text', isInput: true, objectName }); + expect(result.success, objectName).toBe(false); + expect(result.error!.issues.some((i) => i.path.join('.') === 'objectName')).toBe(true); + } + }); + + it('a flow carries the declaration through FlowSchema', () => { + const flow = FlowSchema.parse({ + name: 'close_account', + label: 'Close Account', + type: 'autolaunched', + variables: [ + { name: 'accountId', type: 'text', isInput: true, objectName: 'crm_account' }, + { name: 'note', type: 'text', isInput: true }, + ], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }); + expect(flow.variables?.map(v => v.objectName)).toEqual(['crm_account', undefined]); + }); + }); }); describe('FlowNodeSchema', () => { @@ -1889,6 +1946,15 @@ describe('unknown keys are rejected, not stripped', () => { .toContain(`\`${key}\` → \`defaultValue\``); } }); + + it('points `reference` / `object` at `objectName`', () => { + // A lookup field's word and a time-relative trigger's word for the + // object a reference points at. Still REJECTED; the alias names the key. + for (const key of ['reference', 'object']) { + expect(unknownKeyIssue(FlowVariableSchema, { name: 'v', type: 'text', isInput: true, [key]: 'crm_account' })!.message) + .toContain(`\`${key}\` → \`objectName\``); + } + }); }); // ── batch 11: the INNER blocks ──────────────────────────────────────────── diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 656fe2cb453..83c737f7d27 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -229,11 +229,44 @@ export const FLOW_PAUSE_CAPABLE_NODE_TYPES: readonly string[] = [ * page state slot, a screen field): the declared `type` is itself an open * `string`, so there is no closed vocabulary to check against, and inventing * one here would be a new validation surface rather than this additive key. + * + * ## `objectName` — a start input that is a record id (#29425 ruling A) + * + * An input may declare that its value is the id of a record of a named + * object. The declaration is what lets every door that starts the flow by + * name judge 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`). + * + * 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 + * carries `'record'` in the shipped corpus for a variable that holds a WHOLE + * record (`examples/app-todo` `completedTask`, a `get_record` + * `outputVariable`), so the same word would name an id on an input and a row + * on every other variable. `type` stays the data type; the value an input + * declared this way holds is the id the caller sent. + * + * Only an input carries it: nothing a caller sends reaches a variable that is + * not an input, so a declaration there would load nothing and is refused + * here rather than shipped as documentation. */ export const FlowVariableSchema = lazySchema(() => strictObject( { surface: 'this flow variable', - aliases: { input: 'isInput', output: 'isOutput', default: 'defaultValue', initialValue: 'defaultValue' }, + aliases: { + input: 'isInput', + output: 'isOutput', + default: 'defaultValue', + initialValue: 'defaultValue', + // The two in-repo words for "the object a reference points at": a + // lookup field's `reference` and a time-relative trigger's `object`. + reference: 'objectName', + object: 'objectName', + }, history: 'Until this shape was closed, these were dropped silently — the variable still parsed, so a ' + 'mis-declared input/output contract shipped without a diagnostic.', @@ -255,6 +288,29 @@ export const FlowVariableSchema = lazySchema(() => strictObject( '`false` and `null`; the boundary is `params[name] !== undefined`.', ) .meta({ title: 'Default Value' }), + objectName: z.string().regex(/^[a-z_][a-z0-9_]*$/).optional() + .describe( + '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`). 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. 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' }), +}).superRefine((variable, ctx) => { + if (variable.objectName !== undefined && variable.isInput !== true) { + ctx.addIssue({ + code: 'custom', + path: ['objectName'], + message: + `\`objectName\` declares a start input a record reference, and \`${variable.name}\` is not an ` + + 'input: no value a caller sends reaches it, so nothing would load it from ' + + `\`${variable.objectName}\`. Set \`isInput: true\` on it, or remove \`objectName\`.`, + }); + } })); /** From 1f7c1d5a9237995ee21fb0bfa93e7a1b7ec5cda3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 17:54:17 +0000 Subject: [PATCH 2/8] chore(spec): regenerate artifacts and declare the dropped refinement for the flow variable's objectName Claude-Session: https://claude.ai/code/session_01JQwxbaiSvS6eyjEknCGKao Co-authored-by: Claude --- .../docs/references/api/automation-api.mdx | 1 + content/docs/references/automation/flow.mdx | 2 + .../spec/authorable-surface/automation.json | 1 + .../spec/dropped-refinements.baseline.json | 43 +++++++++++++------ 4 files changed, 34 insertions(+), 13 deletions(-) diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 5c9cd00b0d6..14aca0681ae 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -199,6 +199,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 | 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`). 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. 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 9e611d47704..cce1b9c681d 100644 --- a/content/docs/references/automation/flow.mdx +++ b/content/docs/references/automation/flow.mdx @@ -61,6 +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 | 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`). 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. 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]` @@ -230,6 +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 | 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`). 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. 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/authorable-surface/automation.json b/packages/spec/authorable-surface/automation.json index d6268ee1262..4bc52caf3a5 100644 --- a/packages/spec/authorable-surface/automation.json +++ b/packages/spec/authorable-surface/automation.json @@ -241,6 +241,7 @@ "automation/FlowVariable:isInput", "automation/FlowVariable:isOutput", "automation/FlowVariable:name", + "automation/FlowVariable:objectName", "automation/FlowVariable:type", "automation/FlowVersionHistory:changeNote", "automation/FlowVersionHistory:createdAt", diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 10935cb9580..1b89840b59e 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -97,6 +97,7 @@ "manifest.flows.element", "manifest.flows.element.errorHandling", "manifest.flows.element.nodes.element.in.waitEventConfig", + "manifest.flows.element.variables.element", "manifest.hooks.element", "manifest.hooks.element.object", "manifest.jobs.element", @@ -150,21 +151,24 @@ "sites": [ "data.flow", "data.flow.errorHandling", - "data.flow.nodes.element.in.waitEventConfig" + "data.flow.nodes.element.in.waitEventConfig", + "data.flow.variables.element" ] }, "api/CreateFlowRequest": { "sites": [ "", "errorHandling", - "nodes.element.in.waitEventConfig" + "nodes.element.in.waitEventConfig", + "variables.element" ] }, "api/CreateFlowResponse": { "sites": [ "data", "data.errorHandling", - "data.nodes.element.in.waitEventConfig" + "data.nodes.element.in.waitEventConfig", + "data.variables.element" ] }, "api/CreateImportJobRequest": { @@ -208,7 +212,8 @@ "sites": [ "data", "data.errorHandling", - "data.nodes.element.in.waitEventConfig" + "data.nodes.element.in.waitEventConfig", + "data.variables.element" ] }, "api/GetInstalledPackageResponse": { @@ -243,6 +248,7 @@ "data.options[1].manifest.flows.element", "data.options[1].manifest.flows.element.errorHandling", "data.options[1].manifest.flows.element.nodes.element.in.waitEventConfig", + "data.options[1].manifest.flows.element.variables.element", "data.options[1].manifest.hooks.element", "data.options[1].manifest.hooks.element.object", "data.options[1].manifest.jobs.element", @@ -382,6 +388,7 @@ "options[1].manifest.flows.element", "options[1].manifest.flows.element.errorHandling", "options[1].manifest.flows.element.nodes.element.in.waitEventConfig", + "options[1].manifest.flows.element.variables.element", "options[1].manifest.hooks.element", "options[1].manifest.hooks.element.object", "options[1].manifest.jobs.element", @@ -461,6 +468,7 @@ "data.packages.element.options[1].manifest.flows.element", "data.packages.element.options[1].manifest.flows.element.errorHandling", "data.packages.element.options[1].manifest.flows.element.nodes.element.in.waitEventConfig", + "data.packages.element.options[1].manifest.flows.element.variables.element", "data.packages.element.options[1].manifest.hooks.element", "data.packages.element.options[1].manifest.hooks.element.object", "data.packages.element.options[1].manifest.jobs.element", @@ -591,14 +599,16 @@ "sites": [ "definition", "definition.errorHandling", - "definition.nodes.element.in.waitEventConfig" + "definition.nodes.element.in.waitEventConfig", + "definition.variables.element" ] }, "api/UpdateFlowResponse": { "sites": [ "data", "data.errorHandling", - "data.nodes.element.in.waitEventConfig" + "data.nodes.element.in.waitEventConfig", + "data.variables.element" ] }, "automation/ApprovalEscalation": { @@ -666,7 +676,8 @@ "sites": [ "", "errorHandling", - "nodes.element.in.waitEventConfig" + "nodes.element.in.waitEventConfig", + "variables.element" ] }, "automation/FlowNode": { @@ -684,11 +695,17 @@ "" ] }, + "automation/FlowVariable": { + "sites": [ + "" + ] + }, "automation/FlowVersionHistory": { "sites": [ "definition", "definition.errorHandling", - "definition.nodes.element.in.waitEventConfig" + "definition.nodes.element.in.waitEventConfig", + "definition.variables.element" ] }, "automation/GetRecordConfig": { @@ -1932,17 +1949,17 @@ "list.timeline.groupByField" ] }, - "ui/ViewFilterOrGroup": { - "sites": [ - "or.element" - ] - }, "ui/ViewFilterElement": { "sites": [ "", "options[0]" ] }, + "ui/ViewFilterOrGroup": { + "sites": [ + "or.element" + ] + }, "ui/ViewFilterRule": { "sites": [ "" From 6aefef65560f6f3034b3a6bd4eb0f59352d32952 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 18:50:37 +0000 Subject: [PATCH 3/8] chore(platform-objects): the flow form's Record Object column in the metadata-form bundles Claude-Session: https://claude.ai/code/session_01JQwxbaiSvS6eyjEknCGKao Co-authored-by: Claude --- .../src/apps/translations/en.metadata-forms.generated.ts | 3 +++ .../src/apps/translations/es-ES.metadata-forms.generated.ts | 3 +++ .../src/apps/translations/ja-JP.metadata-forms.generated.ts | 3 +++ .../src/apps/translations/zh-CN.metadata-forms.generated.ts | 3 +++ 4 files changed, 12 insertions(+) diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 007c270574a..124c348e3af 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -2253,6 +2253,9 @@ export const enMetadataForms: NonNullable = { "variables.defaultValue": { label: "Default Value" }, + "variables.objectName": { + label: "Record Object" + }, status: { label: "Status", helpText: "Deployment status: draft → active → obsolete" diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index cc3200cbe44..337b31c14bf 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -2253,6 +2253,9 @@ export const esESMetadataForms: NonNullable = "variables.defaultValue": { label: "Valor predeterminado" }, + "variables.objectName": { + label: "Objeto de registro" + }, status: { label: "Estado", helpText: "Estado de despliegue: draft → active → obsolete" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 66c656c855c..ca545d7c8b9 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -2253,6 +2253,9 @@ export const jaJPMetadataForms: NonNullable = "variables.defaultValue": { label: "既定値" }, + "variables.objectName": { + label: "レコードオブジェクト" + }, status: { label: "状態", helpText: "デプロイ状態: draft → active → obsolete" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index e72385d29e8..3557f725da5 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -2253,6 +2253,9 @@ export const zhCNMetadataForms: NonNullable = "variables.defaultValue": { label: "默认值" }, + "variables.objectName": { + label: "记录对象" + }, status: { label: "状态", helpText: "部署状态:draft → active → obsolete" From cfe7f4e4f820d3d20c05a7918f583d6d4aa8facc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 18:54:26 +0000 Subject: [PATCH 4/8] test(platform-objects): the metadata-form catalog's translated count is 700 with the flow variable's Record Object row Claude-Session: https://claude.ai/code/session_01JQwxbaiSvS6eyjEknCGKao Co-authored-by: Claude --- .changeset/29442-flow-start-input-record.md | 3 ++- .../object-lifecycle-panel-echo-decisions.test.ts | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.changeset/29442-flow-start-input-record.md b/.changeset/29442-flow-start-input-record.md index b07c961dffd..162eeea5ae9 100644 --- a/.changeset/29442-flow-start-input-record.md +++ b/.changeset/29442-flow-start-input-record.md @@ -1,6 +1,7 @@ --- '@objectstack/spec': minor '@objectstack/lint': minor +'@objectstack/platform-objects': patch --- feat(spec): a flow start input can declare that it is the id of a record of a named object (`objectName`) @@ -10,7 +11,7 @@ Metadata-protocol: added `FlowVariableSchema` declares one optional key, `objectName`: on an input (`isInput: true`), it says the input's value is the id of one record of the named object, e.g. `{ name: 'accountId', type: 'text', isInput: true, objectName: 'crm_account' }`. The rule it states: 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 that 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. An input without the declaration keeps the visible-read rule: it is judged as a record only where the flow's own definition reads it as a record id. -- **Spec.** `objectName` is an object name (snake_case) and is refused on a variable that is not an input, since no caller's value reaches one. `type` stays the data type: the variable holds the id. A `reference` or `object` key on a flow variable is still refused, and the refusal now names `objectName`. The flow form's variables repeater gains the column. +- **Spec.** `objectName` is an object name (snake_case) and is refused on a variable that is not an input, since no caller's value reaches one. `type` stays the data type: the variable holds the id. A `reference` or `object` key on a flow variable is still refused, and the refusal now names `objectName`. The flow form's variables repeater gains the column, labelled in the metadata-form bundles (en, zh-CN, ja-JP, es-ES). - **Lint.** `flow-input-unknown-object`: a declared object the stack does not define is a warning (it may come from another installed package), and an error at the runtime publish gate when the write's package closure does not reach it, the verdict `flow-trigger-unknown-object` gives a trigger object. A `sys_` name is not judged. This is the contract half: the start doors read the declaration in a later change, and until then no door loads a declared input. Nothing to do for existing flows. diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index c0dd9c47fa0..59f8230faf9 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1253,7 +1253,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // 699 since #28879: the action form's `cancelText` row (the dismiss // label of the action's confirm and param dialogs) — one row label, // authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(699); + // 700 since #29442: the flow form's variables repeater offers + // `objectName` (a start input declared a record id) — one row label, + // authored in all three locales. + expect(translated.length, `${locale} positive control`).toBe(700); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the From f06ad2356cf46b1aa061354775084403af371c65 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 19:17:02 +0000 Subject: [PATCH 5/8] fix(spec): the dropped-refinements header counts its body, and the flow translation face classifies objectName Claude-Session: https://claude.ai/code/session_01JQwxbaiSvS6eyjEknCGKao Co-authored-by: Claude --- packages/spec/dropped-refinements.baseline.json | 4 ++-- packages/spec/src/system/flows-translation-face.test.ts | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 049184e727e..0a8aa24bb89 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -2,8 +2,8 @@ "description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.", "measured": { "zod": "4.4.3", - "publishedSchemasWithDroppedRefinements": 248, - "droppedRefinementSites": 993, + "publishedSchemasWithDroppedRefinements": 249, + "droppedRefinementSites": 1006, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, diff --git a/packages/spec/src/system/flows-translation-face.test.ts b/packages/spec/src/system/flows-translation-face.test.ts index d8644c2d18c..2ea98dbabb6 100644 --- a/packages/spec/src/system/flows-translation-face.test.ts +++ b/packages/spec/src/system/flows-translation-face.test.ts @@ -106,6 +106,9 @@ const RUNNER_SLOTS: Readonly> = { 'flow.variables[].name': noReader('a variable name — the binding a screen field or node writes to'), 'flow.variables[].type': noReader('a data-type token'), 'flow.variables[].defaultValue': noReader('a value the run binds, not text it shows'), + // [#29442] Read by the start doors, which load the input from that object; the + // object's own labels are translated under `objects.`. + 'flow.variables[].objectName': noReader('an object name — the object a start input declared a record id is loaded from'), 'flow.edges[].id': noReader('an edge id'), 'flow.edges[].source': noReader('a node id the edge leaves'), 'flow.edges[].target': noReader('a node id the edge enters'), From 400e8cc6b3a30237c712c8018c11dc7b07000ffc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:02:25 +0000 Subject: [PATCH 6/8] =?UTF-8?q?fix(spec):=20objectName=20is=20planned=20?= =?UTF-8?q?=E2=80=94=20the=20liveness=20row=20warns=20its=20authors=20and?= =?UTF-8?q?=20the=20describe=20says=20no=20door=20reads=20it=20yet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drills flow.variables in the liveness ledger (the five wired keys live, objectName planned with authorWarn), drops the undrilled-container row, words the describe, TSDoc, docs and changeset in the planned style, pins the author warning, and gives flow-input-unknown-object an os explain entry. Claude-Session: https://claude.ai/code/session_01JQwxbaiSvS6eyjEknCGKao Co-authored-by: Claude --- .changeset/29442-flow-start-input-record.md | 4 +- content/docs/automation/flows.mdx | 15 ++++++-- .../lint/src/lint-liveness-properties.test.ts | 29 ++++++++++++++ packages/lint/src/rule-explanations.ts | 28 ++++++++++++++ .../validate-flow-trigger-readiness.test.ts | 11 ++++++ packages/spec/liveness/flow.json | 38 ++++++++++++++++++- .../undrilled-containers.baseline.json | 1 - packages/spec/src/automation/flow.zod.ts | 35 ++++++++++------- 8 files changed, 141 insertions(+), 20 deletions(-) diff --git a/.changeset/29442-flow-start-input-record.md b/.changeset/29442-flow-start-input-record.md index 162eeea5ae9..176c98704a3 100644 --- a/.changeset/29442-flow-start-input-record.md +++ b/.changeset/29442-flow-start-input-record.md @@ -9,9 +9,9 @@ feat(spec): a flow start input can declare that it is the id of a record of a na Clause-②: yes (widening) Metadata-protocol: added -`FlowVariableSchema` declares one optional key, `objectName`: on an input (`isInput: true`), it says the input's value is the id of one record of the named object, e.g. `{ name: 'accountId', type: 'text', isInput: true, objectName: 'crm_account' }`. The rule it states: 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 that 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. An input without the declaration keeps the visible-read rule: it is judged as a record only where the flow's own definition reads it as a record id. +`FlowVariableSchema` declares one optional key, `objectName`: on an input (`isInput: true`), it says the input's value is the id of one record of the named object, e.g. `{ name: 'accountId', type: 'text', isInput: true, objectName: 'crm_account' }`. The key is **planned**: no door reads it yet, and `os validate` warns on every flow that writes it (`liveness-planned-property`). The contract it states, for the start doors to enforce: 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 that 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. An input without the declaration keeps the visible-read rule: it is judged as a record only where the flow's own definition reads it as a record id. - **Spec.** `objectName` is an object name (snake_case) and is refused on a variable that is not an input, since no caller's value reaches one. `type` stays the data type: the variable holds the id. A `reference` or `object` key on a flow variable is still refused, and the refusal now names `objectName`. The flow form's variables repeater gains the column, labelled in the metadata-form bundles (en, zh-CN, ja-JP, es-ES). - **Lint.** `flow-input-unknown-object`: a declared object the stack does not define is a warning (it may come from another installed package), and an error at the runtime publish gate when the write's package closure does not reach it, the verdict `flow-trigger-unknown-object` gives a trigger object. A `sys_` name is not judged. -This is the contract half: the start doors read the declaration in a later change, and until then no door loads a declared input. Nothing to do for existing flows. +This is the contract half: the start doors read the declaration in a later change, and until then no door loads a declared input, so a start that hands the input an id the caller cannot read still runs. Nothing to do for existing flows. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index c6795c2cf1a..d350f545fd2 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -2029,7 +2029,7 @@ variables: [ | `isInput` | `boolean` | Available as input parameter | | `isOutput` | `boolean` | Available as output parameter | | `defaultValue` | `unknown` | Bound at run start when no parameter supplies one — this is what makes the declaration a guarantee. Optional | -| `objectName` | `string` | On an input only: the input is the id of a record of this object. See [a start input that is a record id](#a-start-input-that-is-a-record-id). Optional | +| `objectName` | `string` | **Planned.** On an input only (refused at parse on any other variable): the input is the id of a record of this object. No start door reads it yet. See [a start input that is a record id](#a-start-input-that-is-a-record-id). Optional | ### `defaultValue` — declaring a variable is not the same as binding it @@ -2123,8 +2123,17 @@ export const CloseAccount = defineFlow({ }); ``` -The rule: 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 + +**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 `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` diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index 96bfc5bd9f2..42811070ee2 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -158,6 +158,35 @@ 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 + // 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', () => { + const findings = lintLivenessProperties({ + flows: [{ + name: 'close_account', + variables: [ + { name: 'note', type: 'text', isInput: true }, + { name: 'accountId', type: 'text', isInput: true, objectName: 'crm_account' }, + ], + 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. + 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([]); + }); + it('warns on an experimental prop with no authorWarn of its own (tool.outputSchema)', () => { // `experimental` warns implicitly — shouldWarn() treats a declared-but- // unenforced guarantee like an opted-in dead prop. Repointed from diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 14fb6b18b64..033070c2d86 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -2072,6 +2072,33 @@ const FLOW_TRIGGER_UNKNOWN_OBJECT_EXPLANATION: RuleExplanation = { ], }; +// `flow-input-unknown-object` (#29442) shares the rule file, the hedge and the +// closure split with `flow-trigger-unknown-object` above; what differs is the +// position it reads and what a wrong name costs. + +const FLOW_INPUT_UNKNOWN_OBJECT_EXPLANATION: RuleExplanation = { + rule: 'flow-input-unknown-object', + covers: 'why an unknown start-input object warns, and when it refuses', + paragraphs: [ + 'A flow input may declare that its value is the id of a record of a named object, with `objectName` on ' + + 'the variable. The start doors are to load such an input from exactly that name in the caller\'s own ' + + 'scope before the run exists, so a name no object answers to would refuse every start that supplies ' + + 'the input. Names match exactly — `app_candidate`, not `candidate`. The declaration is planned: no ' + + 'door reads it yet, and the property lint says so separately.', + 'The rule asks whether this stack defines the object. One it does not define may be declared by ' + + 'another installed package, which a build of this stack cannot see, so the finding is a warning and ' + + 'can be ignored when such a package declares the object; a `sys_` name is a platform object and is ' + + 'not judged. A start that omits the input is not affected, so this is not a verdict that the flow ' + + 'never runs.', + 'A write into a package is judged against that package\'s closure instead: its own objects, the objects ' + + 'of the packages its manifest declares in `dependencies`, and platform objects. An object outside it ' + + 'is one the package never declared it may reference, so there the finding is an error and the publish ' + + 'is refused, the verdict `flow-trigger-unknown-object` gives a trigger object. Declare a dependency on ' + + 'the package that defines the object, or name an object inside the closure. A `sys_` name is not ' + + 'judged there either.', + ], +}; + // ── Validation rules that compile at publish time ─────────────────────────── // `validate-rule-compilability.ts` and `validate-rule-schema-formats.ts`: the // write path's fail-open skip and the runtime's ajv environment are shared, @@ -5563,6 +5590,7 @@ export const RULE_EXPLANATIONS: Readonly> = Obje [FLOW_TRIGGER_UNKNOWN_EVENT_EXPLANATION.rule]: FLOW_TRIGGER_UNKNOWN_EVENT_EXPLANATION, [FLOW_API_TRIGGER_SECRET_MISSING_EXPLANATION.rule]: FLOW_API_TRIGGER_SECRET_MISSING_EXPLANATION, [FLOW_TRIGGER_UNKNOWN_OBJECT_EXPLANATION.rule]: FLOW_TRIGGER_UNKNOWN_OBJECT_EXPLANATION, + [FLOW_INPUT_UNKNOWN_OBJECT_EXPLANATION.rule]: FLOW_INPUT_UNKNOWN_OBJECT_EXPLANATION, [VALIDATION_RULE_REGEX_UNCOMPILABLE_EXPLANATION.rule]: VALIDATION_RULE_REGEX_UNCOMPILABLE_EXPLANATION, [VALIDATION_RULE_JSON_SCHEMA_UNCOMPILABLE_EXPLANATION.rule]: VALIDATION_RULE_JSON_SCHEMA_UNCOMPILABLE_EXPLANATION, [VALIDATION_RULE_JSON_SCHEMA_UNKNOWN_FORMAT_EXPLANATION.rule]: VALIDATION_RULE_JSON_SCHEMA_UNKNOWN_FORMAT_EXPLANATION, diff --git a/packages/lint/src/validate-flow-trigger-readiness.test.ts b/packages/lint/src/validate-flow-trigger-readiness.test.ts index 9d4a69c979d..c5c50cee55c 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.test.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.test.ts @@ -182,6 +182,17 @@ describe('validateFlowTriggerReadiness', () => { expect(findings[0].message).toContain("declares input 'invoiceId' a record of object 'billing_invoice'"); expect(findings[0].hint).toContain("set the input's objectName to an object inside that closure"); }); + + it('routes the hedge and the closure split to `os explain`, keeping the fix one line', () => { + expect(explanationOf(FLOW_INPUT_UNKNOWN_OBJECT)).toMatch(/another installed package/); + expect(explanationOf(FLOW_INPUT_UNKNOWN_OBJECT)).toMatch(/closure/); + const [finding] = validateFlowTriggerReadiness({ + objects: [candidateObject], + flows: [inputFlow([{ name: 'invoiceId', type: 'text', isInput: true, objectName: 'billing_invoice' }])], + }); + expect(finding.hint).not.toContain('\n'); + expect(finding.hint.length).toBeLessThanOrEqual(200); + }); }); it('warns when an auto-triggered flow has no explicit status (defaults to draft)', () => { diff --git a/packages/spec/liveness/flow.json b/packages/spec/liveness/flow.json index fb303198853..3533a211841 100644 --- a/packages/spec/liveness/flow.json +++ b/packages/spec/liveness/flow.json @@ -62,7 +62,43 @@ "variables": { "status": "live", "evidence": "packages/services/service-automation/src/engine.ts", - "note": "{name,isInput,isOutput} wired. `objectName` (#29425 ruling A, declared by #29442): an input's declaration that its value is the id of a record of the named object. PLANNED consumer, not yet wired: the start doors in packages/runtime/src/flow-start-admission.ts load a declared input from that object in the caller's scope before the run exists — the server half, Blocked-by #29442. Until it lands the key is parsed, validated (input-only, an object name) and linted (flow-input-unknown-object), and no door reads it. `variables` is an undrilled container (scripts/liveness/undrilled-containers.baseline.json), so the key has no row of its own and this note is its record." + "note": "{name,type,isInput,isOutput,defaultValue} wired; drilled per key (#29442) so `objectName` can carry its own `planned` row and author warning — the blanket row could not say that one child is not read yet.", + "children": { + "name": { + "status": "live", + "verifiedAt": "2026-11-02", + "evidence": "packages/services/service-automation/src/dag-traversal.ts#seedDeclaredVariables (binds each declared variable under `v.name`, from `context.params[v.name]` or its default)" + }, + "type": { + "status": "live", + "verifiedAt": "2026-11-02", + "evidence": "ui/apps/console/src/pages/developer/FlowRunsPage.tsx#FlowTestRunner (the Flow Runs test runner picks each input's widget and coerces its value by `v.type`)", + "note": "An open string: no engine path coerces by it; the Console test runner is its reader." + }, + "isInput": { + "status": "live", + "verifiedAt": "2026-11-02", + "evidence": "packages/services/service-automation/src/dag-traversal.ts#seedDeclaredVariables (only an `isInput` variable is bound from the caller's params)" + }, + "isOutput": { + "status": "live", + "verifiedAt": "2026-11-02", + "evidence": "packages/services/service-automation/src/dag-traversal.ts#executeWithoutRetry (an `isOutput` variable's final value is copied onto the run result's output)" + }, + "defaultValue": { + "status": "live", + "verifiedAt": "2026-11-02", + "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 server half of #29425 ruling A ships, 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." + } + } }, "edges": { "status": "live", diff --git a/packages/spec/scripts/liveness/undrilled-containers.baseline.json b/packages/spec/scripts/liveness/undrilled-containers.baseline.json index b4f764822ad..cd85f162a71 100644 --- a/packages/spec/scripts/liveness/undrilled-containers.baseline.json +++ b/packages/spec/scripts/liveness/undrilled-containers.baseline.json @@ -93,7 +93,6 @@ "flow/nodes.inputSchema", "flow/nodes.position", "flow/nodes.waitEventConfig", - "flow/variables", "hook/retryPolicy", "job/retryPolicy", "manifest/contributes.kinds", diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 83c737f7d27..bec185ae1c5 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -232,16 +232,24 @@ export const FLOW_PAUSE_CAPABLE_NODE_TYPES: readonly string[] = [ * * ## `objectName` — a start input that is a record id (#29425 ruling A) * - * An input may declare that its value is the id of a record of a named - * object. The declaration is what lets every door that starts the flow by - * name judge 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 + * [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. + * * 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 * carries `'record'` in the shipped corpus for a variable that holds a WHOLE @@ -290,14 +298,15 @@ export const FlowVariableSchema = lazySchema(() => strictObject( .meta({ title: 'Default Value' }), objectName: z.string().regex(/^[a-z_][a-z0-9_]*$/).optional() .describe( - '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`). 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. 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.', + '[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.', ) .meta({ title: 'Record Object' }), }).superRefine((variable, ctx) => { From 6a6ead5ad6aba763274bea9e859097b993b3002c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:09:19 +0000 Subject: [PATCH 7/8] chore(spec): regenerate the references and the flow liveness counts for the planned objectName Claude-Session: https://claude.ai/code/session_01JQwxbaiSvS6eyjEknCGKao Co-authored-by: Claude --- 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 14aca0681ae..877669dd0a8 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -199,7 +199,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 | 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`). 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. 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 | [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. | ### Nested Shape: `CreateFlowRequest.nodes[number]` diff --git a/content/docs/references/automation/flow.mdx b/content/docs/references/automation/flow.mdx index cce1b9c681d..ab57f330d9a 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 | 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`). 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. 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 | [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. | ### 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 | 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`). 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. 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 | [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. | --- diff --git a/packages/spec/liveness/state-counts/flow.md b/packages/spec/liveness/state-counts/flow.md index 52cafd01c7c..b2a583a8654 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` | 36 | 0 | 0 | 0 | 2 | 38 | +| `flow` | 40 | 0 | 0 | 0 | 3 | 43 | From 588ceba1215273a60d638cb76aaf8e46ed874cc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 21:25:47 +0000 Subject: [PATCH 8/8] fix(lint,spec): future-tense the flow-input-unknown-object consequence; drop the tracker number from the objectName hint (#29442) Review 6588554525 should-fixes: the finding message now says a wrong name "will be refused once the start doors enforce the declaration", matching the planned key, and the authorHint on flow.variables[].objectName no longer names a tracker issue. The two code comments that made the same present-tense claim follow. Claude-Session: https://claude.ai/code/session_01JQwxbaiSvS6eyjEknCGKao Co-authored-by: Claude --- .../src/validate-flow-trigger-readiness.ts | 19 +++++++++++-------- packages/spec/liveness/flow.json | 2 +- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/lint/src/validate-flow-trigger-readiness.ts b/packages/lint/src/validate-flow-trigger-readiness.ts index c15bdde0968..cf132f8f002 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.ts @@ -201,9 +201,10 @@ export interface FlowTriggerReadinessFinding { export const FLOW_TRIGGER_UNKNOWN_OBJECT = 'flow-trigger-unknown-object'; /** * #29442 (#29425 ruling A) — a start input declared a record reference - * (`variables[].objectName`) names an object this stack does not define. Every - * door that starts the flow by name loads such an input from that object, so a - * wrong name refuses every start that supplies the input. The same hedge and + * (`variables[].objectName`) names an object this stack does not define. Once + * the start doors enforce the declaration (planned), every door that starts the + * flow by name will load such an input from that object, so a wrong name will + * refuse every start that supplies the input. The same hedge and * the same closure verdict as `flow-trigger-unknown-object`: advisory with no * package closure, an error under one. */ @@ -935,10 +936,11 @@ export function validateFlowTriggerReadiness( // family has a use for. // 1i. #29442 (#29425 ruling A) — a start input declared a record reference - // names an object this stack does not define. Every door that starts - // the flow by name loads the input from exactly that name in the - // caller's scope, so a wrong name refuses every start that supplies - // the input. Judged like the trigger's object (1 and 1b-i): a `sys_` + // names an object this stack does not define. Once the start doors + // enforce the declaration (planned), every door that starts the flow + // by name will load the input from exactly that name in the caller's + // scope, so a wrong name will refuse every start that supplies the + // input. Judged like the trigger's object (1 and 1b-i): a `sys_` // name is a platform object and is not judged, and the verdict is the // closure's when one is stated. The schema owns the declaration's // shape (input-only, an object name); this rule reads only the name. @@ -959,7 +961,8 @@ export function validateFlowTriggerReadiness( { message: `declares input '${variableName}' the id of a record of object '${objectName}', which this ` + - `stack does not define — if the name is wrong, every start that supplies the input is refused.` + + `stack does not define — if the name is wrong, every start that supplies the input will be ` + + `refused once the start doors enforce the declaration.` + suggestName(objectName, objectNames), hint: `Set the input's objectName to the object's exact registered name, or ignore this if another ` + diff --git a/packages/spec/liveness/flow.json b/packages/spec/liveness/flow.json index 3533a211841..9668e6a5893 100644 --- a/packages/spec/liveness/flow.json +++ b/packages/spec/liveness/flow.json @@ -95,7 +95,7 @@ "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 server half of #29425 ruling A ships, so an id the caller cannot read still starts a run.", + "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." } }