Repository navigation
Commit 628df0c
fix(runtime): the action door and the declared flow endpoint refuse a self-triggered system flow to a non-system caller (#22424)
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 #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 #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](https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent db805c3 commit 628df0c
15 files changed
Lines changed: 1439 additions & 105 deletions
File tree
- .changeset
- content/docs
- api
- automation
- permissions
- ui
- examples/app-showcase/src
- automation/flows
- system/apis
- packages
- qa/dogfood/test
- runtime/src
- domains
- verify/src
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
101 | 101 | | |
102 | 102 | | |
103 | 103 | | |
104 | | - | |
| 104 | + | |
105 | 105 | | |
106 | 106 | | |
107 | 107 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2057 | 2057 | | |
2058 | 2058 | | |
2059 | 2059 | | |
2060 | | - | |
2061 | | - | |
| 2060 | + | |
| 2061 | + | |
| 2062 | + | |
2062 | 2063 | | |
2063 | 2064 | | |
2064 | 2065 | | |
2065 | 2066 | | |
| 2067 | + | |
2066 | 2068 | | |
2067 | 2069 | | |
2068 | 2070 | | |
2069 | 2071 | | |
2070 | 2072 | | |
2071 | | - | |
| 2073 | + | |
2072 | 2074 | | |
2073 | 2075 | | |
2074 | 2076 | | |
| |||
2079 | 2081 | | |
2080 | 2082 | | |
2081 | 2083 | | |
2082 | | - | |
2083 | | - | |
| 2084 | + | |
| 2085 | + | |
2084 | 2086 | | |
2085 | 2087 | | |
2086 | 2088 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
180 | 180 | | |
181 | 181 | | |
182 | 182 | | |
183 | | - | |
| 183 | + | |
184 | 184 | | |
185 | 185 | | |
186 | 186 | | |
| |||
356 | 356 | | |
357 | 357 | | |
358 | 358 | | |
359 | | - | |
| 359 | + | |
360 | 360 | | |
361 | 361 | | |
362 | 362 | | |
| |||
366 | 366 | | |
367 | 367 | | |
368 | 368 | | |
369 | | - | |
| 369 | + | |
370 | 370 | | |
371 | 371 | | |
372 | 372 | | |
| |||
431 | 431 | | |
432 | 432 | | |
433 | 433 | | |
434 | | - | |
| 434 | + | |
435 | 435 | | |
436 | 436 | | |
437 | 437 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
407 | 407 | | |
408 | 408 | | |
409 | 409 | | |
410 | | - | |
411 | | - | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
412 | 414 | | |
413 | 415 | | |
414 | 416 | | |
| |||
544 | 546 | | |
545 | 547 | | |
546 | 548 | | |
547 | | - | |
| 549 | + | |
548 | 550 | | |
549 | 551 | | |
550 | 552 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1634 | 1634 | | |
1635 | 1635 | | |
1636 | 1636 | | |
1637 | | - | |
1638 | | - | |
1639 | | - | |
| 1637 | + | |
| 1638 | + | |
| 1639 | + | |
| 1640 | + | |
| 1641 | + | |
| 1642 | + | |
| 1643 | + | |
| 1644 | + | |
| 1645 | + | |
| 1646 | + | |
| 1647 | + | |
1640 | 1648 | | |
1641 | 1649 | | |
1642 | 1650 | | |
1643 | 1651 | | |
1644 | 1652 | | |
1645 | | - | |
| 1653 | + | |
1646 | 1654 | | |
1647 | 1655 | | |
1648 | 1656 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
95 | 95 | | |
96 | 96 | | |
97 | 97 | | |
98 | | - | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
99 | 106 | | |
100 | 107 | | |
101 | 108 | | |
| |||
0 commit comments