Skip to content

fix(runtime): the action door and the declared flow endpoint refuse a self-triggered system flow to a non-system caller - #22424

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22407-flow-door-parity
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22407-flow-door-parity

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

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 declared runAs: 'system' whose type is autolaunched, record_change or schedule, through an action of type: 'flow' (REST POST /api/v1/actions/:object/:action and the MCP run_action bridge that reaches it) or a declared endpoint of type: 'flow'. screen and api flows 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's Flow.type enum), the refusal (403 PERMISSION_DENIED, ADR-0112) and refusesElevatedSelfTriggeredStart(automation, flowName, executionContext). The predicate, the type set and the refusal are moved here out of domains/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.
  • The trigger door (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.
  • The action door (action-execution.ts, dispatchFlowAction): the check sits after the existence probe (an unknown target keeps its 404) and before execute. REST /actions and MCP run_action both reach this one line. The refusal is thrown with code + status, so the envelope survives both entrances.
  • The declared endpoint (endpoint-executor.ts, executeFlow): the same check at the same position, answered through apiErrorResponse. 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 by pnpm gen:system-context-census (files 55 to 56, declarations 27 to 28; read sites stay 119). No other row is touched.
  • One in-repo example application's flow endpoint started an elevated self-triggered flow for any signed-in caller. Fixed by the ruling's route: its target flow is declared 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: an api-typed flow arms a signed inbound hook (ADR-0041, it registers only with a per-flow secret), which is a different door; a screen parent with a subflow node 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/runtime patch), with the migration line for an author whose action or endpoint now answers 403.
  • Docs, patch round 1 (the seat extended the claim's surface in 6075084564). content/docs/automation/flows.mdx: the flow-dispatch status table gains the 403 PERMISSION_DENIED row; 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: the flow row of the action-type table states the refusal, and the requiredPermissions bullet notes that leaving it unset does not open such a target. content/docs/api/declarative-endpoints.mdx: the flow row's status list gains the 403.

The measured before-table

Measured first, on this branch at c116ccb58 (base abd254508, 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 answers 403 PERMISSION_DENIED). Each writer flow creates one ledger row named after itself; "row" is the elevated write, "run" is the run log.

door caller target before after
action (REST), MCP run_action, declared endpoint member autolaunched / record_change / schedule, runAs: 'system' 200, row written, run recorded 403 PERMISSION_DENIED, no row, no run
the same three platform admin's session autolaunched, runAs: 'system' 200, row written 403, no row, no run
action (REST, in-process) system principal each of the three types 200, row written 200, row written
the same three member screen / api, runAs: 'system' 200, elevated row unchanged
the same three member autolaunched, runAs: 'user' 400 FLOW_FAILED, no row, run recorded unchanged
the same three member a screen parent (runAs: 'user') whose subflow node calls the elevated autolaunched child 200, child's row written unchanged
declared endpoint anonymous authRequired: true 401, no run unchanged

The pins carry the table as assertions, each row with its before reading: packages/verify/src/action-flow-elevated-door.test.ts (the action door with the system-principal rows) and packages/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 and run_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' / no runAs, screen / api, unknown target (404) and a service without getFlow are unchanged. The action's requiredPermissions gate (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 an autolaunched system target and starts a screen one.
  • packages/runtime/src/endpoint-flow-elevated-door.test.ts: the same matrix at the declared endpoint, including the guest principal an authRequired: false endpoint admits, with the declared envelope checked.
  • Wire: packages/verify/src/action-flow-elevated-door.test.ts and packages/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 answers 403, not 200. 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 across packages/, examples/ and the dogfood suite. Of those, 5 name a runAs: '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 scripted getFlow serves { name } only (no runAs, so the rule never applies). The flow actions the example applications declare target two screen / runAs: 'user' flows, unchanged. The one in-repo producer is the example endpoint fixed above; its dogfood proof (an authenticated caller's POST answers 200 and 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.js and index.cjs before 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 equal HEAD, git status --porcelain empty, runtime rebuilt, ablation-dist-preflight --absent reports 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.
  • Dogfood, every door-driving file the sweep above hit plus this PR's own (13 files, among them authz-conformance, flow-runas, schedule-acting-organization, the two declared-endpoint suites and the MCP identity suite): 225 passed.
  • The example application's own suite: 33 files, 408 passed; its objectstack validate: exit 0, no finding on the changed flow.
  • typecheck exit 0 for @objectstack/runtime (with its test layer), @objectstack/verify, @objectstack/dogfood and the example application; --listFiles shows 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.
  • The dispatch-derived gate list, re-derived with no paths at this head (97 commands, identical to the dispatch lead): all 97 exit 0. check:skill-examples and check:dual-build-cjs-loads first answered PREREQUISITE NOT MET and went green after a full build. dispatch-gates --ran: 97 derived, 97 run, 0 NOT MEASURED.
  • The integration layer of packages/cli is not touched by this diff.
  • Patch round 1 at d7b2170de (docs only): the 40 gates dispatch-gates --commands derives 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 since 28fe10794.

Acceptance notes

  • The trigger door's refusal words changed (code and status did not): one message now serves three doors, so it no longer names the trigger door. PR fix(runtime): the trigger door refuses a self-triggered system flow to a non-system caller #22404 is not released yet, and no test pinned the old words.
  • The documentation tables list this 403, carried by this PR in patch round 1 (d7b2170de): the status table in content/docs/automation/flows.mdx, the flow row and the requiredPermissions bullet of content/docs/ui/actions.mdx, and the flow row of content/docs/api/declarative-endpoints.mdx. Three other pages still list the flow-dispatch codes without the 403: the type: 'flow' row of content/docs/protocol/kernel/http-protocol.mdx, the trigger row of content/docs/api/plugin-endpoints.mdx, and the error-handling sample in content/docs/api/client-sdk.mdx. They are outside this PR and noted for their owner.
  • The MCP run_action bridge throws the action's requiredPermissions refusal as a bare error, with no code or status, while REST answers it 403. The new refusal carries both at both entrances. The bare throw is unchanged, and only the runtime door test measured it.
  • The console's flow-action buttons start flows through the trigger door. Under this ruling every door answers alike, and routing the button through the action door is a UI card after landing, per the ruling.
  • Test placement: the declared endpoint and run_action cannot be reached on @objectstack/verify's lean boot (it composes no MetadataPlugin, which owns matchEndpoint, 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

claude added 5 commits October 9, 2026 04:46
…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>
@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 16 documentable anchor(s).

16 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 27a8b33dec2eb98b73b3e5146e740067cdbd7806.

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

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: PERMISSION_DENIED (literal, 31 pages)
  • 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 27a8b33dec2eb98b73b3e5146e740067cdbd7806 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c91fc2d97280c635481ecde55c99c4dee55db825 — the merge of head d7b2170de0bff5326cc7142799dc80f1cdefcf0d into base 27a8b33dec2eb98b73b3e5146e740067cdbd7806, 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 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

⚠️ 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 27a8b33dec2eb98b73b3e5146e740067cdbd7806 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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>
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 9, 2026 06:18
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 9, 2026 06:18
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit c572fe2 Oct 9, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22407-flow-door-parity branch October 9, 2026 07:11
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/xl tests tooling

Projects

None yet

2 participants