Skip to content

Commit 628df0c

Browse files
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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): the action door and the declared flow endpoint refuse a self-triggered flow declared to run as system to any caller but the system principal
6+
7+
Clause-②: no
8+
9+
The trigger door already refused this start, but two other doors that start a flow by name did not ask: an action of `type: 'flow'` (`POST /api/v1/actions/:object/:action`, and the MCP `run_action` tool, which reaches the same dispatch) and a declared endpoint of `type: 'flow'`. Any signed-in member who was refused at the trigger door could start the same elevated flow through either one, and its elevated write landed.
10+
11+
**What changes.** All three doors now apply one rule from one place. A caller that is not the system principal gets `403 PERMISSION_DENIED` (ADR-0112 envelope) when it starts a flow declared `runAs: 'system'` whose `type` is `autolaunched`, `record_change` or `schedule`. Nothing runs: no run record, no node, no side effect. This includes a platform admin's session and the guest principal an `authRequired: false` endpoint admits. The refusal names no part of the flow, and its message is now the same at every door: it no longer names the trigger door.
12+
13+
**What stays as it was.**
14+
15+
- `screen` and `api` flows, elevated or not: entries the author designed (ADR-0073 D2).
16+
- Every flow that does not declare `runAs: 'system'`.
17+
- A parent flow's `subflow` node calling an elevated flow: the child starts through the engine, never through a door.
18+
- The system principal (an in-process caller, such as a job), which starts every flow.
19+
- The action's own `requiredPermissions` gate (ADR-0066 D4) still answers first. Holding that capability does not open a self-triggered elevated target.
20+
- A declared endpoint's policy chain: an anonymous caller at an `authRequired: true` endpoint still gets `401` first.
21+
22+
**If an action or an endpoint now answers 403.** Its target flow runs on its own trigger. If members are meant to start that elevated work, declare the target `type: 'screen'` (or `type: 'api'` for a signed inbound hook), which states that it is an entry and keeps the elevation reviewable. Or keep it self-triggered and point the action or endpoint at such an entry whose `subflow` node calls it.

