Skip to content
23 changes: 23 additions & 0 deletions .changeset/29425-declared-record-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@objectstack/runtime': patch
'@objectstack/spec': patch
---

fix(runtime): a flow start input declared a record id (`objectName`) is loaded in the caller's own scope from the declared object at every door that starts the flow by name, before the run exists

Clause-②: yes (narrowing) — a start that ran before is now refused when it hands a declared record-id input an id the caller cannot read in the declared object, an id that names no record there, or a value that is not one id. No flow shipped in this repository declares the key, and an input without the declaration answers exactly as before.
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped: `objectName` on a flow variable already parses, and what moves is the answer a start door gives to a caller who hands such an input a value it cannot read. Nothing at rest records that answer, so there is nothing for a migration to rewrite. -->

**The rule.** A flow start input may declare that its value is the id of a record of a named object, with `objectName` on the variable (`{ name: 'accountId', type: 'text', isInput: true, objectName: 'crm_account' }`). Every door that starts the flow by name now loads such an input from the declared object in the caller's own scope before the run exists, whether or not the flow's definition reads the input in plain sight. A record the caller cannot read there and an id that names no record get the same answer, `404 RECORD_NOT_FOUND` (the action door's own refusal), and no run is created. A value that is not one id (a string or a number) is refused `400` naming the input: `VALIDATION_FAILED` from the start check, or, at an action whose param declares a type, `VALIDATION_ERROR` from that param's own contract, which answers first. The system principal is not judged.

**Which doors.** `POST /api/v1/automation/:name/trigger` and the legacy `POST /api/v1/automation/trigger/:name`; a declared endpoint of `type: 'flow'`; an action of `type: 'flow'` over the REST action route and the MCP `run_action` tool. All of them ask the one check they already shared for inputs a definition reads in plain sight.

**Beside the definition's own reads, never in place of them.** An input declared a record id of one object and also read by the definition as an id of another (a `get_record` node's `filter.id`, say) is loaded from both, so the caller must be able to read both rows. A parent flow that hands its input on, unchanged, to a `subflow` or `map` callee whose input carries the declaration is judged by that declaration too. A `loop` or `map` that later rebinds the name does not withdraw the declaration: it is the author's word about the value the caller sent.

**What stays as it was.** An input without the declaration keeps the visible-read rule: it is judged only where the definition reads it as a record id. A start whose caller can read the declared record starts as before.

**If a start now answers 404 or 400.** Hand the declared input the id of a record the caller can read in the object the input names, or start the flow as the system principal.

