Repository navigation
fix(runtime): the action door and the declared flow endpoint refuse a self-triggered system flow to a non-system caller - #22424
Conversation
…low endpoint against elevated flows The before-table, measured on a real kernel before either door asks anything of the caller. A signed-in member with no grant on the target object starts a runAs-system flow of each self-triggered type (autolaunched, record_change, schedule) through an action of type flow (REST and the MCP run_action tool) and through a declared endpoint of type flow (authRequired true): every row answers 200 and the elevated write lands, while the member's own create is refused 403. screen and api flows, a runAs-user control and a screen parent calling the elevated child as a subflow are measured alongside. The assertions are the readings; the fix commit flips the refused rows. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude <noreply@anthropic.com>
… entry The example application's authenticated flow endpoint starts an elevated janitor flow on demand. Under the type x caller rule that every door starting a flow by name now applies, an elevated flow of a self-triggered type is refused to any caller but the system principal, so the endpoint's target is declared type screen: the explicit, reviewable statement that signed-in callers may start this elevated work. Its nodes, run-as declaration and the endpoint's declaration are unchanged. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude <noreply@anthropic.com>
… self-triggered system flow to a non-system caller The trigger door's type x caller rule now binds every door that starts a flow by name. An action of type flow (REST /actions and the MCP run_action bridge, which share one dispatch) and a declared endpoint of type flow refuse a caller that is not the system principal a flow declared runAs system whose type is autolaunched, record_change or schedule: 403 PERMISSION_DENIED, nothing dispatched. screen and api flows, flows not declared runAs system, a parent's subflow node and the system principal are unchanged. The predicate, the type set and the refusal move out of the trigger domain into one module all three doors call, so they cannot drift. The refusal's words lose the door's name so they read true at every door. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude <noreply@anthropic.com>
… by name; changeset The elevated self-triggered start's isSystem read moves with the shared predicate, so its row anchors the new module and states the bypass at all three doors. Counts regenerated by gen:system-context-census. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c91fc2d97280c635481ecde55c99c4dee55db825 && git checkout c91fc2d97280c635481ecde55c99c4dee55db825
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 27a8b33dec2eb98b73b3e5146e740067cdbd7806 d7b2170de0bff5326cc7142799dc80f1cdefcf0d && git checkout -B drift-repro 27a8b33dec2eb98b73b3e5146e740067cdbd7806 && git merge --no-ff d7b2170de0bff5326cc7142799dc80f1cdefcf0d
node scripts/docs-audit/affected-docs.mjs --json 27a8b33dec2eb98b73b3e5146e740067cdbd7806
|
…door answers an elevated self-triggered start The status table on the flows page gains the 403 PERMISSION_DENIED row for a non-system caller starting a runAs-system flow of a self-triggered type, the same at the trigger route, a flow action and a declared flow endpoint. Its lead sentence now says that row is the door's own admission check, not the engine's verdict, and the never-dispatched count moves from four to five. The actions page's flow row and the requiredPermissions bullet, and the declarative endpoints page's flow row, state the same refusal. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude <noreply@anthropic.com>
Fixes #22407
Clause-②: no
The maintainer's ruling on the card (letter A, ruling-ref
6074046403): the type × caller rule ruling B landed at the trigger door (PR #22404) binds every door that starts a flow by name. A caller that is not the system principal may not start a flow declaredrunAs: 'system'whosetypeisautolaunched,record_changeorschedule, through an action oftype: 'flow'(RESTPOST /api/v1/actions/:object/:actionand the MCPrun_actionbridge that reaches it) or a declared endpoint oftype: 'flow'.screenandapiflows stay the author's designed entries at every door. No spec key moves and no new error code is minted.What changed
packages/runtime/src/flow-start-admission.ts(new): the ONE home of the rule. It holds the self-triggered type set (compile-bound to the spec'sFlow.typeenum), the refusal (403PERMISSION_DENIED, ADR-0112) andrefusesElevatedSelfTriggeredStart(automation, flowName, executionContext). The predicate, the type set and the refusal are moved here out ofdomains/automation.ts, where PR fix(runtime): the trigger door refuses a self-triggered system flow to a non-system caller #22404 landed them module-private. No second copy exists.domains/automation.ts) calls the moved predicate. Its behaviour is unchanged except for the refusal's words: the message no longer names the trigger door, so the one message reads true at all three doors. It still names nothing of the flow.action-execution.ts,dispatchFlowAction): the check sits after the existence probe (an unknown target keeps its404) and beforeexecute. REST/actionsand MCPrun_actionboth reach this one line. The refusal is thrown withcode+status, so the envelope survives both entrances.endpoint-executor.ts,executeFlow): the same check at the same position, answered throughapiErrorResponse. The policy chain upstream is untouched.content/docs/permissions/system-context.mdx: row 56 now anchors the moved predicate in its new module and states the bypass at all three doors. Counts regenerated bypnpm gen:system-context-census(files 55 to 56, declarations 27 to 28; read sites stay 119). No other row is touched.type: 'screen', the explicit statement that it is an entry. Nodes,runAs, and the endpoint's declaration are unchanged, so its intended caller (an authenticated caller of that endpoint) gets the same run. Why this route and not a subflow parent: anapi-typed flow arms a signed inbound hook (ADR-0041, it registers only with a per-flow secret), which is a different door; ascreenparent with asubflownode would add a second flow, a second label and a new endpoint target for no behaviour the one-line declaration does not already give..changeset/22407-flow-door-elevated-start.md(@objectstack/runtimepatch), with the migration line for an author whose action or endpoint now answers403.6075084564).content/docs/automation/flows.mdx: the flow-dispatch status table gains the403 PERMISSION_DENIEDrow; its lead sentence now says that row is the door's own admission check, made before dispatch, not the engine's verdict; the never-dispatched count moves from four to five.content/docs/ui/actions.mdx: theflowrow of the action-type table states the refusal, and therequiredPermissionsbullet notes that leaving it unset does not open such a target.content/docs/api/declarative-endpoints.mdx: theflowrow's status list gains the403.The measured before-table
Measured first, on this branch at
c116ccb58(baseabd254508, before PR #22404 was on main), through the real kernel: a signed-in member with no grant on the target object (the member's own create there answers403 PERMISSION_DENIED). Each writer flow creates one ledger row named after itself; "row" is the elevated write, "run" is the run log.run_action, declared endpointautolaunched/record_change/schedule,runAs: 'system'200, row written, run recorded403 PERMISSION_DENIED, no row, no runautolaunched,runAs: 'system'200, row written403, no row, no run200, row written200, row writtenscreen/api,runAs: 'system'200, elevated rowautolaunched,runAs: 'user'400 FLOW_FAILED, no row, run recordedscreenparent (runAs: 'user') whosesubflownode calls the elevatedautolaunchedchild200, child's row writtenauthRequired: true401, no runThe pins carry the table as assertions, each row with its
beforereading:packages/verify/src/action-flow-elevated-door.test.ts(the action door with the system-principal rows) andpackages/qa/dogfood/test/flow-door-elevated-start.dogfood.test.ts(all three doors).Pins
packages/runtime/src/action-flow-elevated-door.test.ts: both entrances (REST andrun_action) for every refused type (code+status+ never dispatched); the refusal names nothing of the flow and equals the trigger door's refusal for the same declaration; the system principal,runAs: 'user'/ norunAs,screen/api, unknown target (404) and a service withoutgetFloware unchanged. The action'srequiredPermissionsgate (ADR-0066 D4): a member lacking it is refused by the gate first, and the flow is never read; a member holding it is still refused anautolaunchedsystem target and starts ascreenone.packages/runtime/src/endpoint-flow-elevated-door.test.ts: the same matrix at the declared endpoint, including the guest principal anauthRequired: falseendpoint admits, with the declared envelope checked.packages/verify/src/action-flow-elevated-door.test.tsandpackages/qa/dogfood/test/flow-door-elevated-start.dogfood.test.ts(above), plus a dogfood case that all three doors answer the trigger door's refusal word for word.The pin sweep
The ruling flips one meaning at two doors: a non-system caller starting an elevated self-triggered flow through a
type: 'flow'action or endpoint now answers403, not200. A repo-wide search over every test that drives either door (/actions/,run_action,runAction,invokeBusinessAction,dispatchFlowAction,handleActions,executeEndpointTarget,actions.run(, and declared paths under/apps/): 148 files acrosspackages/,examples/and the dogfood suite. Of those, 5 name arunAs: 'system'declaration at all: this PR's two new wire files, a CLI boot-warning formatter test and a spec pin test (neither drives a door), and one runtime action test whose scriptedgetFlowserves{ name }only (norunAs, so the rule never applies). The flow actions the example applications declare target twoscreen/runAs: 'user'flows, unchanged. The one in-repo producer is the example endpoint fixed above; its dogfood proof (an authenticated caller'sPOSTanswers200and runs the flow) stays green with the same subject. The refusal's code and its old words (through the trigger door) are pinned nowhere else in the repo.So no existing pin asserted the pre-ruling meaning at these two doors; the flipped pins are this branch's own before-table, each now asserting
403+PERMISSION_DENIED+ no row + no run.Ablation of the two new call sites (the shared predicate handed a system context at the action door and at the endpoint, markers present in
packages/runtime/dist/index.jsandindex.cjsbefore reading any result): runtime 18 failed / 62 passed, exactly the refusal pins of the two new files, while PR #22404's trigger-door file (the control: that door was not ablated) and every unchanged-path pin stayed green; verify 7 failed / 9 passed (the six member and admin rows and the wire envelope); dogfood 13 failed / 14 passed (the twelve member and admin rows across the three doors, and the word-for-word parity case). Restore: both blobs equalHEAD,git status --porcelainempty, runtime rebuilt,ablation-dist-preflight --absentreports both markers gone, and the re-run is 80 / 16 / 27 passed.Tests and gates (head
28fe10794)pnpm --filter @objectstack/runtime exec vitest run --project local: 344 files, 4868 passed, 19 skipped.pnpm --filter @objectstack/verify exec vitest run: 21 files, 170 passed.authz-conformance,flow-runas,schedule-acting-organization, the two declared-endpoint suites and the MCP identity suite): 225 passed.objectstack validate: exit 0, no finding on the changed flow.typecheckexit 0 for@objectstack/runtime(with its test layer),@objectstack/verify,@objectstack/dogfoodand the example application;--listFilesshows each new test file inside its program.pnpm lint(the full run): exit 0.node scripts/check-system-context-census.mjs: OK, 119 read sites across 56 files, 115 of 115 required symbols cited.check:skill-examplesandcheck:dual-build-cjs-loadsfirst answeredPREREQUISITE NOT METand went green after a full build.dispatch-gates --ran: 97 derived, 97 run, 0 NOT MEASURED.packages/cliis not touched by this diff.d7b2170de(docs only): the 40 gatesdispatch-gates --commandsderives for the three pages, then the remaining 57 of the no-path union, all exit 0.dispatch-gates --ran: 97 derived, 97 run, 0 NOT MEASURED. Production, tests and the changeset are unchanged since28fe10794.Acceptance notes
403, carried by this PR in patch round 1 (d7b2170de): the status table incontent/docs/automation/flows.mdx, theflowrow and therequiredPermissionsbullet ofcontent/docs/ui/actions.mdx, and theflowrow ofcontent/docs/api/declarative-endpoints.mdx. Three other pages still list the flow-dispatch codes without the403: thetype: 'flow'row ofcontent/docs/protocol/kernel/http-protocol.mdx, the trigger row ofcontent/docs/api/plugin-endpoints.mdx, and the error-handling sample incontent/docs/api/client-sdk.mdx. They are outside this PR and noted for their owner.run_actionbridge throws the action'srequiredPermissionsrefusal as a bare error, with nocodeorstatus, while REST answers it403. The new refusal carries both at both entrances. The bare throw is unchanged, and only the runtime door test measured it.run_actioncannot be reached on@objectstack/verify's lean boot (it composes noMetadataPlugin, which ownsmatchEndpoint, and does not depend on@objectstack/mcp), so their wire pins live in the dogfood package, which depends on both. The action door's wire pin, with the system-principal rows, is in@objectstack/verify.Generated by Claude Code