Repository navigation
Commit c347d22
fix(runtime): every door that starts a flow by name refuses a record key the caller cannot read before the run exists (#29227)
<!-- claude-projects-attribution: {"github_login":"os-steve"} -->
_Requested by **Steve** · [project
thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9Wc1DX7AbpEoxwZbBojqqdW)_
Fixes #29050
Clause-②: no
Metadata-protocol: none
## 维护者速读
- 改了什么:所有按名字启动 flow 的入口(两种写法的 trigger 路由、声明式 `type: 'flow'`
endpoint、flow 类 action 的 REST 与 MCP
入口)在启动前,都用调用者自己的权限读一次这次启动交给运行的记录;读不到就回 action 入口同一个 `404
RECORD_NOT_FOUND`,不产生运行记录。
- 为什么改:三个入口对「这次启动指向的记录,调用者能不能读」问法不一致,action 入口问了,其余入口没问;以 `runAs:
'system'` 运行的 flow 会越过调用者自己的读权限。本 PR 让三个入口问同一个问题、给同一个答复。
- 风险与回滚:调用者能读的记录、不指定记录的启动、系统身份(进程内调用方)都不变。新增三种拒绝:读不到或不存在的记录回
404;以系统身份运行的 flow 指定了记录但没指定对象回 400;记录键不是单个 id 回 400。用户身份 flow
在指定了对象时也会检查,与 action 入口一致。回滚即撤掉本 PR。
- 先看:`packages/runtime/src/action-execution.ts` 的
`refuseUnreadableFlowStartRecords` 和
`flowActionCallerRecordKeys`;`domains/automation.ts` 的
`respondToFlowTrigger` 与 `flowStartRecordKeys`;`endpoint-executor.ts` 的
`executeFlow`。
- 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run
src/flow-start-record-admission.test.ts`,应看到 32 条全过。
- 需要你做的:无,席位落地。
## What changes
Every door that starts a flow by name now asks a third admission
question after `flowStartRefusal` and before `execute()`: **the row this
start addresses.** One function, `refuseUnreadableFlowStartRecords`
(`packages/runtime/src/action-execution.ts`, beside the action door's
subject load that it reuses), is called by:
- the trigger routes, `POST /api/v1/automation/:name/trigger` and the
legacy `POST /api/v1/automation/trigger/:name` (`respondToFlowTrigger`,
`domains/automation.ts`);
- a declared endpoint of `type: 'flow'` (`executeFlow`,
`endpoint-executor.ts`);
- an action of `type: 'flow'` at the REST action route and the MCP
`run_action` tool (`dispatchFlowAction`), for the row slots the caller's
own `params` fill. The routed record keeps the subject load and refusal
it already had, unchanged.
The rule it applies:
- **Which keys.** Every record key in a row slot the door itself defines
(`recordId`, the `{objectName}Id` alias, an action's declared
`recordIdParam`), with the value the run will receive, a caller's own
`params` value included.
- **The load.** Each key is loaded in the caller's own scope through the
door's own caller-scope read (`callData('get', …)`, the read the action
door's subject load makes), via `loadActionSubjectRecord`.
- **The refusal.** A key that scope does not deliver is refused by
`refuseDeniedSubjectLoad`: the action door's own `404 RECORD_NOT_FOUND`
(ADR-0112 envelope). An unreadable record and an id that names nothing
get one answer. Nothing runs and no run is persisted.
- **The object.** The object the start names: `objectName` (or `object`)
in a trigger or endpoint body, the action's own object at the action
door. A start that names a record key but no object cannot be judged: a
flow declared `runAs: 'system'` refuses it `400 VALIDATION_FAILED`
naming `objectName`; a flow that runs as the user starts as before.
- **Non-id keys.** A record key whose value is not one id (a string or a
number) is refused `400 VALIDATION_FAILED` naming the key, and is never
loaded.
- **Unchanged.** A start whose caller can read the record; a start that
names no record; the system principal; an action whose `recordIdField`
names a field other than `id` (its row slots carry that field's value,
not an id).
Supporting changes:
- `flow-start-admission.ts`: the module docblock states the third
question, and `flowRunsElevated` is the one reading of `runAs` it needs.
The module still imports nothing from `action-execution.ts`.
- `domains/automation.ts`: `recordIdAlias` is now the one spelling of
the `{objectName}Id` alias, read by `buildAutomationContext` to seed it
and by the new `flowStartRecordKeys` to judge it. The trigger routes
answer the refusal through `deps.errorFromThrown`, the exit the action
door serves the same refusal through, so both doors answer one envelope.
- `content/docs/permissions/system-context.mdx`: row 57 (the flow start
admission bypass of the system principal) now names this check and its
symbol, and the site register is regenerated with
`check-system-context-census.mjs --write`. The new function reads
`isSystem` once, so `check:system-context-census` required a row to own
it.
- Changeset: `.changeset/29050-flow-start-record-admission.md`, a
`patch` for `@objectstack/runtime`, rule-only.
## Why
#29050: the doors that start a flow by name did not all ask whether the
caller can read the record the start hands the run, so the three doors
disagreed. `flow-start-admission.ts` already says a door left out of a
start rule is the defect. This PR states the rule once and has every
door ask it.
## Tests
- **New** `packages/runtime/src/flow-start-record-admission.test.ts` (32
tests), over a row-scoped data double:
- at both trigger spellings, a flow endpoint, the REST action route and
the MCP action tool, a start door given a record key the caller cannot
read is refused `404 RECORD_NOT_FOUND`, and no run is created;
- a record that does not exist gets the identical answer;
- a start on a record the caller can read starts the run (the firing
control at each door);
- each row slot is judged with the value the run receives;
- an elevated start that names a record but no object is refused 400
naming `objectName`, and nothing is loaded;
- a record key that is not one id is refused 400 naming the key, and
nothing is loaded;
- unchanged cases: no record key, the system principal, a user-run flow
with no object, an action whose row slots carry a non-id field;
- the trigger route and the action door answer the same status, code and
message for the same refusal.
- **Forced test edits** (8 files). Each fixture started a flow on a
record against a data double that did not serve that record to the
caller. Each now serves it; no assertion changed:
- `flow-run-locale.test.ts`, `flow-run-posture.test.ts`,
`flow-caller-param-keys.test.ts`: the trigger-route cases' kernel also
serves a data read for the launched record.
- `domains/automation-trigger-params-shape.test.ts`: the dispatcher
double also serves the launched record (the `a record launch` case).
- `domain-handler-registry.test.ts`: the both-routes identity case
serves its launched record.
- `endpoint-executor.test.ts`: the flow delegation case's `callData`
double returns the launched record.
- `dispatcher-plugin.endpoint-fallback.integration.test.ts`: the fake
engine serves the row the flow endpoint is launched on.
- `http-dispatcher.actions-type-dispatch.test.ts`: in `lets an explicit
param win over every seeded key`, the caller can also read the record
its own param names.
## Acceptance notes
- **Doors measured** (the card's assumption A). Four entrances reach
`IAutomationService.execute` by flow name from a caller: the two trigger
spellings, a declared `type: 'flow'` endpoint, and `dispatchFlowAction`
(shared by the REST action route and the MCP `run_action` tool). All
four now ask the question. A parent flow's `subflow` node starts its
child through the engine, not a door, as `flow-start-admission.ts`
already notes.
- **The object** (assumption B). The trigger and endpoint bodies carry
`objectName`/`object`, and the action door has its action's object. A
record key with no object is refused for an elevated flow and passes for
a user flow, as described above. No `packages/spec` change.
- **User-run flows** (assumption C). The check also runs for user flows
when the start names an object, the same way the action door's
routed-record load already runs for every flow. Measured cost: 12
existing tests in 8 files had to serve their launched record (the forced
edits above).
- **Before any run.** Every refusal is asserted with `execute` never
called.
## Verification
Head `2020b6e5`. Code readings were taken on `5fda11a5` (the merge of
`origin/main` at `4550091a`); `2020b6e5` adds only the docs row above.
Heavy runs went through `os-verify-lock.sh`.
<details><summary>Runtime package: typecheck, full test, lint</summary>
- `pnpm --workspace-concurrency=2 --filter '@objectstack/runtime^...'
build` after the merge: VERDICT command-exit 0.
- `pnpm --filter @objectstack/runtime typecheck` (includes
`check:test-typecheck`): VERDICT command-exit 0.
- `pnpm --filter @objectstack/runtime test`: Test Files 515 passed
(515), Tests 8350 passed, 19 skipped (8369). VERDICT command-exit 0.
- Before the fixture edits, the same full run read Test Files 8 failed,
507 passed (515); Tests 12 failed, 8338 passed. All 12 were the record
launches named under *Forced test edits*, failing with `expected 404 to
be 200`, an `execute` mock called 0 times, or (the action door case) a
run that never started.
- `vitest run` over the 8 edited files plus the new file: Test Files 9
passed (9), Tests 250 passed (250).
- `eslint packages/runtime --no-inline-config` (the root `pnpm lint`
command, scoped): exit 0, no output.
</details>
<details><summary>Ablations: every new refusal goes red when the check
is taken out</summary>
Each leg used `scripts/ablation-replace.mjs` on one anchor, ran `vitest
run src/flow-start-record-admission.test.ts` through the lock, and
restored. `git diff HEAD` was 0 bytes after every leg.
- **Trigger routes** (the trigger door hands the check no keys): 17
failed, 15 passed. For example, `expected 200 to be 404`, `expected 200
to be 400`, and the parity case `expected { status: 200, … } to deeply
equal { status: 404, … }`.
- **Flow endpoint** (no keys): 1 failed (`a record key the caller cannot
read: 404 RECORD_NOT_FOUND, no run`): `expected 200 to be 404`.
- **Action door** (no caller-param keys): 3 failed (REST ×2, MCP):
`expected 200 to be 404`, and `promise resolved "{ ok: true, … }"
instead of rejecting`.
- **No object on an elevated flow** (always start): 2 failed: `expected
200 to be 400`.
- **Non-id key** (loaded instead of refused): 2 failed: `expected 404 to
be 400`.
- **The `{object}Id` slot** (dropped from the keys): 2 failed: `expected
200 to be 404`.
</details>
<details><summary>dispatch-gates</summary>
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` on `2020b6e5`: 93 commands. Each ran with
stdin closed after a full `turbo run build --filter='./packages/**'`
(Tasks 78 successful, 78 total; VERDICT command-exit 0). All 93 exited
0, `check:nul-bytes` included.
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--ran FILE`: "93 derived famil(ies) accounted for — 93 run, 0
NOT-MEASURED (a DERIVED zero — all 93 recorded an exit code and none of
them is 3)". Exit 0.
- On the first pass (head `5fda11a5`), `check:system-context-census` was
red: the new function reads `isSystem` and no row on
`content/docs/permissions/system-context.mdx` owned it. Row 57 now does
(commit `2020b6e5`). Two prerequisite exits on that pass were cleared
and pass on the final run: the teardown-shape self-test's pinned fixture
commit was fetched, and the CJS-load gate ran after the full build.
- Not measured locally, as `--commands` lists them: the families whose
argv takes a value only a CI run has (`check-issue-citations.mjs
--census`, the shard attestation emitters,
`check-test-completeness.mjs`), and the CI-scheduled jobs (Build Core,
Build Docs, the type-check lanes, and the `main`-only dogfood and
temporal batteries). Those are CI's readings.
</details>
## Human Minimum
Each item is one rule from
[CONTRIBUTING.md › Human
Minimum](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md#human-minimum),
which links the AGENTS.md text that states it.
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed. (Not touched.)
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English.
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADHkmT7iCiGQkgXGQAo4Fo)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent c2a5be0 commit c347d22
16 files changed
Lines changed: 755 additions & 20 deletions
File tree
- .changeset
- content/docs
- automation
- permissions
- packages/runtime/src
- domains
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2473 | 2473 | | |
2474 | 2474 | | |
2475 | 2475 | | |
2476 | | - | |
2477 | | - | |
2478 | | - | |
| 2476 | + | |
| 2477 | + | |
| 2478 | + | |
2479 | 2479 | | |
2480 | 2480 | | |
2481 | 2481 | | |
2482 | 2482 | | |
2483 | 2483 | | |
2484 | 2484 | | |
| 2485 | + | |
2485 | 2486 | | |
2486 | 2487 | | |
2487 | 2488 | | |
2488 | 2489 | | |
2489 | 2490 | | |
2490 | | - | |
2491 | | - | |
| 2491 | + | |
| 2492 | + | |
2492 | 2493 | | |
2493 | 2494 | | |
2494 | 2495 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
202 | 202 | | |
203 | 203 | | |
204 | 204 | | |
205 | | - | |
| 205 | + | |
206 | 206 | | |
207 | 207 | | |
208 | 208 | | |
| |||
625 | 625 | | |
626 | 626 | | |
627 | 627 | | |
| 628 | + | |
628 | 629 | | |
629 | 630 | | |
630 | 631 | | |
| |||
0 commit comments