Skip to content

fix(runtime): the trigger door refuses a self-triggered system flow to a non-system caller - #22404

Queued
objectstack-fleet[bot] wants to merge 6 commits into
mainfrom
claude/issue-22310-elevated-flow-trigger-door
Queued

objectstack-fleet[bot] wants to merge 6 commits into
mainfrom
claude/issue-22310-elevated-flow-trigger-door

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22310

Clause-②: no

A self-triggered flow declared to run as system could be started by any signed-in member through the trigger door. The door asked only whether the caller was anonymous, so a flow meant to run on its own trigger, or as a sub-flow, ran elevated for anyone who named it.

This implements the maintainer's ruling on the card (letter B, record 6070744023, with triage's settled reading of the carve-out, 6071477320). A caller that is not the system principal may not start a flow declared runAs: 'system' whose type is autolaunched, record_change or schedule. This applies at POST /api/v1/automation/:name/trigger, at the legacy POST /api/v1/automation/trigger/:name, and at @objectstack/verify's flows.run, all of which answer through one function.

Draft until the seat lands it. The two required checks the first round left red are addressed on this head (patch round 1, with the claim's file surface extended by the seat): the census row and the ruling's pin sweep. The review of record is the seat's ACCEPT on #22310. No contract-tier review is owed: this PR declares Clause-②: no and touches neither packages/spec nor governed text (the pm-dispatch contract-review reference).

Measure first: the before-table

All rows were measured through flows.run on the real kernel: auth, security middleware and the real automation engine. The "before" column was taken at cd46ffa4, this branch's first commit, before any fix. That commit carries the table as its assertion. The fixture is neutral. It has one object a fresh member is not granted (the member's direct create is refused with 403 PERMISSION_DENIED, which is the control) and one writer flow per type that creates a row in that object. In the table, "row" means the elevated write landed and "run" means a run-log entry was recorded.

caller flow declares type before (cd46ffa4) after (this head)
member runAs: 'system' autolaunched 200, row, run 403 PERMISSION_DENIED, no row, no run
member runAs: 'system' record_change 200, row, run 403, no row, no run
member runAs: 'system' schedule 200, row, run 403, no row, no run
member runAs: 'system' screen 200, row, run unchanged
member runAs: 'system' api 200, row, run unchanged
member runAs: 'user' autolaunched 400 FLOW_FAILED, no row, run unchanged
member a runAs: 'user' parent whose subflow node calls the runAs: 'system' autolaunched writer autolaunched 200, child's row, run unchanged
platform admin runAs: 'system' autolaunched / record_change / schedule 200, row, run 403, no row, no run
system principal runAs: 'system' autolaunched / record_change / schedule 200, row, run unchanged

The card measured two autolaunched paths. This table also measures record_change, schedule, screen and api: before the fix, a member could start every one of them, and the elevated write landed each time.

