feat(spec): declare continueRestoredRun on the IApprovalService contract - #17567
Conversation
…ontract The approvals half of the operator repair pair. `restoreConsumedSuspension` re-arms a consumed pause and deliberately does not replay the resume signal; for an approval suspension nothing could re-issue the continuation, because every front door guards on a `pending` request and the row is terminal. The issuer exists as a class member on `plugin-approvals`; declaring it optional on the contract puts the same verb family on the contract rather than half on a contract and half on a class. Shape and docblock discipline follow `IAutomationService.cancelRun` / `restoreConsumedSuspension`: optional, structural result declared inline, and the note that promising a repair verb that will refuse is worse than promising nothing. No REST or CLI route is declared. Also corrects the class docblock's posture paragraph, which claimed this verb has no contract entry and that `restoreConsumedSuspension` appears in no contract — both false as of this change and of #16495 respectively. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
The assertion matched `the continuation must be` contiguously; the docblock wraps between `the` and `continuation`, so the pin was red for the wrapping rather than for the prose it exists to hold. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 135 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 2ae4128d7d6823a45254f2c9292f53e1f6d34507 && git checkout 2ae4128d7d6823a45254f2c9292f53e1f6d34507
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin abc4b83ce8b01d4e5af3560a0547421f6f5ca98f a6ced94691c3ea0e07a3c059e09200f0ed7c7e89 && git checkout -B drift-repro abc4b83ce8b01d4e5af3560a0547421f6f5ca98f && git merge --no-ff a6ced94691c3ea0e07a3c059e09200f0ed7c7e89
node scripts/docs-audit/affected-docs.mjs --json abc4b83ce8b01d4e5af3560a0547421f6f5ca98f
|
|
Seat adoption record — the verdict below is adopted VERBATIM. Tier verified from the transcript, ⛔ not from self-report. The reviewer's transcript carries 100 harness-stamped
Consequence for landing — the gate stays shut. The verdict is PASS WITH FINDINGS with two must-fix-before-landing items, both published prose. ⛔ ⭐ Worth naming: the reviewer caught that the round's own correction was false — the class comment was fixed against #16495 and not against #13953, so a wrong claim about the sibling verb was replaced with a different wrong claim, in JSDoc that ships. That is exactly the failure an adversarial reader exists to find, and it is why this gate is not a formality. Contract review (
|
…hed prose Both are prose the contract review measured as wrong; no shape, no semver level and no ruled decision moves. The class docblock said this verb has no REST route "exactly as `restoreConsumedSuspension` is reached". That verb does have a door — `POST /:name/runs/:runId/restore-suspension`, platform-operator gated, the #13953 services half — so the equivalence was false. The correction had fixed the paragraph against #16495 and not against #13953, replacing one wrong claim about the sibling with another. It now states the difference instead. The contract docblock said `decide` / `recall` / `sendBack` / `resubmit` "all guard on a `pending` request". Measured: `decide` and `sendBack` guard on `pending`, `resubmit` on `returned`, and `recall` on `pending` or the revise window. The argument is untouched — a `rejected` row is none of them — but the published text stated a guard the code does not have. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
Re-push for the two must-fix items — new head
|
| run | result |
|---|---|
pnpm --filter @objectstack/spec exec vitest run src/contracts/approval-service.test.ts |
exit 0 — 6 passed (the docblock pin, which the prose edit could have broken) |
pnpm --filter @objectstack/spec exec vitest run src/contracts/ |
exit 0 — 44 files, 411 tests |
pnpm --filter @objectstack/spec typecheck |
exit 0 — ledger held at 54 / 259 / 144 |
pnpm --filter @objectstack/plugin-approvals typecheck |
exit 0 — ledger held at 8 / 324 / 27 |
pnpm exec turbo run build --concurrency=2 |
VERDICT command-exit 0 — 74 successful, 74 total |
eslint . --no-inline-config |
exit 0 — 6591 files, 0 problems |
node scripts/check-spec-docblock-symbol-anchors.mjs |
exit 0 — 2867 anchors resolve (the gate that reads the {@link ...} anchors I edited) |
pnpm check:nul-bytes + a direct grep -naP control-byte scan of both edited files |
exit 0, no match |
plugin-approvals typecheck of this round answered exit 2 with TS2307: Cannot find module '@objectstack/spec/system' — this worktree was recreated after the round-1 cleanup, so no dist existed. That is a missing prerequisite, not a red; it is exit 0 after the closure build, and only that reading is reported.
Gates re-derived on the new head with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 85 commands, byte-identical family list to round 1. All 85 run, 85 exit 0. The same three answered a PREREQUISITE REFUSAL (exit 3) on the freshly-checked-out worktree — check:dual-build-cjs-loads, check:i18n, check:type-check-debt — and all three exit 0 after the full build; none is reported as a pass while it was a refusal. Reconciled with --ran carrying every exit code: "85 derived, 85 run, 0 NOT-MEASURED, 0 UNRUN", a DERIVED zero.
origin/main (f721ef0ff2) and one file it derives from changed in that range — scripts/check-closing-keyword-parity.mjs. I did not merge main (a re-push, not a redesign). Instead I ran origin/main's own copy of that script against this tree: exit 0 plain and exit 0 --self-test, verdict "3 parsers agree on all 9 keywords ... all registered". So the staleness did not change the answer for this diff. What remains unmeasured is the family list as it would derive from a tree AT origin/main.
Edits proved by STATE on the pushed ref, not by any exit code — git show origin/claude/issue-15389-approval-service-continue-restored:PATH: the false clause exactly as restoreConsumedSuspension is reached → 0 occurrences, with platform-operator door / restore-suspension present at :5104-5105; all guard on a pending request in the contract → 0 occurrences, with the measured phrasing present at :959-961. Local HEAD equals the pushed ref.
Carrier state — untouched, as ordered
Still draft, not enqueued, no auto-merge armed (auto_merge: null), no review submitted. needs:contract-review not touched and still present — read back from the API just now: ['documentation', 'size/m', 'tests', 'tooling', 'needs:contract-review']. documentation / tests / tooling were added by another actor between the two rounds; ⛔ not my writes and ⛔ not corrected by me, recorded only so the list is not read as mine. No second Claim:; the assignee was never written.
The findings explicitly fenced off — ①.6, ①.7, the implements presence/narrowing limit, and the future-door authorization posture — are not in this diff.
Generated by Claude Code
The same false sentence the contract carried — `decide` / `recall` / `sendBack` / `resubmit` "all guard on a `pending` request" — also stands in this file's own docblock, fifty lines above the paragraph this PR already edits. Measured at this head, unchanged: `decide` and `sendBack` guard on `pending`, `resubmit` on `returned`, and `recall` on `pending` or the revise window. Restated in the same words the contract now uses, so the two published copies of the claim agree instead of one being corrected and its twin left stale with nothing marking which is which. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
Increment under the extended licence — new head
|
| run | result |
|---|---|
the docblock pin (vitest run src/contracts/approval-service.test.ts) |
exit 0 — 6 passed |
pnpm --filter @objectstack/plugin-approvals typecheck |
exit 0 — ledger held at 8 / 324 / 27 |
pnpm exec turbo run build --concurrency=2 |
VERDICT command-exit 0 — 74 successful, 74 total |
eslint . --no-inline-config |
exit 0 — 6590 files, 0 problems |
node scripts/check-spec-docblock-symbol-anchors.mjs |
exit 0 |
check:nul-bytes + a direct grep -naP scan of the edited file |
exit 0, no match |
check-comment-mask-adoption / check-comment-mask-corpus |
exit 0 / exit 0 |
apps/docs/next-env.d.ts, a generated Next.js file that only exists once the docs app is built. This worktree was rebuilt from scratch and the run happened before the full build. Not related to the diff, and 0 problems either way.
Gates re-run in full, deliberately — a comment edit that adds two lines shifts every line below it, which is exactly what line-anchored census gates rot on, so this was not assumed inert. Re-derived on the new head: 85 commands, family list byte-identical to the previous two rounds. All 85 run, 85 exit 0. The line-anchored ones are called out by name because they were the risk: check-system-context-census 0 (and --self-test 0), check-tenant-audit-census 0 (and --self-test 0), check-platform-object-tenancy-census 0 (and --self-test 0), check:engine-double-contract 0 — no anchor rot, no ledger regeneration needed. The usual three answered a PREREQUISITE REFUSAL (exit 3) on the freshly checked-out worktree and all three exit 0 after the full build; none reported as a pass while it was a refusal. Reconciled with --ran carrying every exit code: "85 derived, 85 run, 0 NOT-MEASURED, 0 UNRUN", a DERIVED zero.
origin/main (ef180302f5) with four files it derives from changed in that range: check-closing-keyword-parity.mjs, check-durability-degradation-log-level.mjs, measure-durability-swallow-family.mjs, pm/check-half-states.mjs. I did not merge main. I ran origin/main's own copy of all four against this tree: exit 0 on every one. So the staleness did not change the answer for this diff. Still unmeasured: the family list as it would derive from a tree AT origin/main.
Fence and carrier
⛔ Still fenced and untouched: ①.6 (the "rewrite the request row" over-claim), ①.7 (the missingRunCapability path), the implements presence/narrowing limit, the future-door authorization posture, and the plugin-approvals CHANGELOG (0 files under any CHANGELOG in the whole PR diff). The PR's file set is unchanged at four.
⛔ Carrier untouched: still draft, not enqueued, auto_merge: null, no review submitted, no second Claim:, assignee never written. needs:contract-review not touched and read back still present: ['documentation', 'size/m', 'tests', 'tooling', 'needs:contract-review'] — the first, third and fourth are another actor's writes, recorded so the list is not read as mine.
Generated by Claude Code
|
Seat adoption record — the narrow re-read below is adopted VERBATIM. Tier verified from the transcript, ⛔ not self-report: 49 harness-stamped ⭐ Two things in this verdict are worth reading even by someone who does not care about this PR. One: the round closed a string and left the claim open. Its "only remaining occurrence" was true — Two: the reviewer declared its own miss. The changeset's prose was in the diff it judged in round 1, and it did not read it. It says so plainly and puts the consequence in its own must-fix list rather than filing it as someone else's problem. ⛔ It also corrected its own earlier reasoning in this PR's favour — the round's "line-anchored gates could rot" rationale is measurably wrong (none of the four named gates is line-anchored), so the full 85-gate re-run was harmless but the rationale should not be cited again. A reviewer that only ever finds fault in the other direction is not measuring. On the seat's own extended licence, judged rather than inherited, as commissioned: the call was right in principle and the edit at Consequence: the gate stays shut. Three must-fixes, all published prose. ⛔ Seat ruling on the one item the verdict left to this seat — Contract review — narrow re-read (
|
|
Correction to this seat's own disposal, one comment later. In adopting the re-read I wrote that the test-comment copies — Measured first, on Then the class test, which is what I skipped. The three fileable classes are (a) a reproducible defect, (b) a breach of a declared contract, (c) a metadata trap. A comment inside a ⇒ Disposal: recorded here, 承接者:无 — no queued PR and no person is due to touch that file, so the acceptance-note fallback does not hold either, and the rule for that case is to record and close the item rather than manufacture a card for it. ⭐ Why the correction is worth making out loud rather than quietly dropping. Tonight this seat promoted two out-of-scope findings into cards — #17574 and #17578 — over their rounds' objections, on the ground that both were published prose contradicted by measurable behaviour. That argument only stays honest if it also decides the other way when the prose is not published. Two test comments and a changelog line are exactly that case. ⛔ Filing them anyway would have been consistency with my own last sentence instead of with the rule.
Generated by Claude Code |
…very spelling Four published-prose sites, no shape or level change. The sharpened tail — "the row is terminal, none of those" — was itself false, on a path this verb implements: `resolveRecordedContinuation` reconstructs a `returned` row, and this file's own comment says why (a resubmit writes no status, so the row still reads `returned` when its resume is the one that stranded). A stranded send-back leaves the row in exactly the state the sentence had just said `resubmit` guards for. Both copies now name that exception instead of denying it; the guard mapping is untouched, being right. Two further spellings of the same claim went with it: "all four `pending` guards", fourteen lines below the corrected bullet inside the same shipped docblock, and `journalStrandedContinuation`'s "every one of them guards on a `pending` request". The changeset carried the claim too, and it is this PR's input to release notes, so the falsity would have shipped there. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
Four prose fixes for the narrow re-read — new head
|
| spelling | count | control (its replacement) | count |
|---|---|---|---|
row is terminal, none of those |
0 | Not because the row is never `returned` |
2 |
all four `pending` guards |
0 | all four (now "all four status guards") |
1 |
every one of them guards on a `pending` request |
0 | guards on a live status |
1 |
every front door guards on a `pending` request |
0 | guards on a live request |
1 |
Every control lights, so each zero is a reading. Local HEAD equals the pushed ref.
Verification at a6ced94691
| run | result |
|---|---|
| the docblock pin | exit 0 — 6 passed |
pnpm --filter @objectstack/plugin-approvals typecheck |
exit 0 — ledger held at 8 / 324 / 27 |
pnpm --filter @objectstack/spec typecheck |
exit 0 — ledger held at 54 / 259 / 144 |
pnpm exec turbo run build --concurrency=2 |
VERDICT command-exit 0 — 74 successful, 74 total |
eslint . --no-inline-config |
exit 0 — 6591 files, 0 problems |
changeset family: check-changeset-fixed, check-empty-changeset --base origin/main, check-changeset-no-major --base origin/main, check:objectui-changeset |
exit 0 / 0 / 0 / 0 |
check:doc-authoring, check-spec-docblock-symbol-anchors, check:nul-bytes |
exit 0 / 0 / 0 |
Gates: re-derived on the new head, 85 commands, family list byte-identical to the previous three rounds; all 85 run, 85 exit 0. The usual three answered a PREREQUISITE REFUSAL (exit 3) on the freshly checked-out worktree and all three exit 0 after the full build; ⛔ none reported as a pass while it was a refusal. Reconciled with --ran carrying every exit code: "85 derived, 85 run, 0 NOT-MEASURED, 0 UNRUN", a DERIVED zero.
⛔ The "anchor rot" rationale is retracted and not repeated. The reviewer's correction is accepted: none of those gates is line-anchored — check-system-context-census.mjs:39-41 says a line number is not an anchor form anywhere in this repo any more, the two tenancy censuses declare themselves invariant under displacement, and engine-double-contract.pinned.json has 0 rows naming this file. The honest reason for re-running the union is narrower and sufficient: a gate claim has to name the head it was measured on, and the head moved.
origin/main (abc4b83ce8), four derivation-input scripts changed in that range. I did not merge main; I ran origin/main's own copies of all four against this tree — exit 0 on every one. Residual: the merged tree, which the queue's own run covers.
Carrier
⛔ Unchanged: still draft, not enqueued, auto_merge: null, no review submitted, no second Claim:, assignee never written. needs:contract-review not touched and read back still present: ['documentation', 'size/m', 'tests', 'tooling', 'needs:contract-review'].
Generated by Claude Code
|
Seat adoption record — adopted VERBATIM. ⭐ Tier verified from the transcript, ⛔ not self-report: 48 harness-stamped ⭐ The reviewer was asked to attack the round's own instrument, and it broke it — while confirming the result. The round built a proposition grep out of this reviewer's earlier lesson (a control that lights proves the grep ran, not that the string was the claim) and offered it as self-proving: run before editing, it returned three spellings the round had never touched. The reviewer re-ran it at all three heads and found the claim overstated, by state: the plugin twin at ⭐ And then it did the work the instrument could not. The pattern also requires the literal token guard, so ⇒ Both halves of tonight's recurring lesson now have their counter-example on the record: a cleared control proves every arm resolved, never that it resolved correctly (#15423) and a lit control proves the grep ran, never that the pattern covers the claim (here). Also confirmed: the round's choice to name the Landing — all four pre-checks read on this head
⇒ Disposition of the two cards the verdict names, applying the test in both directions rather than filing the list:
Contract review — final re-read (
|
Fixes #15389
Clause-②: yes
Declares the operator repair verb
continueRestoredRunon the spec'sIApprovalServicecontract, as an optional member. This is the contract half of the card; PR #15966 landed the class member onplugin-approvalsasPart ofand is not re-implemented here.The ruling this executes
Maintainer, 2026-09-09, verbatim 「#106 同意」 on decision batch #106 whose item 3 was A:
B (class-only) and C (a new REST/CLI door) were refused explicitly. Neither is re-opened here: no route is added, and the shape is copied rather than invented.
What is in the diff
packages/spec/src/contracts/approval-service.ts— the optional member, its structural result declared inline, and a docblock following the [spec] DeclarecancelRunandrestoreConsumedSuspensiononIAutomationService— #13953's ruled contract half, step (1), never filed #16495 pair: what it replays and what it deliberately does not, why it takes noExecutionContext, that no door is declared, and the optionality rule ending in the promising-nothing note.packages/spec/src/contracts/approval-service.test.ts— the conformance pin, shaped as the sibling ofautomation-service.test.ts's[#16495]block: exported type-level identity aliases for the parameters and the result, the optionality pin, a signature-carry pin, a@ts-expect-errorpin for the requiredsource, and a docblock-discipline pin.packages/plugins/plugin-approvals/src/approval-service.ts— comment only. Its posture paragraph claimed this verb has "no entry in the specApprovalServicecontract" and thatrestoreConsumedSuspension"appears in no contract". The first becomes false with this change; the second became false with [spec] DeclarecancelRunandrestoreConsumedSuspensiononIAutomationService— #13953's ruled contract half, step (1), never filed #16495. Corrected to state the ruled posture instead: both verbs are declared, optional, and a caller reaching either through the contract must probe for presence and refuse fail-closed..changeset/spec-approval-continue-restored-contract.md—@objectstack/spec: minor.Two decisions inside the ruled shape, stated rather than buried
The result is declared inline, not exported.
restoreConsumedSuspensiondoes the same (#16495 route (i)): the implementation's namedApprovalContinuationResultstays withplugin-approvals.packages/spec/api-surface/contracts.jsonis unchanged as a result — the published export roster does not widen, only the interface member does.sourceIS enumerated here, unlikerestoreConsumedSuspension'srefusal, and the docblock says why.refusalis the engine's own open vocabulary, which the contract would have to keep in step with.sourceis a closed, binary property of the replay itself (journal= the literal signal re-issued,reconstructed= rebuilt from the recorded outcome) and it is the caller's branch point: a caller that cannot tell an exact replay from an inferred one cannot say what it trusts. A third provenance would be a spec card.The ruling's conditional pin — measured, not assumed
The ruling named
scripts/engine-double-contract.pinned.jsonif that file governs the double. It does not. That ledger is keyed by test-file path and pins fake ObjectQL engine write-verb dispatches (delete/update); it lists nopackages/specfile at all and no method names. The new pin declares no engine double. Measured rather than read:pnpm check:engine-double-contractexits 0 with the ledger RETAINED at 778 rows, unchanged.Changeset, confirmed against the published roster
The ruling measured
@objectstack/spec: minor; this round confirms it and agrees.@objectstack/spec'sfiles[]shipsdist, and the member reaches the published declaration —grep -c continueRestoredRun packages/spec/dist/contracts/index.d.ts= 1 after the build (0 before), withrestoreConsumedSuspensionas the lit control in the same file. Additive and optional, sominor, notmajor: the only implementer still conforms untouched.Verification
Every exit code captured right after a single redirected command, never through a pipe; heavy runs serialized through
scripts/pm/os-verify-lock.sh(slotdev-15389) and read from its ownVERDICT command-exitline.pnpm --filter @objectstack/spec testpnpm --filter @objectstack/spec typecheckcheck:test-typecheck: OK, ledger held at 54 files / 259 errors / 144 pinned signatures, nothing addedpnpm --filter @objectstack/plugin-approvals typecheckimplements IApprovalServiceconformancepnpm --filter @objectstack/plugin-approvals testpnpm exec turbo run build --concurrency=2eslint . --no-inline-configGates. Derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackfrom the actual change set, never hand-built: 85 commands, all 85 run, 85 exit 0. Three first answered a PREREQUISITE REFUSAL on an unbuilt workspace (check:dual-build-cjs-loads,check:i18n,check:type-check-debt, all exit 3, which the scripts themselves spell "NOT a pass: nothing was measured") and all three exit 0 after the full build; none is reported as a pass while it was a refusal. Reconciled with--rancarrying every exit code: "85 derived, 85 run, 0 NOT-MEASURED, 0 UNRUN", a DERIVED zero rather than a claimed one. The six artifact-roster families whose roster directory contains one of my paths — where the derivation says silence is evidence in neither direction — were read rather than assumed: all six exit 0.Phantom-check guard. The
@ts-expect-errorpin is only a pin if a program compiles the file.tsc -p packages/spec/tsconfig.test.json --listFilesnamescontracts/approval-service.test.ts, withcontracts/automation-service.test.ts(the #16495 pin) as the lit control in the same output.Ablation. Direction predicted before each leg. Each anchor asserted to occur exactly once as an exact substring before anything was written; each mutation proved on disk by a
git hash-objectdelta plus marker counts (anchor 0, injected 1), never inferred from an editor's exit code; each restored bygit checkout HEAD -- ABSOLUTE_PATHundertrap ... EXIT INT TERM, proved by blob equality against HEAD and an emptygit diff HEAD, with an empty hash treated as failure. All legs ran against the committed tree, so the restore leg points at an implementation HEAD actually carries.sourcefrom the contract (predicted red).check:test-typecheckexit 1: "1 problem(s) · 4 type error(s)" in the pin file — the identity alias asTS2344: Type 'false' does not satisfy the constraint 'true', andTS2578: Unused '@ts-expect-error' directive, which is the direct proof that the@ts-expect-erroris live. Restored, blob back at HEAD.source: 'journal'(predicted red; this is the conformance link, andplugin-approvalsresolves@objectstack/specthroughexportstodist/, so the leg REBUILDS). Build VERDICT command-exit 0,ablation-dist-preflight @objectstack/specconfirms the mutation reached the built artifact, thentsc --noEmitonplugin-approvalsexit 1:error TS2416: Property 'continueRestoredRun' in type 'ApprovalService' is not assignable to the same property in base type 'IApprovalService'. The restore leg rebuilt too, and the preflight--absentconfirms the mutation is gone from all 216 built files while the real union is present again in 2.Green baseline re-run after the last leg: 6 passed, tree clean (
git status --porcelain0 lines,git diff HEAD0 lines).Not measured
验收备注
Nothing was filed. One boundary observation, recorded and not filed:
packages/plugins/plugin-approvals/CHANGELOG.mdcarries the same "no entry in the specApprovalServicecontract" sentence, now superseded. It is left alone deliberately — a CHANGELOG entry is the record of what a released version did and was true when written; rewriting it would rewrite history rather than correct prose. 承接者: the next release-notes pass, which reads changesets rather than that line.Generated by Claude Code