diff --git a/.changeset/29442-flow-start-input-record.md b/.changeset/29442-flow-start-input-record.md
new file mode 100644
index 00000000000..176c98704a3
--- /dev/null
+++ b/.changeset/29442-flow-start-input-record.md
@@ -0,0 +1,17 @@
+---
+'@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`)
+
+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 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, 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 c649ce6a703..d350f545fd2 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` | **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
@@ -2098,6 +2099,56 @@ 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' }],
+});
+```
+
+
+**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`
+`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 61ebd4c19bf..9f9718bd209 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/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx
index 5c9cd00b0d6..877669dd0a8 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 | [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 9e611d47704..ab57f330d9a 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 | [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]`
@@ -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 | [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/lint/src/index.ts b/packages/lint/src/index.ts
index 9837ffc8c3e..5ba609cf32b 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/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 b5bb4d92e42..1cc3498eda0 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,
@@ -5568,6 +5595,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 509d7f93c46..0f23dbe0102 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,71 @@ 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('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)', () => {
const findings = validateFlowTriggerReadiness({
objects: [candidateObject],
@@ -1478,6 +1544,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',
@@ -1504,7 +1587,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..cf132f8f002 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,16 @@ 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. 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.
+ */
+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 +490,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 +506,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 +935,44 @@ 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. 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.
+ // 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 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 ` +
+ `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/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts
index ea7cdc82915..d7993bd5eb7 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
@@ -2257,6 +2257,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 5a2b7b66761..b2a44f09fe4 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
@@ -2257,6 +2257,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 ebf4c0a03ff..dbe5b24c425 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
@@ -2257,6 +2257,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/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts
index a7e37933afc..da392eb5285 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
@@ -1256,7 +1256,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => {
// 700 since #29462: the field form's `referenceNameVisible` row (a lookup
// or user field shows a refused target's name as plain text) — one row
// label, authored in all three locales.
- expect(translated.length, `${locale} positive control`).toBe(700);
+ // 701 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(701);
}
// ⭐ 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
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 65e85f90706..c856b2f2e04 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
@@ -2257,6 +2257,9 @@ export const zhCNMetadataForms: NonNullable =
"variables.defaultValue": {
label: "默认值"
},
+ "variables.objectName": {
+ label: "记录对象"
+ },
status: {
label: "状态",
helpText: "部署状态:draft → active → obsolete"
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 28c44baf555..50a89d21811 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": 998,
+ "publishedSchemasWithDroppedRefinements": 249,
+ "droppedRefinementSites": 1011,
"refinementSitesThatDidProject": 369,
"refinementSitesWithNoJsonFormToCompare": 0
},
@@ -98,6 +98,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",
@@ -152,21 +153,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": {
@@ -210,7 +214,8 @@
"sites": [
"data",
"data.errorHandling",
- "data.nodes.element.in.waitEventConfig"
+ "data.nodes.element.in.waitEventConfig",
+ "data.variables.element"
]
},
"api/GetInstalledPackageResponse": {
@@ -246,6 +251,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",
@@ -387,6 +393,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",
@@ -468,6 +475,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",
@@ -599,14 +607,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": {
@@ -674,7 +684,8 @@
"sites": [
"",
"errorHandling",
- "nodes.element.in.waitEventConfig"
+ "nodes.element.in.waitEventConfig",
+ "variables.element"
]
},
"automation/FlowNode": {
@@ -692,11 +703,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": {
@@ -1942,17 +1959,17 @@
"list.timeline.groupByField"
]
},
- "ui/ViewFilterOrGroup": {
- "sites": [
- "or.element"
- ]
- },
"ui/ViewFilterElement": {
"sites": [
"",
"options[0]"
]
},
+ "ui/ViewFilterOrGroup": {
+ "sites": [
+ "or.element"
+ ]
+ },
"ui/ViewFilterRule": {
"sites": [
""
diff --git a/packages/spec/liveness/flow.json b/packages/spec/liveness/flow.json
index 0ec231b2a9e..9668e6a5893 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."
+ "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 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."
+ }
+ }
},
"edges": {
"status": "live",
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 |
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.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..bec185ae1c5 100644
--- a/packages/spec/src/automation/flow.zod.ts
+++ b/packages/spec/src/automation/flow.zod.ts
@@ -229,11 +229,52 @@ 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)
+ *
+ * [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
+ * 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 +296,30 @@ 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(
+ '[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) => {
+ 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\`.`,
+ });
+ }
}));
/**
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.