feat(runtime): give the two operator run-lifecycle verbs a door (cancel, restore-suspension) - #16755
Conversation
`AutomationEngine`'s `cancelRun` (ADR-0044) and `restoreConsumedSuspension` (#13909) had no operator door: no REST route, no CLI command, and until `IAutomationService` declared them, no way for a contract-holding host to call them either. Add `POST /automation/:name/runs/:runId/cancel` and `POST /automation/:name/runs/:runId/restore-suspension`, behind the ADR-0095 `PLATFORM_ADMIN` rung, required unconditionally. Fail-closed on every axis the contract cannot close on its own: an absent optional member answers 501 rather than a 200, an unrecognised restore refusal code answers 500 rather than a 409 that would claim a diagnosis, and the cancel door's `false` names both readings instead of reading as a clean no-op. No once-only side effect keys off `cancelRun`'s non-exclusive `true`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…n-lifecycle-operator-door
…evation read `check:doc-authoring` refuses tracker ids inside runtime prose strings — a route ledger `note` reaches operators and generated surfaces, none of whom can resolve `#NNNN`. The ids move to adjacent `//` comments, where the reader who can resolve them already is. `check:check-system-context-census` requires every `isSystem` read to carry an anchor on the census page. The new gate's `isSystem` bypass is anchored on the automation row and the page's seven census-derived counts move with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
📓 Docs Drift CheckThis PR changes 1 package(s): 32 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a89f26ce9c5de3f76a8a0e9c432e3408a6f84255 && git checkout a89f26ce9c5de3f76a8a0e9c432e3408a6f84255
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ce8bfc9d651676c80f3c5438bc2db0e86d0560cb 34feecba75aea132ba43345dac3539e007200059 && git checkout -B drift-repro ce8bfc9d651676c80f3c5438bc2db0e86d0560cb && git merge --no-ff 34feecba75aea132ba43345dac3539e007200059
node scripts/docs-audit/affected-docs.mjs --json ce8bfc9d651676c80f3c5438bc2db0e86d0560cb
|
Contract review (
|
| file | kind |
|---|---|
.changeset/automation-run-lifecycle-operator-door.md |
new |
content/docs/permissions/system-context.mdx |
census counts 106→107 for the new isSystem read |
packages/runtime/src/domains/automation-run-lifecycle-door.test.ts |
new, 38 tests |
packages/runtime/src/domains/automation.ts |
+602: predicate, gate, body validator, refusal table, two route arms |
packages/runtime/src/route-ledger.ts |
+2 rows, server-only |
Governed paths touched: NO (none of docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/**). ⇒ no maintainer-only-merge trigger from the governed surface; the "Governed Surface Queue Guard" check is green.
3. The two doors (Clause-② widening)
| axis | cancel | restore-suspension |
|---|---|---|
| route | POST /automation/:name/runs/:runId/cancel |
POST /automation/:name/runs/:runId/restore-suspension |
| domain file | packages/runtime/src/domains/automation.ts (arm at ~L2325) |
same (~L2385) |
| ledger row | route-ledger.ts:396, disposition: 'server-only', note present, no responseSchema |
:402, same |
| spec contract | IAutomationService.cancelRun?(runId, reason?) — declared on origin/main (automation-service.ts:739) |
restoreConsumedSuspension?(runId, { requestedBy?, reason? }) (:823) — declared |
| gate | refuseUngrantedRunLifecycleWrite: isSystem → pass; posture === 'PLATFORM_ADMIN' → pass; everything else 403 (absent context, absent posture, TENANT_ADMIN, minted positions[] entry) |
same predicate isRunLifecycleWrite, same gate |
| ADR-0112 | 403 PERMISSION_DENIED via details.code promotion; 501 NOT_IMPLEMENTED (status-derived, ledger row error-code-ledger.zod.ts:1120) |
+ 404/503/409/500 by refusal code; engine code rides details.refusal, never details.code; pinned that error.code for a 409 is RESOURCE_CONFLICT, not RUN_COMPLETED |
| tenancy | verb takes runId only, no org argument — identical to the existing resume arm (automationService.resume(parts[2], signal)) |
same |
- 「以协议为基准」: both verbs are spec-declared members (PR feat(spec): declare the operator run-lifecycle verbs cancelRun and restoreConsumedSuspension on IAutomationService (contract half of the #13953 ruling) #16563) — the door binds to the contract, not to the concrete engine. The wire payloads (
{ runId, cancelled, notice },{ runId, restored, reason }) have nopackages/spec/src/apischema; the ledger's own rule says an absentresponseSchemais "undeclared, not a defect" and forbids filling it ahead of conformance coverage, so this is recorded as an observation (F8), not a block. - ADR-0066 D4 / capability: the gate is a posture rung, not a named capability, exactly as the ruling asked ("no new permission type"); ADR-0095 D3 derives
PLATFORM_ADMINfrom the unscopedadmin_full_accessgrant (hasPlatformAdminStanding→grants.posture === 'PLATFORM_ADMIN'), so the check is capability-derived. Verified present and unconditional; it sits ahead ofdeps.getService(...)and ahead of body validation, so nothing is cancelled before a refusal and a 501-vs-403 does not fingerprint the deployment. - ADR-0105 tenancy: the run lookup is not organization-scoped in the engine (
loadSuspendedRunStrict(runId)→store.load(runId)), and the door adds no scoping. Under this ruling that is by design — "no per-run ownership, a run belongs to the environment" — and the gate refuses every rung belowPLATFORM_ADMIN(the rung that crosses the tenant wall per ADR-0095 D1/D2), so no tenant-scoped principal reaches the verb at all. Pinned:TENANT_ADMIN→ 403, mintedpositions:['platform_admin']+TENANT_ADMIN→ 403, no-posture → 403, verb never called. Accepted as the ADR-0105-consistent shape for an environment-level verb; noted as F6 for the record. parts[0] === 'trigger'exclusion matches the toggle/clone arms; pinned (trigger/runs/run_7/cancelexecutes the flow, no gate, no cancel).requestedByis filled fromexecutionContext.userIdand refused by name in the body; pinned.
4. Route-envelope gate, ledger conformance, SDK
- No new module under
packages/runtime/src/domains/;automation.tsis already inDISPATCHER_DOMAINS(scripts/check-route-envelope.mjs:442,handBuilt: 0).check:route-enveloperuns inside "Lint & Repo Gates" (lint.yml:2567) — green on head. route-ledger.conformance.test.tshygiene: non-sdkrows must carry anote— both do;gapratchet<= 0untouched. Client-side ledger consumers (packages/client/src/*route-ledger*.test.ts) run in Test Core — green.- ⛔ Live-mount parity is red — see F1. The ledger says the routes exist; the router does not serve them.
- ⛔ Authz blind-spot census is red — see F2.
- SDK client method: not added, not deferred with a card. Rows are
server-onlywith prose saying "adding one reclassifies this row tosdk". The ruling declines a CLI command; it is silent on the JS SDK. See F4.
5. Changeset
.changeset/automation-run-lifecycle-operator-door.md → "@objectstack/runtime": minor. Correct level: two new externally reachable doors on a released package = additive widening ⇒ at least minor (Clause-② yes ⇒ patch forbidden by the #16055 level axis; major forbidden by the launch-window guard). "Check Changeset" green on head. Fixed-group lockstep: FROM 17.3.0 TO 17.4.0 for every package in the fixed group. No ADR-0087 marker needed (not a declared-breaking changeset). Body of the changeset is accurate to the diff.
6. Tests
automation-run-lifecycle-door.test.ts — 38 tests, driving HttpDispatcher.handleAutomation directly with a stub automation service. Per verb:
| pin | cancel | restore |
|---|---|---|
| happy path (operator, 200, verb called once) | ✅ | ✅ (and runId echoed is the path's, not the service's) |
| unauthorised caller refused (user / tenant admin / minted position / no posture) | ✅ 403, verb not called | ✅ |
| cross-tenant run id refused | ✅ by construction — every non-PLATFORM_ADMIN rung is refused before the id is read; no per-org run fixture exists because the model has no per-run ownership |
✅ same |
| wrong-state transition refused | cancelled:false → 200 + two-reading notice (the contract's idempotent-success shape); the true/false notices differ — pinned |
✅ 8 refusal codes → 404/503/409, unknown code → 500, restored:false without code → 500, non-object result → 500 |
absent member → 501, handled:true, never 200, only after the gate |
✅ | ✅ |
closed body envelope, requestedBy refused by name, bodyless accepted |
✅ | ✅ |
no side effect keyed off cancelRun's return (announceKernelEvent never fired) |
✅ | — |
No .skip / .only / .todo in the new file (grepped). Typecheck coverage: "Type Check · workspace/source/consumer gates" all green on head.
7. CI on head c9c1c6d2b (38 check runs)
- 30 success · 3 failure · 5 skipped (Packed-tarball ×2, Auto Label, Check PR Size, Console Pin Gate — all expected skips).
- ⛔ Failures: Dogfood Regression Gate (1/3) —
route-ledger-live-mount-parity.dogfood.test.ts(F1); Dogfood Regression Gate (3/3) —authz-probe-blind-spot.test.ts, 2 tests (F2); and the rollup "Dogfood Regression Gate". mergeable_state:unknownon two reads (GitHub has not computed it; draft PR).- Distance: merge-base
7c12e475eis 24 commits behindorigin/main(78bc4ad58at review time; the PR's recorded base was8ccf7a1df). Not a conflict indicator, but a re-merge is due when F1/F2 land.
Findings
F1 — ⛔ BLOCKING — the two routes are ledgered but not mounted; they 404 on the live server.
packages/runtime/src/dispatcher-plugin.ts registers the automation run family per route (:1527 GET …/runs, :1536 GET …/runs/:runId, :1547 POST …/runs/:runId/resume, :1556 GET …/runs/:runId/screen); there is no wildcard /automation/* mount and this diff adds no registration for …/cancel or …/restore-suspension. The dogfood live-mount parity gate says so verbatim: "POST /automation/:name/runs/:runId/cancel — LEDGERED BUT NOT MOUNTED. The live router answers nothing … this URL 404s at runtime while every ledger-reading guard passes it" (same for restore-suspension). The 38 unit tests are green because they call dispatcher.handleAutomation(...) directly and never touch the server. As shipped, an operator holding only HTTP — the ruling's whole premise — still cannot reach either verb.
Expectation: register both POST mounts in dispatcher-plugin.ts beside the resume registration, forwarding req.body / req.query / { request: req } identically; re-run pnpm --filter @objectstack/dogfood test and show the parity test green; state the mount site in the PR body.
F2 — ⛔ BLOCKING — the authz blind-spot census pins the ledger at 80 rows; it is now 82.
packages/qa/dogfood/test/authz-probe-blind-spot.census.ts:294-302 (file: 'packages/runtime/src/route-ledger.ts', population: 80, controls: { "route: '": 80, "domain: '": 80, … }) and the second row at :317 (population: 80) fail authz-probe-blind-spot.test.ts ("population, reach and blind spot are unchanged" and "every positive control is still present"). This census exists to force a classification of every new route's authz reach (#13260), not a number bump.
Expectation: re-derive the census per that file's own discipline (population 82; classify both rows — gated, PLATFORM_ADMIN-only, isSystem bypass — into reachable/blind-spot honestly), update the controls, and refresh the "80 rows / 21 domains" prose in authz-conformance.matrix.ts:28, authz-ledger-population.baseline.ts:62, authz-probe-blind-spot.census.ts:109.
F3 — PR body accuracy. The body states "dispatch-gates --ran reconciliation: 86 derived families, 86 run, 0 unrun" and names only two NOT-MEASURED gates; the Dogfood Regression Gate is red on head for causes this diff introduces. Either the dogfood suite is not in the derived family set for a ledger + dispatcher-domain change (a gate-derivation gap worth a card), or it was not run. Expectation: the 验收备注 records the dogfood run result on the fixed head, and says which.
F4 — SDK deferral has no card. Both rows are server-only; the ruling declines a CLI command and says nothing about the JS SDK client. The note's "adding one reclassifies this row to sdk" is scope prose, not a tracked decision. Expectation: file a follow-up card for client.automation.cancelRun / restoreSuspension (or record a maintainer decision that these stay server-only) and cite it from both ledger notes. Non-blocking.
F5 — the :name segment is not validated against the run. Both arms call the verb with parts[2] and never check that the run belongs to flow parts[0]; any flow name reaches any run id. This is the existing convention of resume/screen/getRun, so it is consistent, but the flow-scoped shape implies a scoping the door does not enforce. Expectation: say so in the ledger notes (one clause), or leave as-is with a stated reason. Non-blocking.
F6 — tenancy scoping is by rung, not by lookup (recorded). See §3. Consistent with the ruling's "no per-run ownership". No change requested; if ADR-0105 ever moves run rows behind an organization wall for platform admins, this door inherits whatever the engine does. Non-blocking.
F7 — docs route tables not updated (non-governed). content/docs/automation/flows.mdx:1731-1732 and packages/services/service-automation/README.md:296-297 list the run family (resume, screen) without the two new doors; the drift bot flagged 30 pages. Expectation: add the two rows to those tables in this PR (not content/docs/releases/**, which stays untouched). Non-blocking.
F8 — wire payloads undeclared in spec (observation). { runId, cancelled, notice } and { runId, restored, reason } have no @objectstack/spec/api schema and the ledger rows carry no responseSchema. Permitted by the ledger's own rule ("absent means undeclared … do not fill ahead of conformance coverage"); noting it so the #3877 Stage-D ratchet has a row to pick up. Non-blocking.
Verified as claimed (no finding)
Spec declares both verbs on origin/main; gate is unconditional and ahead of the service probe; isSystem bypass matches the family (system-context.mdx row 57 and census 106→107 updated); absent-member → 501 never 200 never handled:false; refusal codes never promoted into error.code; requestedBy never wire-settable; no once-only side effect; no lister; no CLI; no CAS; no existing predicate/refusal/capability relaxed (diff is purely additive on automation.ts); changeset level and package correct; no governed path touched; no .skip/.only/.todo.
Merge disposition: not mergeable as-is (F1, F2 → CI red, and F1 means the ruling's deliverable is not on the wire). Re-review on the fixed head is short: the parity and census tests are the acceptance for F1/F2.
Generated by Claude Code
…lready declares `POST /automation/:name/runs/:runId/cancel` and `.../restore-suspension` were declared in `route-ledger.ts` and answered by the `/automation` domain handler, but nothing registered them on the HTTP router: `registerAutomationRoutes` in `dispatcher-plugin.ts` mounts one literal registration per automation route and gained neither. Both URLs answered Hono's `notFound` at runtime while every ledger-reading guard passed them — the class the route-ledger to live-mount parity gate (#7526) exists to catch, and the class it caught this in. Two literal `server!.post` registrations, mirroring the `resume` arm beside them. They carry no authority logic: the gate stays the single `isRunLifecycleWrite` predicate in `domains/automation.ts` on the ADR-0095 posture rung, required unconditionally. The dogfood authz probe blind-spot census moves with the population it measures: the runtime ledger reads 82 rows instead of 80. Both new rows carry `domain: '/automation'`, an already-classified key, so `reachable` moves with `population`, `blindSpot` stays 0 and the key count stays 21. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…n-lifecycle-operator-door
`authz-ledger-population.baseline.ts`'s header still stated the runtime route ledger at 80 rows. The number moved to 82 with the two operator run-lifecycle rows; the date the measurement was taken is kept, because the key arithmetic the sentence goes on to state (40 minted, 6 classified, 34 baselined) is the part that did not move — both new rows sit under the already-classified `/automation` domain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Contract review (
|
| # | status | evidence |
|---|---|---|
| F1 (blocking) — routes ledgered but not mounted | closed | dispatcher-plugin.ts:1571 server!.post(\${base}/automation/:name/runs/:runId/cancel`…)and:1580 …/restore-suspension, beside resumeat:1547; each forwards req.body, req.query, { request: req }byte-for-byte as theresumearm does and contains no authority logic (a comment says so; the code agrees — try/dispatch/sendResult/errorResponse only). The gate remains the singleisRunLifecycleWrite predicate (domains/automation.ts:594, read at :1590, :2326, :2381). automation.tsbyte-identical toc9c1c6d. Dogfood 1/3 on head (job 102038734987): Test Files 45 passed (45) · Tests 345 passed (345), shard attested green; aggregate Dogfood Regression Gate` success. |
| F2 (blocking) — census pinned at 80 | closed | authz-probe-blind-spot.census.ts:311-316 now population: 82, reachable: 82, blindSpot: 0, keys: 21, controls 82/82/2. Re-counted on head: 82 route: ', 82 domain: ', 21 distinct domains; origin/main and the merge-base both read 80, so the +2 is entirely this PR's and the pin survives the merge. Prose refreshed at census.ts:109, matrix.ts:28, baseline.ts:62-64. Dogfood 3/3 on head (job 102038735165): 43 passed | 1 skipped (44) · 405 passed | 2 skipped (407). Honesty of the classification: by the census's own populationRule ("reachable = rows carrying a domain"), the two rows count as reachable because /automation is an already-classified key (matrix row anonymous-deny-automation, covers: dispatcher-domain:route-ledger.ts:/automation) — reach is domain-level, and the file says so ("the per-route rows being documentation"). No dogfood test drives either new route (git grep over packages/qa/dogfood: only the census comment names them); the per-route 403/501/409 behaviour is pinned only by the 38 runtime unit tests. That is honest within the census's stated contract, so no finding — see observation N3. |
| F3 — body accuracy (dogfood not reported) | closed, one clause short | 验收备注 now records the run per shard; the numbers match the CI job logs exactly (1/3: 45/345; 3/3: 43+1/405+2). It does not say which of the two causes: the answer is a gate-derivation gap — dispatch-gates.mjs derives no family for the dogfood workflow job on a packages/runtime/** change (86/86 derived green at c9c1c6d2b while dogfood was red) — and that gap is already carded as #16285 (open, pm:queue, "any package whose CI coverage is a workflow job rather than a gate script is invisible to it"). No new card needed; a cite is owed (N1). |
| F4 — SDK deferral has no card | open, non-blocking | No card found (search: only #13953/#16495/#15222). Ledger notes carry scope prose only ("this card declares no client method and implies none; adding one reclassifies this row to sdk"); no maintainer decision recorded either. Residual, carried as N4. |
F5 — :name not validated against the run |
open, non-blocking | Both arms still call the verb with parts[2] (:2333, :2389) and never read parts[0] against the run; neither ledger note carries the one clause asked for. Consistent with resume/screen/getRun. Residual, carried as N5. |
| F6 — tenancy by rung, not lookup | accepted-as-recorded | Unchanged; gate refuses every rung below PLATFORM_ADMIN before the id is read. |
| F7 — docs route tables not updated | open → finding again (N2) | content/docs/automation/flows.mdx:1730-1732 and packages/services/service-automation/README.md:295-297 still list resume / screen / detail only; neither file is in the diff. Non-governed docs. |
| F8 — wire payloads undeclared in spec | observation, unchanged | No responseSchema on either row; permitted by the ledger's own rule. |
Governed paths, changeset, CI, distance
Governed paths touched: NO — the full diff vs merge-base (9 files) contains none of docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/**; "Governed Surface Queue Guard" green.
Changeset: .changeset/automation-run-lifecycle-operator-door.md → "@objectstack/runtime": minor, no BREAKING, no ADR-0087 marker — correct for two additive doors on a released package. "Check Changeset" green (twice, incl. the 14:39 re-run).
CI on head (41 check runs): 36 success · 0 failure · 5 skipped (Packed-tarball ×2, Console Pin Gate, and the 14:39 label-event re-run's Auto Label / Check PR Size — all expected). All three dogfood shards plus the rollup green; Lint & Repo Gates (carries check:route-envelope, check:partof-closing-keyword) green. mergeable_state: clean.
Commit trailers: none of the 4 non-merge commits (c67f66f93, c9c1c6d2b, 0765b1436, 34feecba7) carries Fixes/Refs/Part of/Closes → check:partof-closing-keyword RULE 2 ("no commit may carry a card-relation trailer at all") is clean; the body's Fixes #13953 is the only carrier (RULE 1/3 clean). Advisory: the squash message must equal the PR body at merge.
Distance: merge-base e08892dac is 38 commits behind origin/main (21aabbc7b). Not a conflict indicator; route-ledger.ts on origin/main still reads 80 rows / 21 domains, so the census pin does not go stale on merge. No re-merge needed.
Clause-②: yes (two new externally reachable doors on the published HTTP surface).
New findings (repair round)
N1 — low, body accuracy. The CI-repair section says the repair "touches dispatcher-plugin.ts and two dogfood measurement files only"; it is three (census.ts, matrix.ts, baseline.ts — the os-dev report on the card already says three). Same section: the dogfood-vs-dispatch-gates divergence is a derivation gap, not a run omission, and is tracked as #16285. Expectation: one-line fix to "three", and cite #16285 in 验收备注 so the next reader does not re-derive it. Can be done in the body alone; no code change.
N2 — low, non-governed docs (F7 re-raised). flows.mdx:1730-1732 and service-automation/README.md:295-297 route tables lack the two doors; an operator reading either page still cannot discover the verbs the ruling exists to expose. Expectation: add the two rows to both tables — either in this PR or as an immediately-following docs-only PR; content/docs/releases/** stays untouched either way. Non-blocking.
N3 — observation, no change requested. Authz reach for the two doors is proven at domain level (census) and at unit level (38 runtime tests driving handleAutomation), but no dogfood probe hits the live URLs with a non-PLATFORM_ADMIN principal. Given F1's history (all 38 unit tests green while the URLs 404'd), a single live-server probe asserting 401 anonymous / 403 tenant-admin / 200-or-501 platform-admin at each door would close the last seam between "the handler refuses" and "the server refuses". A candidate row for whoever picks up #16285 or the authz-probe follow-ups.
N4 — carried from F4, non-blocking. SDK deferral still has no card and no recorded decision. Expectation: file client.automation.cancelRun / restoreSuspension as a follow-up card (or record "stays server-only" from the maintainer) and cite it from both ledger notes.
N5 — carried from F5, non-blocking. Expectation: one clause in each ledger note stating that :name is not checked against the run (matches resume/screen/getRun), so the flow-scoped shape is not read as a scoping the door enforces.
N6 — housekeeping. The PR is still draft. The director seat must undraft before the merge queue will take it.
Verified as claimed (no finding)
Mounts forward identically to resume; no authority logic in the plugin; automation.ts byte-identical; census 82/82/0/21 re-counted; the three prose sites refreshed; per-shard dogfood numbers equal the CI logs; check-test-completeness and shard attestation green on 1/3 and 3/3; no .skip/.only/.todo added; no governed path; changeset level and package correct; no commit trailers; nothing outside dispatcher-plugin.ts + three dogfood files in the PR-side repair delta.
Merge disposition: F1 and F2 are closed with independent evidence; the remaining items (N1, N2, N4, N5) are non-blocking and non-governed. Mergeable once undrafted; the docs rows (N2) and the two body clauses (N1) are cheap to fold in before the squash if the director prefers a single landing.
Maintainer-only merge: no — non-governed, additive, minor, no !; the director seat can land it.
Generated by Claude Code
…alytics-native-sql-authz #16755 landed on main and reached the one file both diffs touch, content/docs/permissions/system-context.mdx, exactly as the cross-PR pile-up patrol predicted on this PR. The merge driver declined to defer it (MIXED — a generated half plus hand-written prose) and text-merged it into a conflict. Resolved by hand in the only direction that keeps both sides' prose: main's enriched automation row is adopted whole, including its fourth anchor `#refuseUngrantedRunLifecycleWrite` and the operator run-lifecycle sentence, carried onto this branch's row number (this branch inserted a row upstream in the same table, so every row below it is offset by one). The census counts are re-derived from the merged tree in the follow-up commit, per the driver's own instruction and the regen ordering rule — never regenerated while in MERGE state. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…tree The merge of #16755 is the case the census gate exists to catch and the merge driver cannot: both sides had independently bumped the SAME declared counts from 106 to 107, so every one of them text-merged cleanly to 107 while the merged tree now holds 108 elevation reads — main's new `#refuseUngrantedRunLifecycleWrite` plus this branch's `canReadObject`. Two correct edits, one wrong sum, and no conflict marker anywhere near it. Re-derived from the merged tree, after the merge was committed and never during MERGE state. Seven declared counts move by one; the gate names each and states there is no mechanical repair, so each was corrected by hand: check-system-context-census: OK — 108 elevation read sites in 20 packages across 45 files, living in 91 symbol(s); the page cites 105 symbol(s) against 105 required, over 129 anchors and 8 file-level citation(s) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Fixes #13953
AutomationEngine's two operator run-lifecycle verbs —cancelRun(ADR-0044) andrestoreConsumedSuspension(#13909) — get a door. Until PR #16563 declared them onIAutomationServiceneither was reachable by an operator at all: no REST route, no CLI command, not on the contract. This is the services half of the 2026-09-05 maintainer ruling (option A, comment 5548737008).The permission model, and how it compares to
resume'sThe gate is the ADR-0095 D2/D3 posture rung —
ec.posture === 'PLATFORM_ADMIN'— required unconditionally, with theisSystembypass every gate in this family carries.positions[]name. The ruling says "the existingplatform_adminposition"; Four server-side readers derive platform authority from the NAME inExecutionContext.positions— same species as #15948's blocked escalation, and already reachable onmain#15981 established that reading that array is not the authority, becausesys_user_positionisapiEnabledwith unconstrainedpositionvalues, so a tenant can mint a row spelling the built-in andresolveUserAuthzGrants§4 pushes it onto the array. The rung is derived from the unscopedadmin_full_accessevidence and nothing else — byte-for-byte whathasPlatformAdminStandingreturns, and what the ruling meant. The correction is made at birth here rather than after a measurement;activation-gate.tscarries the same one.refuseUngrantedActivationWriterequires the operator only undergroup/isolatedand falls open undersingle— correctly, because amanage_metadatacapability tier still gates it there. This door has no tier in front of it, so the same conditionality would leave both verbs open to any authenticated caller on every single-organization deployment.Against
resume, the card's floor.resumehas no route-level gate: it is fail-closed in the engine on the suspended node's declaredresumeAuthority(#3801 / #5561, since when a node declaring noresumeAuthorityis refused too), so an ordinary authenticated caller reaches the service and the engine decides. These doors refuse that same caller before the service is resolved. Pinned as a side-by-side in one test: the callerresumeforwards to the service gets 403 at both lifecycle doors.Nothing existing was relaxed. Two new routes, one new predicate, one new gate; no existing predicate, refusal, capability or posture rule changed. The hard stop in the dispatch did not fire.
Where the gate sits. With the three gates already there and ahead of the service probe, for their documented reasons read one tier up: which standing a route requires must not vary with which automation service a deployment mounts, an unentitled caller must not learn from a 501-vs-403 whether automation is mounted here, and nothing may be cancelled or re-armed before the refusal — "cancel first, refuse second" is the worst shape a run-lifecycle door can have.
One predicate, read three times.
isRunLifecycleWriteis read by the gate and by both route arms, so a gate narrower than its route (a bypass) or wider than its route (an over-block) is not expressible. It excludesparts[0] === 'trigger'exactly as the toggle (#10243) and clone (#12156) arms do, soPOST /automation/trigger/runs/:id/cancel— the legacy execution door for a flow literally namedruns— is neither over-blocked nor dispatched as a cancel. Pinned.The absent-member fail-closed pin
Both members are optional on
IAutomationService(13 of its 15 are). The contract states the consequence and hands this door the job: "a door MUST probe for presence and refuse fail-closed when it is absent — never answer success for a verb it could not dispatch".A service not declaring the verb answers 501,
NOT_IMPLEMENTED, naming the member — the shaperesumealready uses one arm up. Three things are pinned, not one:{ cancelled: false }for a verb never dispatched is indistinguishable on the wire from a real engine answering about a run it could not find — service-automation: a resume consumes the pause BEFORE running downstream nodes, so any node that throws leaves the run terminally unresumable — and the only inspector for it reports all clear #13909's exact failure.{ handled: false }. That fall-through renders as404 ROUTE_NOT_FOUNDwith a "check the API discovery endpoint" hint, both halves false here, and an operator reads a routing bug that does not exist.Unknown refusal codes
IAutomationServicetypes the restore refusal asrefusal?: string— a covariant widening of the engine's closed eight-memberSuspensionRestoreRefusalunion (#16495 route (i)). Any refusal-code → HTTP-status mapping is therefore a non-exhaustive string switch by construction.The eight known codes map onto the statuses this same door already answers those conditions with on
resume, so one deployment cannot answerRUN_NOT_FOUNDtwo ways depending on which verb asked:RUN_NOT_FOUNDSTORE_UNAVAILABLERESUME_IN_PROGRESS·RESTORE_IN_PROGRESS·RUN_SUSPENDED·RUN_COMPLETED·RUN_CANCELLED·NO_CONSUMED_SUSPENSIONAnything else — an unrecognised code, or a
restored: falsecarrying none — answers 500. Deliberately not one of the 409s: a 409 would claim a diagnosis this door did not make ("the run's state refuses this, retrying will not help"), and a caller or an agent reading that would stop, believing the platform had answered them. 500 says the true thing — the implementation refused, this door does not know what it refused with, and nothing about the run's state has been established. Never a 200 either way.⛔ The vocabulary is neither narrowed nor extended at the call site. Closing it is a
packages/speccard, as the contract's own docblock rules. The engine's code ridesdetails.refusal, neverdetails.code— that key is promoted intoerror.code, which ADR-0112 closes toStandardErrorCode∪ the registered ledger, and none of the engine's eight are members.error.codederives from the status instead. Pinned.No once-only side effect keys off
cancelRun's returnPer ruling ① on this card, no cancel-side compare-and-set is added, and the consequence is absorbed in the door instead. The engine's
cancelRundoesloadSuspendedRunStrictthen an unconditional delete-by-id; onlyresumepasses theclaimAdvancecompare-and-set throughforgetSuspendedRun. So two overlapping cancels each read the row, each delete, eachrecordLog('cancelled'), and each returntrue.The proof, in three parts:
deps.announceKernelEvent, no store write, no event — thecancelled ? … : …expression selects a response string and nothing else.trueproduce two byte-identical 200s, and the domain's side-effect channel (deps.announceKernelEvent, which reacheskernel.context.trigger) is asserted never called. Nothing accumulates; nothing is "already done".trueanswer carries a notice stating thattrueis not exclusive and must not be used as an idempotency token — because this door is not the last consumer, and a caller reading a barecancelled: trueas "I, uniquely, ended this run" would build the forbidden side effect one tier up.⭐ The stop condition did not fire: the door does not require a once-only side effect, so the CAS did not become necessary.
⛔ Never a success that hides the condition
cancelled: falseis a 200 — the contract calls it idempotent success — but never a bare one. Two conditions land on that samefalseand nothing above the engine can tell them apart: "no suspended run under this id" and "the durable store could not be READ, so the run may still be parked and resumable" (the engine reports the second aterrorfor exactly that reason). The response carries a notice naming both readings and telling the operator to confirm the run's state. The two arms carry different notices, pinned, so the condition can never be collapsed.Who asked, and why
⭐
requestedByis filled from the authenticated caller, never from the body, and the closed{ reason? }envelope refuses the key by name — a wire-settablerequestedBywould let one operator write another's name into the trace that records who re-armed a terminally-failed run, which is the one field that record exists for. A caller who spells it gets a loud refusal instead of the silent impression that it took.cancel'sreasonis relayed verbatim — the contract calls it "why, in the operator's words", so the door does not decorate it.cancelRunhas norequestedByslot (only the repair verb does), so what the terminal record carries about who cancelled is whatever the operator wrote. Inventing a slot here would be a second name for a contract parameter that does not exist.Zone 2 — who inside the engine calls these two verbs today
Re-measured on this tree, confirming the contract review's reading (comment 5565076877):
cancelRunhas an in-process caller, but not throughIAutomationService—plugin-approvalscarries its own duck-typedApprovalResumeSurfacefor the revise-window recall. That caller isisSystemand never speaks HTTP, so this door's gate does not touch it.restoreConsumedSuspensionhas zero non-test callers in the repo.automationslot holds the concrete engine, so anygetService('automation')holder could already call both at runtime — they were invisible in the type system, not unreachable. ⇒ This PR turns a runtime-reachable, type-invisible capability into a contract-visible one with a gate in front of it.What this deliberately does not ship
sys_automation_runterminal rows and is its own card.server-onlyand say so as scope, not as a closed question: this card declares no client method and implies none; adding one reclassifies the row tosdk. (gapwas not available: the ledger's gap ratchet is<= 0and raising it "demands an explicit, reviewed decision".)Route shape — a note for review
The ruling wrote
POST /automation/runs/:id/cancel. The routes here are flow-scoped (/:name/runs/:runId/...), matching the existing run family (resume,screen,getRun) — the same class of shorthand as the ruling'scancelRun(runId), which comment 5570713718 already corrected to the engine's real signature. A flat/automation/runs/...shape would also have to be matched asparts[0] === 'runs', which shadows a flow literally namedruns— the exact route-order hazard this file's header warns about for/actions,/connectorsand/_status.验收备注
check:type-check-debtandcheck:dual-build-cjs-loadswerePREREQUISITE NOT MET(exit 3) atc9c1c6d2band are measured green at34feecba7— the second only after building the seven packages its refusal names, the first without the OOM once the closure build existed. Both are inside the 86/86 reconciliation below; noPREREQUISITE NOT METresult is reported here as a measurement.@objectstack/dogfoodis run exactly as CI shards it, at34feecba7:--shard=1/345 files / 345 tests passed ·--shard=2/345 files / 299 passed, 1 skipped ·--shard=3/343 files passed and 1 skipped file / 405 passed, 2 skipped — 134 files, 1052 tests, 0 failures, the whole suite. No test was skipped, disabled,.skip-ed or quarantined to get there.declared-unresolvableCONTROL flips red once packages/plugins/organizations is BUILT — its premise died when the package moved to open core #16539:packages/qa/dogfood/test/enterprise-organizations.test.ts'sdeclared-unresolvableCONTROL goes red on any checkout wherepackages/plugins/organizationshas been BUILT. Measured here in both legs at this head, on a file this PR does not touch: with thatdistpresent the file readsTests 1 failed | 8 passed (9), with it parked asideTests 9 passed (9). Mechanism, measured inside vitest: vitest exportsNODE_PATHcontainingnode_modules/.pnpm/node_modules, socreateRequirefrom the/tmp"declared but not installed" fixture host resolves the package anyway and the probe answersavailable: true. CI's dogfood job is green only because it builds dogfood's dependency closure, which excludes that package — so the dogfood numbers above were taken with thatdistabsent, i.e. in the state the dogfood CI job actually runs in. It surfaced here only becausecheck:dual-build-cjs-loadsdemanded a fuller build.cancelRun's signature has norequestedByslot whilerestoreConsumedSuspension's does, so the cancel door cannot record who asked except through the operator's ownreasontext. An asymmetry in the engine's shape, not a defect in either verb.details.refusalhere. Registering them is the spec card that would close the vocabulary.Tests
packages/runtime/src/domains/automation-run-lifecycle-door.test.ts— 38 new tests, driving the realHttpDispatcher(real router, real gate stack, real envelope helpers).pnpm --filter @objectstack/runtime test— 241 files / 3378 tests, all pass.pnpm --filter @objectstack/runtime typecheck— green; the new test file is proven inside the type-check program (tsc -p tsconfig.test.json --listFiles: 1 hit, positive control 1, negative control 0).ROUTE_LEDGERfrompackages/client) — 3 files / 8 tests, pass.eslint . --no-inline-configover the whole repo — exit 0, 6347 files, 0 errors, 0 warnings (measured atc9c1c6d2b; exit code captured before any pipe).dispatch-gates --ranreconciliation: 86 derived families, 86 run, 0 unrun.CI repair round — re-measured at
34feecba7The first push of this branch was red on
Dogfood Regression Gate(aggregate plus shards 1/3 and 3/3) for two independent reasons, only one of which was a pinned expectation. Both are repaired in0765b1436, andorigin/mainwas merged in as a merge commit (never a rebase; no force-push, no amend) before the final re-derivation.route-ledger-live-mount-parity.dogfood.test.ts(shard 1/3)LEDGERED BUT NOT MOUNTED:registerAutomationRoutesindispatcher-plugin.tsmounts one literal registration per automation route and gained neither, so each URL answered Hono'snotFoundat runtime while every ledger-reading guard passed it — the exact class #7526's gate exists to catch.server.postregistrations beside theresumearm. They carry no authority logic: the gate stays the singleisRunLifecycleWritepredicate indomains/automation.ts.authz-probe-blind-spot.test.ts(shard 3/3)expected 82 to be 80— the two new rows.blindSpotstays 0 andkeysstays 21 because both rows carrydomain: '/automation', an already-classified key. The three co-located prose claims of the same number were refreshed.⛔ The ruled shape did not drift. The repair touches
dispatcher-plugin.tsand two dogfood measurement files only —packages/runtime/src/domains/automation.tsis byte-identical to the reviewed head — so the door is still gated on the ADR-0095PLATFORM_ADMINposture rung, unconditionally,isSystem-bypass only, never apositions[]entry, never posture-conditional; no lister, no CLI command, no cancel-side compare-and-set, and nothing keys a once-only side effect offcancelRun's return.Re-measured at
34feecba7, after the merge:pnpm --filter @objectstack/dogfoodwhole suite, sharded as CI shards it — 134 files / 1052 tests, 0 failures (the per-shard split is in 验收备注 above).pnpm --filter @objectstack/runtime test— 243 files / 3405 tests, all pass.pnpm --filter @objectstack/runtime --filter @objectstack/dogfood typecheck— exit 0.eslint . --no-inline-config --format jsonover the whole repo — exit 0, 6357 files, 0 errors, 0 warnings, not narrowed.dispatch-gates --commandsre-derived after the merge (the first derivation printedSTALE TREE, 41 commits behind, which is why the merge happened): 86 families, all 86 run, all exit 0, reconciled —86 derived famil(ies) accounted for — 86 run, 0 NOT-MEASURED.scripts/pm/os-verify-lock.sh; the verdict quoted is itsVERDICT command-exitline, never a bare$?.Ablation — every pin proved able to fail
Three mutations, each with an on-disk landing proof (
grep -con both the removed anchor and the injected text, plus agit hash-objectdiffering from the baseline blob), atrap … EXIT INT TERMrestore verified bygit diff HEADbeing empty and the blob hash returning to baseline, and a green control on the unmutated tree.positions[]name beside the rungcancelRunanswers 200 instead of 501Negative claims, each with a firing positive control
git grep -E "cancelRun|restoreConsumedSuspension|restore-suspension" origin/main -- packages/cli→ 0 files. Positive control, same matcher form on symbols the CLI provably uses (listFlows|registerFlow|getFlow) → 4 files, fires.git grep -E "'(cancel|restore-suspension)'" origin/main -- packages/runtime/src packages/rest/src→ 0. Positive control, same form on'(resume|screen)'in the same file → 2, fires.Changeset
.changeset/automation-run-lifecycle-operator-door.md,@objectstack/runtimeminor. PerAGENTS.md:1029read verbatim:skip-changesetis for a diff that publishes nothing from any released package, and adding two externally reachable doors to a released package publishes something. Notmajor— purely additive, no existing route, refusal, capability or contract member changes behaviour. No ADR-0087 marker:check:adr-0087-registrationrequires one only on a declared-breaking changeset, and this one isminor(the gate ran green).Clause-②: yesThe deliverable adds externally reachable entry points on a published surface, so it widens the accept set by definition.⚠️ The contract-review tier is quota-exhausted, so this stays in draft until it returns.
needs:contract-reviewis hung on this PR as well as on the issue.🤖 Generated with Claude Code
https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37