What changed

  • packages/runtime/src/domains/automation.ts: respondToFlowTrigger now asks refusesElevatedSelfTriggeredStart after the existence check and before execute. An unknown name keeps its 404. A refused start dispatches nothing. The predicate refuses when all three hold: the caller is not the system principal (executionContext.isSystem, the field the domain's anonymous floor already reads, which is never set on inbound HTTP), the flow declares runAs: 'system', and its type is one of the three self-triggered types. The type set is bound to the spec's Flow.type enum at compile time.
  • The refusal is 403 PERMISSION_DENIED, in the ADR-0112 envelope. That is the code and status this domain's other permission refusals already use, so no code is minted. The message is the same for every refused flow. It names what admits such a flow and nothing about this one: not its name, type, run-as declaration or definition.
  • The declaration is read through the automation service's own getFlow, the probe flowIsUnknown already uses, which serves the same definition execute runs. There is no second loader and no copy of the engine's run-as policy in the transport (mechanism hypothesis H5 holds). getFlow is optional on IAutomationService. A service that omits it cannot be asked, so the door dispatches as before, exactly as the existence check does (see Acceptance notes).
  • No spec key moves, and nothing changes in service-automation.
  • content/docs/permissions/system-context.mdx: the automation-domain row anchors #refusesElevatedSelfTriggeredStart and names the new bypass (the in-process system principal starting an elevated self-triggered flow at the trigger door). The declared counts are regenerated by pnpm gen:system-context-census (118 to 119 read sites). No other row is touched.
  • The ruling's pin sweep, repo-wide (see The pin sweep below): the only pins that asserted a non-system caller starting an elevated self-triggered flow at the trigger door are 3 dogfood tests in 2 files, and each now asserts the ruled door.

Mechanism hypotheses, each checked by reading the code and measuring:

  • H1 holds: existence check, then execute.
  • H2 holds: verify's flows.run dispatches POST /automation/:name/trigger through the runtime's HttpDispatcher, which reaches respondToFlowTrigger.
  • H3 holds: subflow-node.ts and map-node.ts start the child through engine.execute. The subflow row above is the measured positive control.
  • H4 holds: buildAutomationContext does not forward isSystem. Elevation comes from flow.runAs inside the engine. The door reads isSystem only to decide admission, and the system rows above are the control.

Pins

  • packages/runtime/src/domains/automation-trigger-elevated-door.test.ts (door side, scripted service, both route spellings): one refusal per refused type, asserting code, status and that execute was never called; one disclosure pin (an identical message for all three types that carries no name, type or run-as); and positive controls for the system principal per refused type, for a non-elevated flow per type, for elevated screen and api flows, for an unknown name still answering 404, and for a service without getFlow.
  • packages/verify/src/automation-trigger-elevated-door.test.ts (the wire, real kernel, through flows.run): the table above, one it per row, asserting status, code, rows written and run-log entries. It also has a direct-create control and one envelope pin.
  • packages/qa/dogfood/test/flow-runas.dogfood.test.ts with fixtures/flow-runas-fixture.ts, and packages/qa/dogfood/test/schedule-acting-organization.dogfood.test.ts: the flipped pins (see The pin sweep).

Ablation (a mutation of the call site to if ((globalThis as any).__ABLATED_22310__ && await refusesElevatedSelfTriggeredStart(…)), landed through scripts/ablation-replace.mjs, which reported anchor 1 to 0 and blob 17cef819 to 3edb21bc):

  • The runtime build carried the marker (ablation-dist-preflight: present in dist/index.js and dist/index.cjs).
  • verify: 7 failed and 9 passed. Exactly the six refused rows and the envelope pin went red. Every unchanged-path row stayed green.
  • runtime: 8 failed and 20 passed. The six refusal pins and the two disclosure pins went red. The disclosure pin first passed under ablation over an empty message and was tightened in 12f88829.
  • Restore leg: the blob equals HEAD, git diff HEAD is empty, a rebuild was done, ablation-dist-preflight --absent reports the marker absent from all 6 built files, and the tree is clean.

Tests and gates (head 799c28789)

  • pnpm --filter @objectstack/runtime test at 0e92dbe4 (runtime source unchanged since): 342 files, 4815 passed, 19 skipped. At 799c28789, the 42 runtime test files that drive the trigger door: 1026 passed.
  • pnpm --filter @objectstack/runtime typecheck and pnpm --filter @objectstack/verify typecheck: exit 0. pnpm --filter @objectstack/dogfood typecheck: exit 0, with the 3 edited dogfood files in the program.
  • pnpm --filter @objectstack/verify test at 0e92dbe4: 20 files, 154 passed. At 799c28789, the 7 verify files that drive the door: 57 passed.
  • Dogfood at 799c28789: every dogfood file that drives the trigger door (9) plus authz-conformance.test.ts: 10 files, 168 passed.
  • pnpm lint (the full run): exit 0 at 799c28789.
  • node scripts/check-system-context-census.mjs: OK, 119 read sites, 115 of 115 required symbols cited.
  • The dispatch-derived gate list, re-derived with no paths at 799c28789 (94 families): all 94 exit 0. pnpm check:dual-build-cjs-loads and check:skill-examples first answered PREREQUISITE NOT MET and went green after a full build. dispatch-gates --ran: 94 derived, 94 run, 0 NOT MEASURED.

The pin sweep

A repo-wide search over every test that reaches the trigger door (/trigger in either spelling, flows.run, handleAutomation, automation.trigger: 74 files across packages/runtime, packages/verify, packages/qa/dogfood, packages/client, packages/services/service-automation, packages/spec, packages/lint and packages/objectql, with no hits in examples/ or apps/) found exactly 3 pins whose meaning the ruling reverses: a non-system caller starting a runAs: 'system' flow of a self-triggered type through the door. The refusal's code and message appear nowhere else. Each flipped pin asserts the new meaning's substance, and each test keeps its subject.

  • flow-runas.dogfood.test.ts (the authz-matrix proof flow-run-as): the two elevation legs now reach each system flow through a runAs: 'user' parent whose subflow node calls it (runas_system_touch_via_parent, runas_system_read_via_parent, added to the fixture). The write leg still stamps the admin's note touched-system. The read leg now also asserts that the row read is the note it asked for. A new case pins the door: the member's direct start of either system flow answers 403 PERMISSION_DENIED, with no inner data, the note left at new and no run recorded.
  • schedule-acting-organization.dogfood.test.ts, control B on sqlite-wasm (subject: which organization a door-launched run carries, the session's and never the declaration's): the session now drives the declaring flow's door twin. The twin has the same nodes and the same organization declaration, type: 'screen' and no cadence, and is derived from the same builder. The delivered row still carries the session's organization and never the declared one. Before that, the control pins that the declaring flow itself is refused to the session: 403 PERMISSION_DENIED, no run recorded, nothing delivered. The memory-driver branch is unchanged. The system principal could not be used here: it is reachable only in-process through @objectstack/runtime's HttpDispatcher, which @objectstack/dogfood does not depend on, so the session-and-twin shape keeps the subject without a new dependency.

Ablation of the door check against the flipped pins (the same call-site mutation, marker present in dist/index.js and dist/index.cjs): exactly the 2 refusal pins went red (the direct-start case in flow-runas, and control B on sqlite-wasm). The 22 other tests in the two files stayed green, including the parent-route elevation legs and control B's twin delivery. Restore: the blob equals HEAD, --absent reports the marker gone from all 6 built files, and the tree is clean. All 27 tests across the three files pass after the restore.

Acceptance notes

  • Admins are refused too. A platform admin's session is a signed-in user, not the system principal, and the ruling refuses every caller but the system principal. The console's flow-action buttons start flows through this same trigger door. An action button whose target is an elevated self-triggered flow therefore now answers 403 for everyone. Measured in-repo corpus at 117d34de: no UI type: 'flow' action in examples/ targets one (the two action targets found are screen flows declared runAs: 'user').
  • Keyed on the declared type, as ruled, not on the resolved trigger kind. An autolaunched flow whose start node binds another trigger (a record event, a cadence, an inbound hook) is refused at this door like any other autolaunched one. A screen or api flow is admitted whatever its start node binds.
  • Optional getFlow. The door can only read a declaration the automation service will serve. The platform engine always implements getFlow. An alternative IAutomationService that omits it gets today's behaviour, the same "no evidence, dispatch as before" reading the existence check takes.
  • Two other doors start a flow by name and were measured, not changed (mechanism hypothesis H6, outside this ruling): an action of type: 'flow' served at POST /api/v1/actions/:object/:action, and a declared endpoint of type: 'flow' with authRequired: true. On this branch, a member with no grant on the target object still starts an elevated self-triggered flow through both: the action door with an autolaunched and a schedule target, and the endpoint door with an autolaunched target. Each answered 200 and the elevated write landed. An anonymous request at the endpoint answers 401. The seat filed this as [finding] runtime(security): two other doors that start a flow by name still let any signed-in member start a self-triggered system flow, after #22310 closes the trigger door #22407, and it is not decided here.
  • Docs (seat, at ACCEPT): content/docs/automation/flows.mdx's status table lists the engine's dispatch outcomes, and the door's caller refusals (the anonymous floor, and now this one) sit outside it, so no published sentence becomes false. A 403 row fits best once [finding] runtime(security): two other doors that start a flow by name still let any signed-in member start a self-triggered system flow, after #22310 closes the trigger door #22407 settles the other doors. Carrier: [finding] runtime(security): two other doors that start a flow by name still let any signed-in member start a self-triggered system flow, after #22310 closes the trigger door #22407's PR.
  • authz-conformance.matrix.ts (row anonymous-deny-automation): its enforcement prose says the execution doors sit outside every per-route capability predicate. Since this PR, the trigger door carries its own caller × flow check. That is prose only, and authz-conformance.test.ts passes. Carrier: the next PR to edit that matrix.

Generated by Claude Code

claude added 4 commits October 9, 2026 01:39
…r type and caller

Measure-first stage, committed before any fix. Through `flows.run` (the
in-process twin of POST /api/v1/automation/:name/trigger) on the real kernel,
a signed-in member with no grant on the target object starts a flow declared
runAs: 'system' of every type (autolaunched, record_change, schedule, screen,
api), and the elevated write lands each time. The table also records the
non-elevated control, a parent flow's subflow call, the platform admin and
the system principal. This is the before-table the door check is judged
against.

Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS
Co-authored-by: Claude <noreply@anthropic.com>
…o a non-system caller

A self-triggered flow declared to run as system could be started by any
signed-in member through the trigger door, which asked only whether the
caller was anonymous. `respondToFlowTrigger` now refuses, after the existence
check and before dispatch, a caller that is not the system principal starting
a flow declared runAs: 'system' whose type is autolaunched, record_change or
schedule: 403 PERMISSION_DENIED, nothing dispatched, nothing of the flow
disclosed. screen and api flows, non-elevated flows, a parent flow's subflow
call and the system principal are unchanged.

The door reads the declaration through the automation service's own getFlow
probe (the one the existence check uses), so there is no second loader and
no copy of the engine's run-as policy.

Pins: door-side per arm with a scripted service (runtime), and the wire half
through flows.run on the real kernel (verify), whose table was measured
before the check existed.

Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS
Co-authored-by: Claude <noreply@anthropic.com>
…nces

Under ablation of the door check it passed over an empty message.

Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/l label Oct 9, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 9, 2026
@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 10 documentable anchor(s).

33 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json fdfdd7e76da8fc206849a5aae7018633f5893b9e.

⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json fdfdd7e76da8fc206849a5aae7018633f5893b9e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from a637280724ccd1114b6dfff04a3cdc66a85700ac — the merge of head 799c28789fff6644d8f57f9dce3cb7e63bd56f9e into base fdfdd7e76da8fc206849a5aae7018633f5893b9e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a637280724ccd1114b6dfff04a3cdc66a85700ac && git checkout a637280724ccd1114b6dfff04a3cdc66a85700ac
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fdfdd7e76da8fc206849a5aae7018633f5893b9e 799c28789fff6644d8f57f9dce3cb7e63bd56f9e && git checkout -B drift-repro fdfdd7e76da8fc206849a5aae7018633f5893b9e && git merge --no-ff 799c28789fff6644d8f57f9dce3cb7e63bd56f9e

node scripts/docs-audit/affected-docs.mjs --json fdfdd7e76da8fc206849a5aae7018633f5893b9e

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs fdfdd7e76da8fc206849a5aae7018633f5893b9e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

claude added 2 commits October 9, 2026 03:17
…vated self-triggered start

The trigger door's new caller check reads ExecutionContext.isSystem, so the
automation-domain row gains its anchor and sentence, and the declared counts
are regenerated (118 to 119 read sites).

Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS
Co-authored-by: Claude <noreply@anthropic.com>
…ow at the trigger door assert the ruled door

flow-runas: the elevation legs reach each system flow through a runAs-user
parent's subflow node, and a new case pins the member's direct start of
either system flow: 403 PERMISSION_DENIED, the note untouched, no run.

schedule-acting-organization control B: the session drives the declaring
flow's door twin (same nodes and declaration, type screen, no cadence), and
the declaring flow itself is pinned refused to the session: 403
PERMISSION_DENIED, nothing delivered, no run.

Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS
Co-authored-by: Claude <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants