Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/29050-flow-start-record-admission.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@objectstack/runtime': patch
---

fix(runtime): every door that starts a flow by name loads the record it hands the run in the caller's own scope, and refuses a record the caller cannot read before the run exists (#29050)

Clause-②: no
Metadata-protocol: none

**The rule.** A start that names a record hands the run that record as the row it addresses. Every door that starts a flow by name now loads each such record key in the caller's own scope first, the load the action door already makes for its routed record, and refuses a key that scope does not deliver with the action door's own `404 RECORD_NOT_FOUND` (ADR-0112 envelope). A record the caller cannot read and an id that names nothing get the same answer. Nothing runs: no run record, no node, no side effect.

**Which doors.** `POST /api/v1/automation/:name/trigger` and the legacy `POST /api/v1/automation/trigger/:name`; a declared endpoint of `type: 'flow'`; and an action of `type: 'flow'` (the REST action route and the MCP `run_action` tool), where a record key the caller's own `params` supply is now judged as the routed record already was.

**Which keys.** The row slots the door itself fills: `recordId`, the `<objectName>Id` alias (`crm_lead` → `crmLeadId`) and an action's declared `recordIdParam`, each with the value the run receives, a caller's own `params` value included. A record key names one record by its id; any other value is refused `400 VALIDATION_FAILED` naming the key.

**The object.** The object the start names: `objectName` (or `object`) in a trigger or endpoint request body, and the action's own object at the action door. A start that names a record key but no object cannot be checked: a flow declared `runAs: 'system'` refuses it `400 VALIDATION_FAILED` naming `objectName`, and a flow that runs as the user starts as before.

**What stays as it was.**

- A start whose caller can read the record.
- A start that names no record.
- The system principal (an in-process caller, such as a job).
- The action door's routed record: its load, its refusal and the record-less and new-record actions are unchanged.
- An action whose `recordIdField` names a field other than `id`: its row slots carry that field's value, not a record id, and are not loaded.

**If a start now answers 404 or 400.** Start it on a record the caller can read, and name the record's object (`objectName`) beside `recordId`.
11 changes: 6 additions & 5 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2473,22 +2473,23 @@ curl -b cookies.txt -X POST \
| `POST /api/v1/automation/:name/runs/:runId/restore-suspension` | Re-arm a run stranded by a failed resume — and, for a nested run, every ancestor whose pause that strand's cascade consumed, deepest first, from whichever member you name. Same gate as `cancel`; body `{ reason? }`. `501 NOT_IMPLEMENTED` when the service does not declare the verb |

**Failures answer real HTTP status codes.** A flow that does not run is not a
`200`. The route classifies seven outcomes. Five of them it reads off the engine's
own verdict — never the message text; the two `403`s are the door's own admission
check, made before anything is dispatched:
`200`. The route classifies eight outcomes. Five of them it reads off the engine's
own verdict — never the message text; the two `403`s and the record `404` are the
door's own admission check, made before anything is dispatched:

| Status | `error.code` | What happened | What to do |
|:---|:---|:---|:---|
| `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 | Start the flow on a record the caller can read, and name the record's object (`objectName`) beside `recordId` |
| `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 |
| `400` | `FLOW_FAILED` | The flow **ran** and was rejected | Read `error.details.summary` for the failing node |

