You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit dfbe34d
Browse filesBrowse the repository at this point in the historyBrowse files
fix(runtime): every flow start door judges a start input against the object the flow's definition reads it from (#29365)
domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1
<!-- claude-projects-attribution: {"github_login":"os-steve"} -->
_Requested by **Steve** · [project
thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9M4Y7Yr5yLRtgGtSGF4kcmk)_
Refs #29050
Clause-②: no
Metadata-protocol: none
## 维护者速读
- 改了什么:flow 定义里明确把某个启动输入当作「某个固定对象的记录 id」来读的地方(CRUD 节点的 `filter.id` /
`$eq` / `$in`、screen 编辑表单的 `recordId`,以及经 `subflow` / `map`
传下去的输入),所有按名字启动 flow 的入口在启动前,都按调用者自己的权限从「flow 读的那个对象」加载这个输入;读不到回 `404
RECORD_NOT_FOUND`,值不是 id(或不是 id 列表)回 `400 VALIDATION_FAILED`
并点名该输入,不产生运行记录。REST action 入口的 body `recordId` 不是单个 id 时回 400 点名
`recordId`。
- 为什么改:规则跟着 flow 定义里声明的读取走:定义把某个启动输入当作某对象的记录 id
读时,就从那个对象按调用者权限判断。只实现定义里已写明的读取,不改 spec,不加新键。
- 风险与回滚:调用者能读的记录、定义里没有声明为 id 读取的输入(包括计算出来的值)、系统身份、action 入口的主记录字段和非 `id`
的 `recordIdField` 都不变。新增拒绝只针对定义里声明的 id 读取。回滚即撤掉本 PR。
- 先看:`packages/runtime/src/flow-start-admission.ts` 的
`flowDeclaredRecordReads`;`packages/runtime/src/action-execution.ts` 的
`refuseUnreadableFlowStartRecords` 与
`flowActionStartInputs`;`domains/actions.ts` 的 `recordId` 形状检查。
- 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run
src/flow-start-declared-record-reads.test.ts`,应看到 48 条全过。
- 需要你做的:无,席位落地。
## What changes
Flow start admission's third question (#29227,
`refuseUnreadableFlowStartRecords`) now also asks it of **the object the
flow reads**, wherever the flow's own definition declares that read.
**The rule.** Where a flow's definition reads a start input as the id of
a record of a fixed object, every door that starts the flow by name
loads that input in the caller's own scope from that object before the
run exists:
- a value the caller cannot read there, or an id that names nothing, is
refused with the action door's own `404 RECORD_NOT_FOUND` (one envelope
for both);
- the input must name one record by its id (a string or a number), or a
list of records by their ids where the definition reads a list; any
other value is refused `400 VALIDATION_FAILED` naming the input, and
nothing is loaded;
- this holds whatever object the start names, or none, and whatever the
flow runs as;
- nothing runs on a refusal.
**Where a definition declares such a read** (`flowDeclaredRecordReads`,
`flow-start-admission.ts`), across every region of the flow
(`collectFlowGraphs`):
- a `get_record`, `update_record` or `delete_record` node with a literal
`objectName` whose `filter.id` or `filter.id.$eq` is the input (one id),
or whose `filter.id.$in` is (a list);
- a `screen` node with a literal `objectName` whose `recordId` is the
input;
- a `subflow` or `map` node with a literal `flowName` that hands the
input (or, for a `map` iterating it, each item of it) to a callee input
the callee reads so, followed up to 8 levels and never re-entering a
flow already on the path.
"Is the input" means a CEL value whose whole source is the input's name,
bare or through `vars.`. A computed value is not a declared read, and a
name a `loop` or `map` node binds as its item or index variable is not
read as a start input.
**Which values each door judges.**
- Trigger routes (both spellings) and a declared `type: 'flow'`
endpoint: every key of the built `params` bag (`flowStartRecordKeys` now
also returns it as `inputs`).
- The action door's flow branch, REST and MCP (`flowActionStartInputs`):
each key of the caller's own `params`, and the routed record id under
the row slots. The routed record keeps its subject load against the
action's object, and is now also judged against the object the flow
reads. Left out: the subject record's own fields, and the row slots of
an action whose `recordIdField` names a non-`id` field.
**The REST action route's routed record is one id**
(`domains/actions.ts`). A body `recordId` that is not a string or a
number is refused `400 VALIDATION_FAILED` naming `recordId`, before the
declared param contract, the record load and dispatch, for every action
type. Absent, `null` and `''` stay the record-less call.
**Unchanged.** A start whose caller can read the record in the object
the flow reads it from; an input the definition does not read as an id;
the system principal; the #29227 checks on the start's named object,
which run first as before.
Changeset: `.changeset/29050-flow-declared-record-reads.md`, a `patch`
for `@objectstack/runtime`, rule-only.
Docs, in the changeset's words:
- `content/docs/automation/flows.mdx`: the trigger route's `404
RECORD_NOT_FOUND` row also states that a start input the flow's
definition reads as the id of a record of a fixed object is loaded in
the caller's own scope from that object, and must be one id or a list of
ids (`400` naming the input otherwise).
- `content/docs/ui/actions.mdx`: a body `recordId` names one record by
its id, a string or a number, or the route answers `400
VALIDATION_FAILED` naming `recordId`.
## Tests
- **New**
`packages/runtime/src/flow-start-declared-record-reads.test.ts` (48
tests), over a row-scoped data double with two objects:
- the reading: each declaring place, the `vars.` spelling, nodes inside
a loop body and a try region, subflow and map hand-offs, a self-calling
flow, and what declares nothing (a non-input, a computed value, a non-id
filter key, a non-literal object, a shadowed loop item, a service with
no `getFlow`);
- both trigger spellings: an input the caller cannot read in the object
the flow reads is refused 404 with no run; the firing control starts the
run;
- a custom input: 404 with no run, the firing control, and 400 naming
the input for an object, a list or a boolean, with nothing loaded;
- a list input: all readable starts, one unreadable is 404, and one id
or a list holding an object is 400;
- reads declared through a subflow, a map and a screen edit form: 404
with no object named, including a user-run parent;
- an unreadable id and an id that names nothing get one envelope;
- a declared endpoint: 404 and the firing control;
- the action door, REST and MCP: the routed record and a declared action
param are judged against the object the flow reads, with firing
controls, and the trigger route and the action door answer one envelope;
- the REST action route: a body `recordId` that is an object, a list or
a boolean is 400 naming `recordId` for a flow and a script action, with
nothing loaded or run; a string and a number pass the shape check;
- unchanged: a computed read, the system principal, an input the flow
does not read as an id.
- **Forced test edits: none.** The 50 existing runtime test files that
drive the changed doors pass unchanged.
## Acceptance notes
- **Built only from what the definition declares.** No `packages/spec`
change and no new keys. A read the definition does not state (a computed
id, a non-id filter key, a node that is not one of the above) is not
judged; those are left for a decision that needs the spec.
- **The routed record's shape.** The check sits at the REST action
route. The MCP `run_action` tool already accepts only a string
`recordId`.
- **Order.** The #29227 checks on the start's named object run first,
unchanged; the declared reads run after them and before `execute()`.
Every refusal is asserted with `execute` never called.
- **Open PRs re-read** before opening: #29283 touches
`action-execution.ts` and `domains/actions.ts`, and #29097 touches
`domains/automation.ts`; none of their hunks overlap this diff.
## Verification
Head `1ffeb77c`: the two docs pages on top of `5f06db07` (the merge of
`origin/main` at `5780fa49` onto `e92495c0`). The code readings were
taken on `5f06db07`; `1ffeb77c` changes no code.
<details><summary>Runtime package: typecheck, tests, lint</summary>
- `pnpm --filter @objectstack/runtime test` through `os-verify-lock.sh`
on head `5f06db07`: Test Files 517 passed (517), Tests 8419 passed, 19
skipped (8438). VERDICT command-exit 0.
- `pnpm typecheck` in `packages/runtime` (includes
`check:test-typecheck`): exit 0; the test-typecheck debt ledger held
unchanged.
- `vitest run src/flow-start-declared-record-reads.test.ts`: Test Files
1 passed (1), Tests 48 passed (48).
- Before the merge of `origin/main`, the 50 existing runtime test files
that drive the trigger routes, the endpoint and the action door (every
test file naming `handleActions`, `dispatchFlowAction`,
`invokeBusinessAction`, `flowStartRecordKeys` or
`refuseUnreadableFlowStartRecords`): Test Files 50 passed (50), Tests
1326 passed (1326), with no edit.
- `eslint packages/runtime --no-inline-config` (the root `pnpm lint`
command, scoped): exit 0, no output.
</details>
Each pin goes red with its check removed (seven ablations, each restored
to the HEAD blob).
<details><summary>Dispatch gates</summary>
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` on head `1ffeb77c` derived 92 commands (the
62 of `5f06db07` plus 30 docs families). Each ran with `< /dev/null`: 92
exit 0.
- `dispatch-gates.mjs --repo objectstack-ai/objectstack --ran <file>`:
92 derived famil(ies) accounted for, 92 run, 0 NOT-MEASURED (a DERIVED
zero). Changed lines 967 (+937 / −30) against the 3000 threshold.
</details>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01ADHkmRp2YLTpRc6Vfw6TD3
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(runtime): a start input a flow's definition reads as a record id is loaded in the caller's own scope from the object the flow reads it from, and must be an id, before the run exists (#29050)
6
+
7
+
Clause-②: no
8
+
Metadata-protocol: none
9
+
10
+
**The rule.** Where a flow's own definition reads a start input as the id of a record of a fixed object, every door that starts the flow by name loads that input in the caller's own scope from THAT object before the run exists, and refuses a value the caller cannot read there 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. The input must name one record by its id (a string or a number), or a list of records by their ids where the definition reads it as a list; any other value is refused `400 VALIDATION_FAILED` naming the input. Nothing runs on a refusal: no run record, no node, no side effect. This holds whatever object the start names, or none, and whatever the flow runs as.
11
+
12
+
**Where a definition reads an input as a record id.** Across every region of the flow (loop bodies, parallel branches, try and catch regions):
13
+
14
+
- a `get_record`, `update_record` or `delete_record` node with a literal `objectName` whose `filter.id` or `filter.id.$eq` is the input (one id), or whose `filter.id.$in` is (a list of ids);
15
+
- a `screen` node with a literal `objectName` whose `recordId` is the input;
16
+
- a `subflow` node with a literal `flowName` that hands the input to a callee input the callee reads so, and a `map` node that does the same with the input itself or with each item of it (a `map` iterating the input reads it as a list of ids).
17
+
18
+
"Is the input" means a CEL value whose whole source is the input's name (`caseId` or `vars.caseId`).
19
+
20
+
**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), for each key of the caller's own `params` and for the routed record id under the row slots, so the routed record is judged against the object the flow reads as well as the action's own object.
21
+
22
+
**The REST action route's routed record is one id.** A body `recordId` that is not one id (a string or a number) is refused `400 VALIDATION_FAILED` naming `recordId`, before the declared param contract, the record load and dispatch, for every action type. A path segment is always one id; an absent, `null` or empty `recordId` is the record-less call, as before.
23
+
24
+
**What stays as it was.**
25
+
26
+
- A start whose caller can read the record in the object the flow reads it from.
27
+
- An input the definition does not read as a record id, including a value the flow computes from an input or derives elsewhere.
28
+
- A name a `loop` or `map` node binds as its item or index variable, which no longer holds the start's value once that node runs.
29
+
- The system principal (an in-process caller, such as a job).
30
+
- At the action door, the subject record's own fields, and the row slots of an action whose `recordIdField` names a field other than `id`.
31
+
32
+
**If a start now answers 404 or 400.** Hand the input 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).
Copy file name to clipboardExpand all lines: content/docs/automation/flows.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -2482,7 +2482,7 @@ door's own admission check, made before anything is dispatched:
2482
2482
|`404`| — | No flow by that name in this deployment | Check the name; nothing was dispatched |
2483
2483
|`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) |
2484
2484
|`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 |
2485
-
|`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`|
2485
+
| `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) |
2486
2486
|`409`|`FLOW_DISABLED`| The flow exists but is switched off | Enable it — the identical request then succeeds |
2487
2487
|`422`|`FLOW_NO_START_NODE`| The stored definition has no `start` node | Fix the flow; retrying cannot help |
2488
2488
|`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 |
0 commit comments