`@objectstack/spec`: the `objectName` describe on a flow variable no longer says that no door applies it, and its liveness row is `live`, so `os validate` no longer warns `liveness-planned-property` on a flow that declares it.
36 changes: 19 additions & 17 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2184,23 +2184,25 @@ export const CloseAccount = defineFlow({
});
```

<Callout type="warn">
**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.
</Callout>

The contract: every door that starts the flow by name (the trigger route, an
action of `type: 'flow'` over REST and MCP `run_action`, a declared endpoint of
Every door that starts the flow by name (the trigger route, an action of
`type: 'flow'` over REST and MCP `run_action`, a declared endpoint of
`type: 'flow'`) loads a declared input from the named object in the caller's
own scope before the run exists. An id the caller cannot read there is refused
`404` `RECORD_NOT_FOUND`; a value that is not an id is refused `400`
`VALIDATION_FAILED` naming the input. An input without the declaration is
judged as a record only where the flow's own definition reads it as a record id
(see the `404` row under [running a flow via the API](#run-a-flow-via-api)).
own scope before the run exists. An id the caller cannot read there, or one
that names no record, is refused `404` `RECORD_NOT_FOUND`, and no run is
created; a value that is not an id is refused `400` naming the input
(`VALIDATION_FAILED`; at an action, a param's own declared type answers first
with `VALIDATION_ERROR`). An input without the declaration is judged as a
record only where the flow's own definition reads it as a record id (see the
`404` row under [running a flow via the API](#run-a-flow-via-api)). The system
principal is not judged.

- **Beside the definition's own reads, never in place of them.** When the flow
also reads the input as an id of another object (a `get_record` node's
`filter.id`, say), the door loads it from both, and the caller must be able
to read both rows.
- **Followed into sub-flows.** A parent that hands its input on, unchanged, to
a `subflow` or `map` callee whose input carries the declaration is judged by
that declaration too.

- **Inputs only.** A variable that is not `isInput: true` receives nothing from
a caller, so `objectName` there is refused at parse.
Expand Down Expand Up @@ -2606,7 +2608,7 @@ door's own admission check, made before anything is dispatched:
| `404` | — | No flow by that name in this deployment | Check the name; nothing was dispatched |
| `403` | `PERMISSION_DENIED` | The caller is not the system principal, and the flow is declared `runAs: 'system'` with a self-triggered `type` (`autolaunched`, `record_change`, `schedule`). The same answer at every door that starts a flow by name: this route, a `type: 'flow'` action (REST and MCP `run_action`) and a declared `type: 'flow'` endpoint | Let it run on its own trigger, or call it from a parent flow's `subflow` node. If members are meant to start it, declare it `type: 'screen'` (or `'api'` for a signed inbound hook) |
| `403` | `PERMISSION_DENIED` | The flow declares `requiredPermissions` and the caller, not the system principal, does not hold every capability it names. Checked after the row above, at the same doors; the message names the declared and the missing capabilities | Grant the capability through a permission set's `systemPermissions`, or start the flow as someone who holds it |
| `404` | `RECORD_NOT_FOUND` | The start names a record (`recordId`, or the object's id alias such as `crmLeadId`) that the caller, not the system principal, cannot read in its own scope, or that does not exist: the two get one answer, the one the action door gives. Checked after the two rows above, at the same doors. A record key that is not one id, or a record named with no `objectName` for a flow declared `runAs: 'system'`, is refused `400` `VALIDATION_FAILED` naming the key. Where the flow's own definition reads a start input as the id of a record of a fixed object (a `get_record`, `update_record` or `delete_record` node with a literal `objectName` whose `filter.id` or `filter.id.$eq` is the input, or whose `filter.id.$in` is; a `screen` node with a literal `objectName` whose `recordId` is the input; a `subflow` or `map` node that hands the input to a callee input the callee reads so), that input is also loaded in the caller's own scope from THAT object, whatever object the start names, or none, and whatever the flow runs as: a value the caller cannot read there gets this `404`, and a value that is not one id (or a list of ids, where the definition reads a list) is refused `400` `VALIDATION_FAILED` naming the input | Start the flow on a record the caller can read, and name the record's object (`objectName`) beside `recordId`. Hand an input the flow reads as a record id the id of a record the caller can read in the object the flow reads it from, in the shape the flow reads (one id, or a list of ids) |
| `404` | `RECORD_NOT_FOUND` | The start names a record (`recordId`, or the object's id alias such as `crmLeadId`) that the caller, not the system principal, cannot read in its own scope, or that does not exist: the two get one answer, the one the action door gives. Checked after the two rows above, at the same doors. A record key that is not one id, or a record named with no `objectName` for a flow declared `runAs: 'system'`, is refused `400` `VALIDATION_FAILED` naming the key. Where the flow's own definition reads a start input as the id of a record of a fixed object (a `get_record`, `update_record` or `delete_record` node with a literal `objectName` whose `filter.id` or `filter.id.$eq` is the input, or whose `filter.id.$in` is; a `screen` node with a literal `objectName` whose `recordId` is the input; a `subflow` or `map` node that hands the input to a callee input the callee reads so), that input is also loaded in the caller's own scope from THAT object, whatever object the start names, or none, and whatever the flow runs as: a value the caller cannot read there gets this `404`, and a value that is not one id (or a list of ids, where the definition reads a list) is refused `400` `VALIDATION_FAILED` naming the input. An input the flow declares a record id (`objectName` on the variable, [above](#a-start-input-that-is-a-record-id)) is loaded from the declared object the same way, whether or not the definition reads it | Start the flow on a record the caller can read, and name the record's object (`objectName`) beside `recordId`. Hand an input the flow reads as a record id the id of a record the caller can read in the object the flow reads it from, in the shape the flow reads (one id, or a list of ids) |
| `409` | `FLOW_DISABLED` | The flow exists but is switched off | Enable it — the identical request then succeeds |
| `422` | `FLOW_NO_START_NODE` | The stored definition has no `start` node | Fix the flow; retrying cannot help |
| `422` | `FLOW_INPUT_SCHEMA_INVALID` | A node's `config` violates the `inputSchema` its own definition declares | Fix the flow; the refusal is non-retryable — even under `errorHandling.strategy: 'retry'` the engine refuses once and never enters the retry loop |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/api/automation-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ const result = AutomationApiErrorCode.parse(data);
| **isInput** | `boolean` | optional (default: `false`) | Is input parameter |
| **isOutput** | `boolean` | optional (default: `false`) | Is output parameter |
| **defaultValue** | `any` | optional | Value bound at run start when no parameter supplies one — this is what makes a declared variable always bound. An explicitly supplied param wins, including `false` and `null`; the boundary is `params[name] !== undefined`. |
| **objectName** | `string` | optional | [planned] Declares this input a record reference: its value is the id of one record of the named object (the object's snake_case name, e.g. `crm_account`). Only on an input (`isInput: true`). The contract: every door that starts the flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow) loads a declared input from this object in the caller's own scope before the run exists; an id the caller cannot read there is refused 404 RECORD_NOT_FOUND, and a value that is not an id is refused 400 VALIDATION_FAILED naming the input. No door applies it yet: today the key is parsed and checked, and a start with an id the caller cannot read still runs. An input without this declaration is judged as a record only where the flow's own definition reads it as a record id. `type` stays the data type: the variable holds the id the caller sent. |
| **objectName** | `string` | optional | Declares this input a record reference: its value is the id of one record of the named object (the object's snake_case name, e.g. `crm_account`). Only on an input (`isInput: true`). The contract: every door that starts the flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow) loads a declared input from this object in the caller's own scope before the run exists; an id the caller cannot read there is refused 404 RECORD_NOT_FOUND, and a value that is not an id is refused 400 naming the input. The load is added beside any read the flow's own definition makes of the input, never in place of one. An input without this declaration is judged as a record only where the flow's own definition reads it as a record id. `type` stays the data type: the variable holds the id the caller sent. |

### Nested Shape: `CreateFlowRequest.nodes[number]`

Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/automation/flow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ const result = FlowSchema.parse(data);
| **isInput** | `boolean` | optional (default: `false`) | Is input parameter |
| **isOutput** | `boolean` | optional (default: `false`) | Is output parameter |
| **defaultValue** | `any` | optional | Value bound at run start when no parameter supplies one — this is what makes a declared variable always bound. An explicitly supplied param wins, including `false` and `null`; the boundary is `params[name] !== undefined`. |
| **objectName** | `string` | optional | [planned] Declares this input a record reference: its value is the id of one record of the named object (the object's snake_case name, e.g. `crm_account`). Only on an input (`isInput: true`). The contract: every door that starts the flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow) loads a declared input from this object in the caller's own scope before the run exists; an id the caller cannot read there is refused 404 RECORD_NOT_FOUND, and a value that is not an id is refused 400 VALIDATION_FAILED naming the input. No door applies it yet: today the key is parsed and checked, and a start with an id the caller cannot read still runs. An input without this declaration is judged as a record only where the flow's own definition reads it as a record id. `type` stays the data type: the variable holds the id the caller sent. |
| **objectName** | `string` | optional | Declares this input a record reference: its value is the id of one record of the named object (the object's snake_case name, e.g. `crm_account`). Only on an input (`isInput: true`). The contract: every door that starts the flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow) loads a declared input from this object in the caller's own scope before the run exists; an id the caller cannot read there is refused 404 RECORD_NOT_FOUND, and a value that is not an id is refused 400 naming the input. The load is added beside any read the flow's own definition makes of the input, never in place of one. An input without this declaration is judged as a record only where the flow's own definition reads it as a record id. `type` stays the data type: the variable holds the id the caller sent. |

### Nested Shape: `Flow.nodes[number]`

Expand Down Expand Up @@ -231,7 +231,7 @@ const result = FlowSchema.parse(data);
| **isInput** | `boolean` | optional (default: `false`) | Is input parameter |
| **isOutput** | `boolean` | optional (default: `false`) | Is output parameter |
| **defaultValue** | `any` | optional | Value bound at run start when no parameter supplies one — this is what makes a declared variable always bound. An explicitly supplied param wins, including `false` and `null`; the boundary is `params[name] !== undefined`. |
| **objectName** | `string` | optional | [planned] Declares this input a record reference: its value is the id of one record of the named object (the object's snake_case name, e.g. `crm_account`). Only on an input (`isInput: true`). The contract: every door that starts the flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow) loads a declared input from this object in the caller's own scope before the run exists; an id the caller cannot read there is refused 404 RECORD_NOT_FOUND, and a value that is not an id is refused 400 VALIDATION_FAILED naming the input. No door applies it yet: today the key is parsed and checked, and a start with an id the caller cannot read still runs. An input without this declaration is judged as a record only where the flow's own definition reads it as a record id. `type` stays the data type: the variable holds the id the caller sent. |
| **objectName** | `string` | optional | Declares this input a record reference: its value is the id of one record of the named object (the object's snake_case name, e.g. `crm_account`). Only on an input (`isInput: true`). The contract: every door that starts the flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow) loads a declared input from this object in the caller's own scope before the run exists; an id the caller cannot read there is refused 404 RECORD_NOT_FOUND, and a value that is not an id is refused 400 naming the input. The load is added beside any read the flow's own definition makes of the input, never in place of one. An input without this declaration is judged as a record only where the flow's own definition reads it as a record id. `type` stays the data type: the variable holds the id the caller sent. |


---
Expand Down
Loading
Loading