Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/22407-flow-door-elevated-start.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/runtime': patch
---

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

Clause-②: no

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.

**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.

**What stays as it was.**

- `screen` and `api` flows, elevated or not: entries the author designed (ADR-0073 D2).
- Every flow that does not declare `runAs: 'system'`.
- A parent flow's `subflow` node calling an elevated flow: the child starts through the engine, never through a door.
- The system principal (an in-process caller, such as a job), which starts every flow.
- The action's own `requiredPermissions` gate (ADR-0066 D4) still answers first. Holding that capability does not open a self-triggered elevated target.
- A declared endpoint's policy chain: an anonymous caller at an `authRequired: true` endpoint still gets `401` first.

**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.
2 changes: 1 addition & 1 deletion content/docs/api/declarative-endpoints.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ declaration to shadow a built-in one. Match → policy chain (`rateLimit` → `a
| `type` | Delegates to | Request shape |
|:---|:---|:---|
| `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` |
| `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` |
| `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 |

The frozen vocabulary has **no path-template syntax**, so an endpoint cannot express
`/leads/{id}` — a record id travels as `?id=…`. A method that no declaration claims
Expand Down
12 changes: 7 additions & 5 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2057,18 +2057,20 @@ curl -b cookies.txt -X POST \
| `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 |

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

| Status | `error.code` | What happened | What to do |
|:---|:---|:---|:---|
| `404` | — | No flow by that name in this deployment | Check the name; nothing was dispatched |
| `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) |
| `409` | `FLOW_DISABLED` | The flow exists but is switched off | Enable it — the identical request then succeeds |
| `422` | `FLOW_NO_START_NODE` | The stored definition has no `start` node | Fix the flow; retrying cannot help |
| `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 |
| `400` | `FLOW_FAILED` | The flow **ran** and was rejected | Read `error.details.summary` for the failing node |

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

```
Expand Down
8 changes: 4 additions & 4 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ The largest single consumer — **17 of the 119 sites**.
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
Expand Down Expand Up @@ -356,7 +356,7 @@ still holds equal to the census on every pull request:
| — in tests | 1013 | — |
| — in non-test sources | 798 | — |
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
| — parsed as a declaration | 27 | ✅ |
| — parsed as a declaration | 28 | ✅ |
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
| — parsed as a property **read** | 125 | ✅ |
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
Expand All @@ -366,7 +366,7 @@ still holds equal to the census on every pull request:
| — behaviour-bearing (rows 1–61 above) | 116 | ✅ |
| — carry the flag onward only (rows 62–64 above) | 3 | ✅ |
| Packages containing at least one elevation read | **20** | ✅ |
| Files containing at least one elevation read | 55 | ✅ |
| Files containing at least one elevation read | 56 | ✅ |
| — the distinct symbols those reads live in — what this page anchors | 102 | ✅ |
| — of those files, the ones holding more than one read in one symbol | 8 | ✅ |

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

⚠️ **The precision that costs, priced here rather than buried.** A symbol anchor
cannot say WHICH read inside a function it means, and **8** of the **55**
cannot say WHICH read inside a function it means, and **8** of the **56**
anchored files hold more than one read inside a single symbol. So the population
check runs per file at symbol granularity: every file the census finds a read in
must be anchored, and the set of symbols this page cites into that file must
Expand Down
8 changes: 5 additions & 3 deletions content/docs/ui/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -407,8 +407,10 @@ is a spec proposal for a properly named key, not a values map under this one.
- **`requiredPermissions: ['can_close_tickets']`** is a **dual-surface gate**
(one declaration, two enforcement points): the server rejects unauthorized
calls with 403, and the UI hides or disables the button for the same users.
Unset means no gate beyond object CRUD permissions. Referenced capabilities
must exist — `os lint` checks that.
Unset means no gate beyond object CRUD permissions — except that a `flow`
action whose target is an elevated self-triggered flow is refused to every
caller but the system principal either way (the `flow` row below). Referenced
capabilities must exist — `os lint` checks that.
- **`visible`** is a CEL predicate evaluated **fail-closed**: an expression
that throws hides the action silently. Two rules save real debugging time:

Expand Down Expand Up @@ -544,7 +546,7 @@ The endpoint dispatches on the **declared `type`**, exactly like the MCP
| `type` | Over REST |
|:---|:---|
| `script` | Runs the registered handler / inline body. |
| `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. |
| `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. |
| `api` | **400** — it dispatches on `target`; call that endpoint directly. |
| `url` / `modal` / `form` | **400** — client-side navigation; there is nothing for the server to run. |

Expand Down
16 changes: 12 additions & 4 deletions examples/app-showcase/src/automation/flows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1634,15 +1634,23 @@ export const InboundTaskWebhookFlow = defineFlow({
* by the same filter, and report. Config keys follow the executor contract
* exactly — `objectName` + `filter` + the declared bulk intent `multi`
* (Prime Directive #12: no `object`/`filters` aliases). `runAs: 'system'`
* because a janitor acts
* across owners; autolaunched with no record trigger — invoke it on demand
* (API/subflow) rather than on every write.
* because a janitor acts across owners.
*
* `type: 'screen'` because it IS an entry: a signed-in caller starts it on
* demand through `InquiryPurgeEndpoint` (`src/system/apis/index.ts`), with no
* record trigger and no cadence. An elevated flow of a self-triggered type
* (`autolaunched`, `record_change`, `schedule`) runs on its own trigger or as a
* `subflow` from a parent, and every door that starts a flow by name — the
* trigger route, a `type: 'flow'` action, a declared `type: 'flow'` endpoint —
* refuses it to any caller but the system principal (`403 PERMISSION_DENIED`).
* Declaring the type an entry is the explicit, reviewable statement that
* signed-in callers may start this elevated work.
*/
export const InquiryPurgeFlow = defineFlow({
name: 'showcase_inquiry_purge',
label: 'Purge Closed Inquiries',
description: 'Deletes inquiries already marked closed — demonstrates get_record + delete_record.',
type: 'autolaunched',
type: 'screen',
runAs: 'system',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
Expand Down
9 changes: 8 additions & 1 deletion examples/app-showcase/src/system/apis/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,14 @@ export const TaskFeedEndpoint: ApiEndpoint = {
cacheTtlSeconds: 30,
};

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