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
15 changes: 15 additions & 0 deletions .changeset/automation-run-lifecycle-operator-door.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@objectstack/runtime": minor
---

The two operator run-lifecycle verbs get a door: `POST /automation/:name/runs/:runId/cancel` and `POST /automation/:name/runs/:runId/restore-suspension`, both gated on the platform operator.

`AutomationEngine` has carried `cancelRun` (ADR-0044) and `restoreConsumedSuspension` (#13909) for as long as either has existed, and neither was reachable by an operator: no REST route, no CLI command, and — until `IAutomationService` declared them — no way for a host holding only the service contract to call them either. The gap mattered most for the repair verb, which has no in-process caller by design: it is meant to be asked for by a person, and there was no way to ask. Maintainer ruling, 2026-09-05 (option A): both are platform-operator verbs, and listing and acting go through `sys_automation_run`, never engine memory.

- **The gate is the ADR-0095 D2/D3 posture rung** (`posture === 'PLATFORM_ADMIN'`), required **unconditionally**, with the usual `isSystem` bypass so `plugin-approvals`' in-process revise-window recall is untouched. It is deliberately not the `positions[]` entry spelling the built-in: `sys_user_position` is `apiEnabled` with unconstrained values, so a tenant can mint that row. It is also deliberately not posture-conditional the way the ADR-0126 §5 activation gate is — that gate falls open under `single` because a `manage_metadata` tier still stands in front of it, and this door has no tier in front of it, so the same conditionality would open an operator verb to any authenticated caller on every single-organization deployment. Which routes is one predicate, read by the gate and by both route arms so they cannot drift.
- **Refusing fail-closed on an absent member is the door's own job.** Both verbs are optional members of the contract, as 13 of its 15 are. A service that does not declare one answers **501 `NOT_IMPLEMENTED`** naming the member — never a 200 carrying a lifecycle verdict for a verb that was never dispatched, and never the `{ handled: false }` fall-through that renders as a 404 with a discovery hint for a route discovery does not list.
- **Refusals are refusals.** The restore door maps the implementation's refusal code onto the statuses this same door already answers those conditions with on `resume` — `RUN_NOT_FOUND` 404, `STORE_UNAVAILABLE` 503, and the run-state conflicts 409. The contract types that code as `refusal?: string`, a covariant widening of the engine's closed eight-member union, so the mapping is a **non-exhaustive string switch by construction**: an unrecognised code — or a refusal carrying none — answers **500**, not one of the 409s, which would claim a diagnosis the door did not make. The vocabulary is neither narrowed nor extended at the call site. The code rides `details.refusal`, never `details.code`, so `error.code` stays inside the ADR-0112 closed catalog.
- **`requestedBy` comes from the authenticated caller, never the wire.** The repair verb's trace records who asked and why; a wire-settable `requestedBy` would let one operator write another's name into the record of who re-armed a terminally-failed run. The body envelope is closed to `{ reason? }` and refuses the key by name, so a caller who tries gets a loud refusal instead of the silent impression that it took.
- **No once-only side effect keys off `cancelRun`'s return, and the wire says why.** The engine has no cancel-side compare-and-set, so two overlapping cancels each answer `true` and each record the terminal log. This door fires no notification, writes no audit entry and announces no kernel event; the `true` answer carries a notice stating the non-exclusivity so a caller does not build that side effect one tier up. The `false` answer carries the other half: the contract's idempotent-success reading **and** the unreadable-store reading, which land on the same `false` and which nothing above the engine can tell apart.

⛔ No lister ships here. A lister backed by the engine's in-memory journal answers zero in any process that did not itself strand the run, and a confident zero is the failure this class is about; a correct one is a query over `sys_automation_run` terminal rows and is its own card. ⛔ No CLI command either — the ruling declines one for want of pull.
18 changes: 9 additions & 9 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a
service self-write, a migration.

This page is **the authority** for what that flag actually does. It exists
because the flag is not one concept: it is a single boolean read at **106
because the flag is not one concept: it is a single boolean read at **107
distinct sites across 20 packages**, and knowing three of those behaviours gives
no hint that the other hundred-and-three exist. Every documented app-side bug
no hint that the other hundred-and-four exist. Every documented app-side bug
traced to `isSystem` had the same shape — the metadata was complete and correct,
and the gap was observable only by querying the resulting rows.

Expand Down Expand Up @@ -131,7 +131,7 @@ that silently does not happen.

### 3. Sharing (`plugin-sharing`)

The largest single consumer — **17 of the 106 sites**.
The largest single consumer — **17 of the 107 sites**.

| # | Behaviour when `isSystem` | What you get / what you lose | Anchor |
|:--|:---|:---|:---|
Expand Down Expand Up @@ -172,7 +172,7 @@ The largest single consumer — **17 of the 106 sites**.
| 54 | 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` |
| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `packages/runtime/src/domains/packages.ts#requireManageMetadata`, `#requireReadCapability` |
| 56 | 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` |
| 57 | Automation run-state read, flow-authoring write and unrelated-screen read all pass | runtime | Get: run state, flow writes and screen reads with no grant | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#refuseUnrelatedScreenRead` |
| 57 | Automation run-state read, flow-authoring write, unrelated-screen read and the two operator run-lifecycle writes all pass | runtime | Get: run state, flow writes, screen reads with no grant — and cancelling or restoring a suspension without the platform-operator rung. 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 | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#refuseUnrelatedScreenRead`, `#refuseUngrantedRunLifecycleWrite` |
| 58 | 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` |
| 59 | Email-template / webhook provenance stamps skipped | plugin-email, plugin-webhooks | Lose: the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-provenance.ts#bindEmailTemplateProvenanceStamp`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` |
| 60 | **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 @@ -277,7 +277,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are
independent decisions, and a seed loader plausibly wants the first two but not
the third. The concept is nevertheless **staying as one boolean**:

- **Shipped semantics.** `isSystem` is a published contract with 106 read sites
- **Shipped semantics.** `isSystem` is a published contract with 107 read sites
in 20 packages. Splitting it is a breaking contract change across all of them.
(The ruling was taken when the census read 80 sites in 18 packages; the count
has grown, which strengthens rather than weakens the argument.)
Expand Down Expand Up @@ -334,16 +334,16 @@ still holds equal to the census on every pull request:
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
| — parsed as a declaration | 22 | ✅ |
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
| — parsed as a property **read** | 112 | ✅ |
| — parsed as a property **read** | 113 | ✅ |
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
| — the remainder: text inside comments and string literals | 358 | — |
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **106** | ✅ |
| — behaviour-bearing (rows 1–62 above) | 102 | ✅ |
| Of those reads: reads of `ExecutionContext.isSystem` | **107** | ✅ |
| — behaviour-bearing (rows 1–62 above) | 103 | ✅ |
| — carry the flag onward only (rows 63–66 above) | 4 | ✅ |
| Packages containing at least one elevation read | **20** | ✅ |
| Files containing at least one elevation read | 45 | ✅ |
| — the distinct symbols those reads live in — what this page anchors | 89 | ✅ |
| — the distinct symbols those reads live in — what this page anchors | 90 | ✅ |
| — of those files, the ones holding more than one read in one symbol | 9 | ✅ |

The six rows marked — are a **dated decomposition, not a live claim**: they were
Expand Down
2 changes: 1 addition & 1 deletion packages/qa/dogfood/test/authz-conformance.matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
// dispatcher domain files.
//
// The population comes from `packages/rest/src/rest-route-ledger.ts` (94 rows
// / 19 families) and `packages/runtime/src/route-ledger.ts` (80 rows / 21
// / 19 families) and `packages/runtime/src/route-ledger.ts` (82 rows / 21
// domains) because those two are enumerated from a RUNNING server and guarded
// in both directions by their own conformance tests — so a new family or
// domain cannot be silently absent from them, and therefore cannot be silently
Expand Down
4 changes: 3 additions & 1 deletion packages/qa/dogfood/test/authz-ledger-population.baseline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@
* Ledger-sourced population keys with no classifying matrix row.
*
* MEASURED 2026-08-31 against `rest-route-ledger.ts` (94 rows / 19 families)
* and `route-ledger.ts` (80 rows / 21 domains): 40 keys minted, 6 classified
* and `route-ledger.ts` (80 rows / 21 domains — 82 since the two operator
* run-lifecycle rows landed, both under the already-classified `/automation`
* domain, so the key arithmetic below is unmoved): 40 keys minted, 6 classified
* by rows that already pin the same surface through the probe table, 34 here.
*
* ⛔ SHRINK-ONLY. See rules 1–4 above; the test enforces all four.
Expand Down
13 changes: 9 additions & 4 deletions packages/qa/dogfood/test/authz-probe-blind-spot.census.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@
// `RestServer.getRoutes()` on a booted server and guarded per route by
// `rest-route-ledger.conformance.test.ts`. It reaches all 17 registrars;
// this table reaches 1.
// `packages/runtime/src/route-ledger.ts`: 80 rows over 21 domains. Its
// `packages/runtime/src/route-ledger.ts`: 82 rows over 21 domains. Its
// machine contract is DOMAIN-level, by live registry introspection
// (`domainRegistry.list()`), the per-route rows being documentation. It
// covers all 15 `async handle*(` methods in `http-dispatcher.ts` and all
Expand Down Expand Up @@ -302,11 +302,16 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
kinds: ['ROUTE_ENUMERATION'],
probes: 1,
keys: 21,
population: 80,
reachable: 80,
// [#13953] 80 -> 82: the two operator run-lifecycle rows
// (`POST /automation/:name/runs/:runId/cancel` and `.../restore-suspension`).
// Both carry `domain: '/automation'`, an EXISTING key, so `reachable` moves
// with `population`, `blindSpot` stays 0 and `keys` stays 21 — a population
// that grows inside an already-classified domain mints nothing new.
population: 82,
reachable: 82,
blindSpot: 0,
populationRule: 'ledger rows inside ROUTE_LEDGER; reachable = rows carrying a `domain` (each distinct value mints a key)',
controls: { "route: '": 80, "domain: '": 80, RouteLedgerEntry: 2 },
controls: { "route: '": 82, "domain: '": 82, RouteLedgerEntry: 2 },
note:
'The dispatcher half. Its machine contract is DOMAIN-level by live registry introspection ' +
'(domainRegistry.list()), guarded in BOTH directions by route-ledger.conformance.test.ts: every ' +
Expand Down
33 changes: 33 additions & 0 deletions packages/runtime/src/dispatcher-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1553,6 +1553,39 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
}
});

// [#13953] The two OPERATOR RUN-LIFECYCLE verbs — cancel a
// suspended run (ADR-0044) and put back the suspension a failed
// resume consumed (#13909). LITERAL registrations, one per verb,
// for the reason `resume` above is one: nothing else registered
// on this router answers a 5-segment POST under `/automation`,
// so a ledger row with no `server!.post` here answers Hono's
// `notFound` at runtime while every ledger-reading guard passes
// it — the exact class the route-ledger ↔ live-mount parity gate
// (#7526) exists to catch, and the class it caught this in.
//
// ⛔ These arms carry NO authority logic. The gate is one
// predicate in `domains/automation.ts` (`isRunLifecycleWrite`,
// the ADR-0095 posture rung, unconditional) read by the gate and
// by both route arms there; a second spelling here would be a
// second policy that happens to agree today.
server!.post(`${base}/automation/:name/runs/:runId/cancel`, async (req: any, res: any) => {
try {
const result = await dispatcher.dispatch('POST', `/automation/${req.params.name}/runs/${req.params.runId}/cancel`, req.body, req.query, { request: req });
sendResult(result, res);
} catch (err: any) {
errorResponse(err, res);
}
});

server!.post(`${base}/automation/:name/runs/:runId/restore-suspension`, async (req: any, res: any) => {
try {
const result = await dispatcher.dispatch('POST', `/automation/${req.params.name}/runs/${req.params.runId}/restore-suspension`, req.body, req.query, { request: req });
sendResult(result, res);
} catch (err: any) {
errorResponse(err, res);
}
});

server!.get(`${base}/automation/:name/runs/:runId/screen`, async (req: any, res: any) => {
try {
const result = await dispatcher.dispatch('GET', `/automation/${req.params.name}/runs/${req.params.runId}/screen`, undefined, req.query, { request: req });
Expand Down
Loading
Loading