‎content/docs/api/declarative-endpoints.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ declaration to shadow a built-in one. Match → policy chain (`rateLimit` → `a
101101
| `type` | Delegates to | Request shape |
102102
|:---|:---|:---|
103103
| `object_operation` | the same `callData` binding that serves `/api/v1/data/{object}` | `find` reads its criteria from the query string; `get` / `update` / `delete` take the record id from `query.id`; `create` / `update` take the body. `create` answers `201`, the rest `200` |
104-
| `flow` | the same automation pipeline as `POST /api/v1/automation/{name}/trigger` | the request body is the flow input. A refused or failed run answers a real status: `404` unknown flow, `409` `FLOW_DISABLED`, `422` `FLOW_NO_START_NODE`, `422` `FLOW_INPUT_SCHEMA_INVALID`, `400` `FLOW_FAILED` |
104+
| `flow` | the same automation pipeline as `POST /api/v1/automation/{name}/trigger` | the request body is the flow input. A refused or failed run answers a real status: `404` unknown flow, `409` `FLOW_DISABLED`, `422` `FLOW_NO_START_NODE`, `422` `FLOW_INPUT_SCHEMA_INVALID`, `400` `FLOW_FAILED`, and `403` `PERMISSION_DENIED`, nothing run, when the target is declared `runAs: 'system'` with a self-triggered type (`autolaunched`, `record_change`, `schedule`), exactly as the trigger door answers |
105105

106106
The frozen vocabulary has **no path-template syntax**, so an endpoint cannot express
107107
`/leads/{id}` — a record id travels as `?id=…`. A method that no declaration claims

‎content/docs/automation/flows.mdx‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2057,18 +2057,20 @@ curl -b cookies.txt -X POST \
20572057
| `POST /api/v1/automation/:name/runs/:runId/restore-suspension` | Re-arm a run stranded by a failed resume — and, for a nested run, every ancestor whose pause that strand's cascade consumed, deepest first, from whichever member you name. Same gate as `cancel`; body `{ reason? }`. `501 NOT_IMPLEMENTED` when the service does not declare the verb |
20582058

20592059
**Failures answer real HTTP status codes.** A flow that does not run is not a
2060-
`200`. The route classifies five outcomes, and it reads the engine's own verdict
2061-
to do it — never the message text:
2060+
`200`. The route classifies six outcomes. Five of them it reads off the engine's
2061+
own verdict — never the message text; the `403` is the door's own admission
2062+
check, made before anything is dispatched:
20622063

20632064
| Status | `error.code` | What happened | What to do |
20642065
|:---|:---|:---|:---|
20652066
| `404` | — | No flow by that name in this deployment | Check the name; nothing was dispatched |
2067+
| `403` | `PERMISSION_DENIED` | The caller is not the system principal, and the flow is declared `runAs: 'system'` with a self-triggered `type` (`autolaunched`, `record_change`, `schedule`). The same answer at every door that starts a flow by name: this route, a `type: 'flow'` action (REST and MCP `run_action`) and a declared `type: 'flow'` endpoint | Let it run on its own trigger, or call it from a parent flow's `subflow` node. If members are meant to start it, declare it `type: 'screen'` (or `'api'` for a signed inbound hook) |
20662068
| `409` | `FLOW_DISABLED` | The flow exists but is switched off | Enable it — the identical request then succeeds |
20672069
| `422` | `FLOW_NO_START_NODE` | The stored definition has no `start` node | Fix the flow; retrying cannot help |
20682070
| `422` | `FLOW_INPUT_SCHEMA_INVALID` | A node's `config` violates the `inputSchema` its own definition declares | Fix the flow; the refusal is non-retryable — even under `errorHandling.strategy: 'retry'` the engine refuses once and never enters the retry loop |
20692071
| `400` | `FLOW_FAILED` | The flow **ran** and was rejected | Read `error.details.summary` for the failing node |
20702072

2071-
The first four never dispatched anything: no node executed, no record was
2073+
The first five never dispatched anything: no node executed, no record was
20722074
written, and there is no run to look up. Only `400` describes a run. The
20732075
`FLOW_INPUT_SCHEMA_INVALID` verdict is a pure function of the flow definition —
20742076
re-running the guard cannot change the answer — so a `retry` strategy does not
@@ -2079,8 +2081,8 @@ burn its budget on it: the run log holds exactly one refusal row.
20792081
`POST /api/v1/actions/:object/:action`, and calling a declared `type: 'flow'`
20802082
[endpoint](/docs/protocol/kernel/http-protocol) under `/api/v1/apps/`, both
20812083
dispatch the same flow through the same service call and classify the outcome
2082-
the same way — the table above has one definition that all three doors read, so
2083-
they cannot answer one engine outcome differently. Branch on the status and
2084+
the same way — each row of the table above has one definition that all three
2085+
doors read, so they cannot answer one request differently. Branch on the status and
20842086
`error.code` at any of them.
20852087

20862088
```

‎content/docs/permissions/system-context.mdx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ The largest single consumer — **17 of the 120 sites**.
180180
| 53 | Package REST route capability gate bypassed | rest | Get: a marketplace publish over REST (`POST /packages/publish`, the one route the REST registrar mounts since #14503) without `manage_metadata`; the package read cohort (`studio.access` / `setup.access`) is enforced by the dispatcher `/packages` domain's own read gate, where the reads are served | `packages/rest/src/package-routes.ts#refusePackageRequest` |
181181
| 54 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `packages/runtime/src/domains/packages.ts#requireManageMetadata`, `#requireReadCapability` |
182182
| 55 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite`, `#refuseUngrantedActivationAuthoring` |
183-
| 56 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes and the trigger door's elevated self-triggered start all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting, through the trigger door, a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`), which every other caller is refused `403 PERMISSION_DENIED` (the maintainer's ruling, letter B). That bypass is the in-process caller's, such as a job: inbound HTTP never carries the flag | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#isRunStarterOrRunStateReader`, `#refuseUngrantedRunLifecycleWrite`, `#refusesElevatedSelfTriggeredStart` |
183+
| 56 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes and the elevated self-triggered start at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A). The three doors ask one predicate, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#isRunStarterOrRunStateReader`, `#refuseUngrantedRunLifecycleWrite`, `packages/runtime/src/flow-start-admission.ts#refusesElevatedSelfTriggeredStart` |
184184
| 57 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertTenantAdmin` |
185185
| 58 | Email-template organization door passes; webhook provenance stamp skipped | plugin-email, plugin-webhooks | Get (`sys_email_template`): a create or update of a template row. The door refuses every other write that names a caller, `403 PERMISSION_DENIED` (ADR-0131 D6), so the seeds, the boot sweep and the projection of a Studio save are the only writers. Lose (`sys_webhook`): the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` |
186186
| 59 | **Automation flow data nodes re-add the `owner_id` stamp** (the one place row 2's gap is compensated inline) | service-automation | Get: a flow-authored INSERT under system elevation still lands owned, when the run resolved a user. Fill-only — flow-authored values win | `packages/services/service-automation/src/runtime-identity.ts#stampSystemInsertOwner`, called from `packages/services/service-automation/src/builtin/crud-nodes.ts#registerCrudNodes` |
@@ -356,7 +356,7 @@ still holds equal to the census on every pull request:
356356
| — in tests | 1013 | — |
357357
| — in non-test sources | 798 | — |
358358
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
359-
| — parsed as a declaration | 27 | ✅ |
359+
| — parsed as a declaration | 28 | ✅ |
360360
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
361361
| — parsed as a property **read** | 126 | ✅ |
362362
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
@@ -366,7 +366,7 @@ still holds equal to the census on every pull request:
366366
| — behaviour-bearing (rows 1–61 above) | 117 | ✅ |
367367
| — carry the flag onward only (rows 62–64 above) | 3 | ✅ |
368368
| Packages containing at least one elevation read | **20** | ✅ |
369-
| Files containing at least one elevation read | 55 | ✅ |
369+
| Files containing at least one elevation read | 56 | ✅ |
370370
| — the distinct symbols those reads live in — what this page anchors | 102 | ✅ |
371371
| — of those files, the ones holding more than one read in one symbol | 8 | ✅ |
372372

@@ -431,7 +431,7 @@ same resolver, and the same registration shape, that holds `docs/adr/**`.
431431
Renaming a symbol is now a loud red instead of a silent misdirection.
432432

433433
⚠️ **The precision that costs, priced here rather than buried.** A symbol anchor
434-
cannot say WHICH read inside a function it means, and **8** of the **55**
434+
cannot say WHICH read inside a function it means, and **8** of the **56**
435435
anchored files hold more than one read inside a single symbol. So the population
436436
check runs per file at symbol granularity: every file the census finds a read in
437437
must be anchored, and the set of symbols this page cites into that file must

‎content/docs/ui/actions.mdx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -407,8 +407,10 @@ is a spec proposal for a properly named key, not a values map under this one.
407407
- **`requiredPermissions: ['can_close_tickets']`** is a **dual-surface gate**
408408
(one declaration, two enforcement points): the server rejects unauthorized
409409
calls with 403, and the UI hides or disables the button for the same users.
410-
Unset means no gate beyond object CRUD permissions. Referenced capabilities
411-
must exist — `os lint` checks that.
410+
Unset means no gate beyond object CRUD permissions — except that a `flow`
411+
action whose target is an elevated self-triggered flow is refused to every
412+
caller but the system principal either way (the `flow` row below). Referenced
413+
capabilities must exist — `os lint` checks that.
412414
- **`visible`** is a CEL predicate evaluated **fail-closed**: an expression
413415
that throws hides the action silently. Two rules save real debugging time:
414416

@@ -544,7 +546,7 @@ The endpoint dispatches on the **declared `type`**, exactly like the MCP
544546
| `type` | Over REST |
545547
|:---|:---|
546548
| `script` | Runs the registered handler / inline body. |
547-
| `flow` | Runs `target` on the automation engine, with your identity forwarded (a `runAs: 'user'` flow enforces RLS as you). Dispatches the same flow as `POST /api/v1/automation/:target/trigger`, without having to know the flow name — **and answers the same way**: a run that ran and was rejected is **400** `FLOW_FAILED`, while a dispatch that never happened is separated out (**404** unknown flow / **409** `FLOW_DISABLED` / **422** `FLOW_NO_START_NODE` / **422** `FLOW_INPUT_SCHEMA_INVALID`). See [Run a flow via API](/docs/automation/flows#run-a-flow-via-api) for the full table — it is one table, read by both doors. |
549+
| `flow` | Runs `target` on the automation engine, with your identity forwarded (a `runAs: 'user'` flow enforces RLS as you). Dispatches the same flow as `POST /api/v1/automation/:target/trigger`, without having to know the flow name — **and answers the same way**: a run that ran and was rejected is **400** `FLOW_FAILED`, while a dispatch that never happened is separated out (**404** unknown flow / **409** `FLOW_DISABLED` / **422** `FLOW_NO_START_NODE` / **422** `FLOW_INPUT_SCHEMA_INVALID`). A target declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) is refused **403** `PERMISSION_DENIED` to every caller but the system principal, exactly as at the trigger door, and nothing runs; holding the action's `requiredPermissions` does not open it. See [Run a flow via API](/docs/automation/flows#run-a-flow-via-api) for the full table — it is one table, read by both doors. |
548550
| `api` | **400** — it dispatches on `target`; call that endpoint directly. |
549551
| `url` / `modal` / `form` | **400** — client-side navigation; there is nothing for the server to run. |
550552

‎examples/app-showcase/src/automation/flows/index.ts‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1634,15 +1634,23 @@ export const InboundTaskWebhookFlow = defineFlow({
16341634
* by the same filter, and report. Config keys follow the executor contract
16351635
* exactly — `objectName` + `filter` + the declared bulk intent `multi`
16361636
* (Prime Directive #12: no `object`/`filters` aliases). `runAs: 'system'`
1637-
* because a janitor acts
1638-
* across owners; autolaunched with no record trigger — invoke it on demand
1639-
* (API/subflow) rather than on every write.
1637+
* because a janitor acts across owners.
1638+
*
1639+
* `type: 'screen'` because it IS an entry: a signed-in caller starts it on
1640+
* demand through `InquiryPurgeEndpoint` (`src/system/apis/index.ts`), with no
1641+
* record trigger and no cadence. An elevated flow of a self-triggered type
1642+
* (`autolaunched`, `record_change`, `schedule`) runs on its own trigger or as a
1643+
* `subflow` from a parent, and every door that starts a flow by name — the
1644+
* trigger route, a `type: 'flow'` action, a declared `type: 'flow'` endpoint —
1645+
* refuses it to any caller but the system principal (`403 PERMISSION_DENIED`).
1646+
* Declaring the type an entry is the explicit, reviewable statement that
1647+
* signed-in callers may start this elevated work.
16401648
*/
16411649
export const InquiryPurgeFlow = defineFlow({
16421650
name: 'showcase_inquiry_purge',
16431651
label: 'Purge Closed Inquiries',
16441652
description: 'Deletes inquiries already marked closed — demonstrates get_record + delete_record.',
1645-
type: 'autolaunched',
1653+
type: 'screen',
16461654
runAs: 'system',
16471655
nodes: [
16481656
{ id: 'start', type: 'start', label: 'Start' },

‎examples/app-showcase/src/system/apis/index.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,14 @@ export const TaskFeedEndpoint: ApiEndpoint = {
9595
cacheTtlSeconds: 30,
9696
};
9797

98-
/** Flow-typed endpoint: POST triggers the janitor flow (get+delete demo). */
98+
/**
99+
* Flow-typed endpoint: POST triggers the janitor flow (get+delete demo).
100+
*
101+
* The target runs `runAs: 'system'`, so it is declared `type: 'screen'` — an
102+
* entry. A `type: 'flow'` endpoint refuses a signed-in caller an elevated flow
103+
* of a self-triggered type (`autolaunched`, `record_change`, `schedule`) with
104+
* `403 PERMISSION_DENIED`, exactly as the trigger route and a flow action do.
105+
*/
99106
export const InquiryPurgeEndpoint: ApiEndpoint = {
100107
name: 'showcase_inquiry_purge_api',
101108
path: '/api/v1/apps/showcase/inquiries/purge',

0 commit comments

Comments
 (0)