The first six never dispatched anything: no node executed, no record was
written, and there is no run to look up. Only `400` describes a run. The
The first seven never dispatched anything: no node executed, no record was
written, and there is no run to look up. Only `400` `FLOW_FAILED` describes a run. The
`FLOW_INPUT_SCHEMA_INVALID` verdict is a pure function of the flow definition —
re-running the guard cannot change the answer — so a `retry` strategy does not
burn its budget on it: the run log holds exactly one refusal row.
Expand Down
3 changes: 2 additions & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ One of the largest consumers of the flag;
| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `requireManageMetadata`, `requireReadCapability` |
| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `refuseUngrantedActivationWrite`, `refuseUngrantedActivationAuthoring` |
| 55b | Walled environment-metadata refusal does not fire | metadata-core, rest, runtime | Get: under `isolated` / `group`, a write of the environment's metadata, of any type, at the `/meta` write doors (save, reset, publish, rollback) and `POST /meta/_migrate-stored` on both transports, at every `/packages` write door (create, the manifest `PATCH`, enable, disable, publish, `publish-drafts`, `discard-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, `adopt-orphans`, delete), at the `/automation` definition writes (create, update, delete, clone), and at the permission-set clone door. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3, D12 items 7–8, D11), because one organization's write would change what every organization resolves. The seeders, the package door and migrations write the environment's metadata this way. Under `single` the refusal is inert for every caller | `walledCatalogWriteVerdict`, `walledCatalogRefusal`, `refuseWalledCatalogPackageWrite`, `refuseWalledFlowDefinitionWrite` |
| 57 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes and the elevated self-triggered start at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A) — and starting, through the same doors, a flow whose `requiredPermissions` the caller does not hold (ruling record 6377848497, letter A). The three doors ask one check, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `mayReadRunState`, `refuseUngrantedFlowWrite`, `isRunStarterOrRunStateReader`, `refuseUngrantedRunLifecycleWrite`, `flowStartRefusal` |
| 57 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes, and the elevated self-triggered start and the start-record check at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A) — and starting, through the same doors, a flow whose `requiredPermissions` the caller does not hold (ruling record 6377848497, letter A). And starting, through the same doors, a flow on a record key the caller's own scope does not deliver, which every other caller is refused with the action door's `404 RECORD_NOT_FOUND` before the run exists (and `400 VALIDATION_FAILED` for a key that is not one id, or for an elevated start that names no object). The three doors ask one check, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `mayReadRunState`, `refuseUngrantedFlowWrite`, `isRunStarterOrRunStateReader`, `refuseUngrantedRunLifecycleWrite`, `flowStartRefusal`, `refuseUnreadableFlowStartRecords` |
| 57b | Connector credential door's capability and posture gates pass | runtime | Get: listing, setting and clearing the environment's stored connector credentials (`/automation/connectors/credential-refs`) without `manage_metadata`, and under the `group` / `isolated` postures, where the door refuses every other caller `403 PERMISSION_DENIED` naming the posture, the platform operator included, because the store is environment-level. The bypass is the in-process caller's: inbound HTTP never carries the flag. Get nothing more about the value: no answer of this door carries it | `refuseUngrantedConnectorCredentialAccess`, `refuseWalledConnectorCredentialAccess` |
| 57c | Connector connection door's capability gate passes | runtime | Get: reading, beginning, completing (the OAuth callback) and revoking an organization's connection of an `oauth2` `authorization_code` connector instance (`/automation/connectors/:name/connection`, `/automation/connectors/oauth/callback`, ADR-0143 D5) without `manage_metadata`. No posture refuses it: a connection belongs to its organization. The bypass is the in-process caller's: inbound HTTP never carries the flag. Get nothing more: no answer of this door carries a token, and the callback's `state` is still bound to the user and organization that began the connect | `refuseUngrantedConnectorConnectionAccess` |
| 58 | Audience-binding suggestion authority gate passes | plugin-security | Get: a system caller lists, accepts and dismisses suggestions without `manage_metadata` and, under a wall, without the platform-operator rung | `assertDeploymentAuthority` |
Expand Down Expand Up @@ -625,6 +625,7 @@ and CI refuses one that differs from what the tree produces.
| `packages/runtime/src/action-execution.ts#actionPermissionError` | 1 | 49 |
| `packages/runtime/src/action-execution.ts#callData` | 1 | 48 |
| `packages/runtime/src/action-execution.ts#refuseUnexposedDataAction` | 1 | 48 |
| `packages/runtime/src/action-execution.ts#refuseUnreadableFlowStartRecords` | 1 | 57 |
| `packages/runtime/src/domains/actions.ts#handleActionsRequest` | 1 | 52 |
| `packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationAuthoring` | 1 | 56 |
| `packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite` | 1 | 56 |
Expand Down
Loading
Loading