feat(runtime): give the two operator run-lifecycle verbs a door (cancel, restore-suspension) - #16755
feat(runtime): give the two operator run-lifecycle verbs a door (cancel, restore-suspension)#16755os-trump wants to merge 6 commits into
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
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