feat(spec): declare the operator run-lifecycle verbs cancelRun and restoreConsumedSuspension on IAutomationService (contract half of the #13953 ruling) - #16563
Conversation
…ationService The contract half of #13953's ruling A: both operator run-lifecycle verbs are declared as optional members, typed as the engine implements them (cancelRun(runId, reason?) -> boolean; restoreConsumedSuspension(runId, options?) -> a narrower structural result), with the ruling's persistent-face statement in their docblocks and a pin test in automation-service.test.ts. @objectstack/spec minor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 130 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 329e4f5ec13ef66180da2b62fdc69de7a90fd647 && git checkout 329e4f5ec13ef66180da2b62fdc69de7a90fd647
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ffca0df9cb09cd61410b92f3d952226b309a58c8 bd193c657b587ebfa7c7318741d6af776eb156ce && git checkout -B drift-repro ffca0df9cb09cd61410b92f3d952226b309a58c8 && git merge --no-ff bd193c657b587ebfa7c7318741d6af776eb156ce
node scripts/docs-audit/affected-docs.mjs --json ffca0df9cb09cd61410b92f3d952226b309a58c8
|
Contract review at
|
The contract docblock said `true` is answered "only when a suspension was consumed by THIS call". The only implementation gives no such guarantee: the engine has no `cancelling` guard (only `resuming` / `restoring`), and `cancelRun` consumes through `forgetSuspendedRun(run, 'cancelled')`, whose store delete is by id and unconditional — nothing like `resume`'s advance claim. Two cancels of one run overlapping in time therefore both answer `true` and both record the terminal `cancelled` log. Say what the engine does: `true` means this call cancelled a suspended run (the `@returns` line, unchanged), NOT that it was the only one — a caller may not read sole authorship out of it, nor use it as an idempotency token for a once-only side effect. Prose only; the `@returns` line, the pin, the changeset and the result shape are untouched. Whether the engine should grow a cancel-side compare-and-set is the services half's call, not this contract's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
Repaired and armed — the exclusivity claim is gone, and the engine facts were re-measured independentlyHead The repair seat did not take the reviewer's measurement on trust — it re-derived both facts on the branch tree ( and traced the delete: ⭐ Worth recording: the engine's own Before → after:
The
|
Fixes #16495
Clause-②: yes
The contract half of the #13953 ruling A (director seat, decision batch #42, comment 5548737008 — maintainer 「13753 我让别人处理了,其他同意」).
IAutomationServicegains the two operator run-lifecycle verbs as optional members, typed as the engine implements them, with the ruling's persistent-face statement in both docblocks, plus a pin test and an@objectstack/specminor changeset. The services half — the REST doors, theplatform_admincheck, the ADR-0112 envelope, any lister — stays with #13953 and is not addressed here. No CLI, no implementation.What changed
packages/spec/src/contracts/automation-service.ts— two members appended toIAutomationServiceaftergetSuspendedScreen:cancelRun?(runId: string, reason?: string)answering a Promise ofbooleanrestoreConsumedSuspension?(runId: string, options?: { requestedBy?: string; reason?: string })answering a Promise of{ restored: boolean; runId: string; refusal?: string; reason: string }Both docblocks quote the ruling verbatim — "listing and acting go through
sys_automation_run(the persistent face), never engine memory" — and its permission posture ("gated on the existingplatform_adminposition (no new permission type, no per-run ownership — a run belongs to the environment, not a user)"), and state that a service not declaring a verb has NO operator door for it: the door must probe for presence and refuse fail-closed.packages/spec/src/contracts/automation-service.test.ts— the spec: name the terminally-failed run state onAutomationResult.status— contract half of #13937 (shape 4 ruling) #14384-shaped pin: four exported type-level identities (parameter tuples and return types of both verbs), a minimal-implementation case (both verbs absent), a typed full-implementation case provingreason/requestedBytravel through the contract, a@ts-expect-errorrefusing a result withoutreason(compiled bycheck:test-typecheck), and a docblock-reading case asserting the ruling's phrases sit above each declaration..changeset/automation-service-operator-verbs-contract.md—@objectstack/specminor.The three calls the ruling left open — made as the director's grading comment (5567526365) rules, with the reasons
verb(runId)shorthand.cancelRun(runId, reason?)andrestoreConsumedSuspension(runId, options?: { requestedBy?, reason? }). A door calling through the contract must be able to say who asked and why: restore's trace records exactly those and writesnot recordedwhenrequestedByis absent; cancel'sreasonlands on the terminalcancelledlog'serror. Both engine methods were re-located by symbol onf48f3f1b21:AutomationEngine implements IAutomationServiceat engine.ts:1718,cancelRunat :6262,restoreConsumedSuspensionat :6551 — the card's anchors hold today.SuspensionRestoreResult/SuspensionRestoreRefusal(engine.ts:1451 / :1431) stay with the engine; the contract declares{ restored, runId, refusal?: string, reason }inline, using the engine's own member names so the wider type satisfies the contract underimplementswithout any rename, andrefusaltypedstringrather than an enumeration this contract would have to keep in step (the director: no second consumer needs the eight codes yet; one that does is a card). Inline rather than a named exported alias on purpose:listSuspendedRunson this same interface is the house precedent, and a named export would add a row toapi-surface/contracts.jsonandexport-origins/contracts.json— the shards PRapi-surface/records a name declared as BOTH a const and a type under(type)only — deleting the value half of any of 132 such exports is invisible tocheck:api-surface#15919's in-flight repair rewrites wholesale. Measured after the build:check:api-surface"public API surface + factory signatures unchanged",check:export-origins"5277 exports across 17 entry points resolve exactly as recorded" — the card's own baseline numbers; zero baselines moved. Deliberately omitted from the contract result: the engine'sflowName/nodeId/consumedAt— the door's shape per the ruling is restored + refusal + reason; if the REST door wants to echo the re-armed node, that widening is the services half's to raise.{ execute, listFlows }" pin stays green, zero implementors break, and the docblocks turn absence into a rule: no member, no door, fail-closed. Required would have redded that pin plus the typed test literals across spec, service-automation and runtime, for no consumer that needs it.Zone 2 — who calls the verbs today (re-measured on
f48f3f1b21)implements IAutomationServiceisAutomationEngine(engine.ts:1718); the only non-test'automation'slot registration is plugin.ts:582.cancelRun: one in-process caller, plugin-approvals' revise-window recall — through its own duck-typedApprovalResumeSurface(approval-service.ts:134), not through this contract. Unchanged by this PR.restoreConsumedSuspension: zero non-test call sites (mentions in engine prose and log text only).Partialof the contract) and :855; spec/src/contracts/core-service-contracts.ts:80. Each sees two new optional members and nothing else.check:adr-0087-registration— "this PR adds no declared-breaking changeset".Verification (all on
60aaf369b6, the only commit; basef48f3f1b21)Build:
pnpm --filter @objectstack/spec buildunder the shared verify lock (held 322 s, exit 0). Generated footprint: zero tracked files changed by the build (git status --porcelainempty); gitignored only —packages/spec/dist/,packages/spec/json-schema/,.turbo/.pnpm --filter @objectstack/spec check:generated— "All 15 generated artifacts are up to date" (react-declaration-parity cannot run here, as designed).Spec gates by name: check:api-surface unchanged · check:export-origins 5277 / 17 exactly as recorded · check:exported-any · check:dual-source-exports · check:docs 228 in sync · check:authorable-surface · check:test-typecheck OK with the ledger held at 54 files / 261 errors / 145 signatures (this file's one pre-existing TS2739 entry unchanged, so the
@ts-expect-errorfired) · browser-reachable-entries · entry-nameability · llms-txt · duration-unit-keys · empty-state · liveness · objectui-pin-citations · skill-refs · strictness-ledger · variant-docs · yaml-examples — all exit 0.Spec typecheck, all three parts of its script:
tsc --noEmit -p tsconfig.json0 errors ·check:scripts-typecheck0 errors ·check:test-typecheckOK.Tests: every spec test that reads this contract file (enumerated by grep over
src/**/*.test.tsfor the import or the source path; 6 files, the fs-readingautomation-result-status.pin.test.tsamong them as the control) —vitest run --maxWorkers=2⇒ 6 files / 199 tests passed. Declared narrowing of the full spec suite: vitest strips types, so only a test that imports or reads this file can observe the change; CI runs the suite whole.Root families from
dispatch-gates --ran: 75 derived, 74 run, 1 unrun —check:type-check-debt(a cross-package--re-measure; declared to CI: an additive optional member cannot raise a consumer's tsc error count).check:dual-build-cjs-loadsanswered exit 3 — PREREQUISITE NOT MET (other packages' dist absent) — recorded as NOT MEASURED, not as a pass.Cross-package reverse verification (the engine's
implements), narrowed by declaration: service-automation's typecheck needs its 21-package dependency closure built, beyond the foreground cap under this container's lock queue. Substitute, measured against the rebuiltdist/contracts/index.d.mts: a class carrying the engine's exact method signatures (copied from engine.ts, the widerSuspensionRestoreResultincluded) compiles underimplements IAutomationService(exit 0); the control withcancelRunanswering a number and areason-less restore result is refused on both members (TS2416 ×2) — before this change neither member existed on the interface, so the control proves the rebuilt declaration is what was read. CI's TypeScript Type Check job covers the closure.Lint, narrowed with the three evidences: population =
eslint .undereslint.config.mjs(five global ignores: node_modules, dist, build, .next, .turbo; the two files fall under its**/*.{ts,…}andpackages/**/*.{ts,…}blocks); files linted = 2 (read from--format json), 0 errors / 0 warnings; invariance = the config never enables type-aware linting ("noparserOptions.project, no typed@typescript-eslintrules" — its own comment at lines 326–328), so this diff cannot move any untouched file's verdict.Ablations, each with an on-disk landing proof and a HEAD-anchored restore proven by
git status --porcelainempty and the blob hash back to HEAD784aa349…:19067c74…) ⇒ the docblock pin fails on exactly that phrase (vitest exit 1: 1 failed / 14 passed). Restored.cancelRunnarrowed to the ruling's shorthandcancelRun?(runId: string)(count 1 → 0; mutated bloba36ecc5e…) ⇒check:test-typecheckred, naming two ARRIVED signatures on the test file: TS2344 (the identity pin) and TS2554 (the two-argument call). Restored.Neither leg needs a build — the pins read source and compile under tsconfig.test.json — so the false-green build-regeneration trap the card warns about does not apply to them.
Byte hygiene:
check:nul-bytesOK over 8144 files; control-character self-scan of the three touched files: no hits.Not in this PR
The REST routes, the
platform_admincheck, the ADR-0112 refusal envelope, any lister, any CLI — #13953's services half, which remains open. The A/B/C fork is ruled and not reopened. No out-of-scope findings were met.needs:contract-reviewis on both carriers; this PR stays draft and is not enqueued by its author.🤖 Generated with Claude Code
Generated by Claude Code