fix(approvals): give a restored approval suspension an issuer, so it can be decided and not only cancelled - #15966
Conversation
…can be decided and not only cancelled `AutomationEngine.restoreConsumedSuspension` re-arms the pause of a run that stranded mid-resume and tells the operator to re-issue the continuation. For an `approval` suspension nobody could. Every approvals door that stamps the resume marker — `decide`, `recall`, `sendBack`, `resubmit` — guards on a `pending` request, and the row is terminal, written by the very call that stranded the run; the generic engine door refuses an `approval` pause outright because that node declares `resumeAuthority: 'service'`. The only verb left was `cancelRun`, which discards the branch's downstream work, so the advertised repair produced a run that looked resumable and was not decidable. Measured against the real engine and the real decision door: the restored suspension lacks nothing. A `resumeAuthority`-marked resume walks the restored pause to completion. What was missing is an ISSUER on the approvals side, which is what this adds — `ApprovalService.continueRestoredRun(requestId, options?)`. The failing door now journals the signal it was carrying, on the repairable exit only (the engine's own `status: 'stranded'` discriminator, the one exit that journals a repair snapshot), under `__strandedContinuation` in the request's `node_config_json` — beside the `__decisionOutputs` side-channel already there, so no column and no authored config moves. It is awaited but can never replace the `RESUME_FAILED` throw the decision's caller is owed. Runs stranded before this shipped are served too: with no journal, the signal is rebuilt from the recorded outcome, which is exact for each accepted status because each is written by exactly one door (`approved` → `approve`, `rejected` → `reject`, `returned` → `revise`, `recalled` → `reject`). The one shape it cannot rebuild — a `rejected` row also carrying a `revise` action, possibly ADR-0044's revision-limit auto-rejection whose resume carried `autoRejected` — is refused rather than guessed. The verb replays a decision and rewrites nothing: all four `pending` guards are untouched, no status, mirror field or audit row is written, `resumeAuthority` stays as it is, and `ApprovalDecisionResult` is not touched. Like the engine verb it completes, it is an in-process operator repair with no REST route and no entry in the spec `ApprovalService` contract. Part of #15389 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…e fake engine's insert arity The raw `resumeAuthority`-marked resume against a restored suspension is the measurement that places this card's fix on the approvals side rather than in `engine.ts`, so it is pinned in its own right and not only through the new verb. Driven on a second stranded-and-restored run so the cancel measurement above it is undisturbed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…proval-restore-continuation
… write site and shifted lines Each repair is the one the failing gate itself prescribes, and each grows or re-anchors a ledger rather than loosening one. A base control at the merge-base (924f0fe) confirms all three are caused by this branch and were green before it. - `engine-double-contract.pinned.json` learns the two doubles (delete, update) in the new pin file. +10 lines, nothing removed, no baseline lowered: 791 pinned / 133 debt / 3 exempt, up from 789 at base. - `content/docs/permissions/system-context.mdx` re-anchored by the census's own `--fix`: inserting methods into `approval-service.ts` shifted eight cited line numbers. Pure line rot, no elevation behaviour added or removed — this branch introduces no `context.isSystem` read. - The tenant-audit census re-measured: `journalStrandedContinuation` adds one write call site, so 221 -> 222, 147 -> 148 decidable, 103 -> 104 decidably elevated. `--write` regenerates the tables; the eight prose figures the gate names as hand-written are corrected to match, each verified against the regenerated table rather than arithmetic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…proval-restore-continuation
…tree Merging origin/main auto-merged content/docs/permissions/system-context.mdx textually clean and semantically stale: the census is green at origin/main (904e707, measured) and green on this branch before the merge, but red on the merge product with 26 problems, every one an anchor into plugin-security or rest — files this branch never touches and the merge brought in. Repaired with the census's own --fix, which re-anchors against the merged code. 15 anchors rewritten; the result reports 105 elevation read sites across 44 files and 140 resolving anchors, identical to what origin/main reports, so this restores the upstream reading rather than inventing one. No elevation behaviour is added or removed anywhere on this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 5 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 fe4ba8a085967de8b5a99e52713f390c20e7d492 && git checkout fe4ba8a085967de8b5a99e52713f390c20e7d492
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f7db8f4fd268a86a08c62ae4894cf7417720f8c9 bc0872bf0caba66bd6ae119d62c5d11316ddb2a2 && git checkout -B drift-repro f7db8f4fd268a86a08c62ae4894cf7417720f8c9 && git merge --no-ff bc0872bf0caba66bd6ae119d62c5d11316ddb2a2
node scripts/docs-audit/affected-docs.mjs --json f7db8f4fd268a86a08c62ae4894cf7417720f8c9
|
Clause-② contract review —
|
PM — held. Rework dispatched, the
|
…proval-restore-continuation # Conflicts: # scripts/engine-double-contract.pinned.json
…ause, and refuse the rebuilds that cannot be proved
Two defects found by contract review of the first revision, both measured at the
real door, and both making the repair verb advance a flow with no decision behind
it - strictly worse than the dead end it exists to open.
GUARDS. `hasSuspendedRun(runId)` answers a boolean and cannot tell a re-armed
pause from any other live pause on the same run. Four shapes were measured
advancing something they should not: a terminal request driving a LATER approval
node's pending pause with no decision recorded on it; a plain RETRY of the verb
doing the same after the run re-suspended; a next-round `returned` row orphaning
round two; and a `recalled` request resurrected into a new pending round from a
pause it never gated. The retry is the one that settles the severity: repeating a
repair verb is the ordinary shape of using it, not a hostile construction.
Three guards now stand, each with its own reverse-control pin: `assertLatestForRun`
(already in this file, used by recall and resubmit for the same reason) refuses a
superseded row; the strict `hasSuspendedRun` refuses a run with no pause and
throws rather than read a store outage as "not suspended"; and a node-identity
check refuses a pause parked anywhere but this request's own node. The last is
fail-closed in every direction - no reader, no listing, no entry, or an entry at
another node all refuse - because a false refusal costs a retry while a false
admission advances a step nobody decided.
It needs NO new automation-engine surface. `listSuspendedRunsDurable` is already
public on AutomationEngine and already names the parked node; the approvals-side
resume interface simply declares the method it already has.
REBUILD. "Each status is written by exactly one door" was false, and it was the
load-bearing premise of the no-journal path that serves runs stranded before this
ships. `returned` has one writer but two issuers, so a stranded resubmit was
rebuilt as a send-back and walked the `revise` edge with `{decision:'revise'}`
where `{resubmitted:true}` was owed - proceeding, rather than failing loudly,
through the engine's unmatched-label fallback. It is now discriminated by the
`resubmit` action row, whose sole writer in this file is `resubmit`. `recalled`
has two writers across three behaviours, two issuing no continuation at all, and
nothing on the row tells them apart - so it is REFUSED on the rebuild path, with
a message naming what an operator can do instead. Both stay journal-recoverable.
HARNESS. The test double ignored `orderBy`, so it returned the OLDEST row where
`assertLatestForRun` asks for the newest - the guard passed on every input and a
pin naming it would have measured nothing. The double now honours it, and guard 1
is additionally driven in isolation on a row whose `created_at` is controlled, so
the ordering is what is under test rather than the clock's resolution.
PIN 3 previously asserted `decide` alone while claiming all four `pending` guards;
it now asserts all four by name.
The system-context census is regenerated from the merged tree with
`pnpm gen:system-context-census` (9 anchors), discharging the deferral the merge
commit took; it reports the same 105 sites / 44 files / 140 anchors as before.
Part of #15389
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Part of #15389— deliberately notFixes.Revision 2 answers the clause-② review's two blocking findings (verdict). Both were real, both were measured at the real door, and both made this verb advance a flow with no decision behind it — strictly worse than the dead end it exists to open. The review's own reproductions are now reverse-control pins. Head
bc0872bf0.B1 — the pause guard checked existence, not identity
hasSuspendedRun(runId)answers a boolean. A run outlives any one request — later rounds and later approval nodes each mint their own — so a terminal row's continuation could be issued against whatever pause the run happened to be sitting on. Four shapes were measured advancing something they should not, and ⭐ the decisive one is a plain retry of the verb, which is the ordinary shape of using an operator tool, not a hostile construction.Three guards now stand, each with its own reverse-control pin and its own mutation leg:
assertLatestForRun(raw)— refuses a superseded row. Already in this file;recall-in-revise-window andresubmituse it for the same reason.hasSuspendedRun— a pause exists at all. Strict: an unreadable store throws rather than reading as "not suspended".Guard 3 is fail-closed in every direction: no reader, no listing, no matching entry, or an entry at another node all refuse. A false refusal costs the operator a retry; a false admission advances a step nobody decided.
⭐ It needs no new automation-engine surface.
AutomationEngine.listSuspendedRunsDurable()is already public and already names the parked node; the approvals-side resume interface simply declares the method the engine already has. Nothing inengine.tsis touched by this PR.B2 — "each status is written by exactly one door" was false
That was the load-bearing premise of the no-journal rebuild — the path serving runs stranded before this ships, which is the population the card names. A status is not the same thing as a continuation:
approvedapproverejectedreject, or REFUSEDreviseaction row means ADR-0044's auto-reject arm is possiblereturnedresubmitorreviseresubmitaction row, whose sole writer in this file isresubmitrecalledA stranded resubmit was previously rebuilt as a send-back and walked the
reviseedge carrying{decision:'revise'}where{resubmitted:true}was owed — and it proceeded rather than failing loudly, through the engine's unmatched-label fallback (#4414). Forrecalled, two of the three behaviours have no branch that should be walked, so any rebuild is a guess whose failure mode is re-opening a request somebody deliberately withdrew. It is refused, with a message naming what the operator can do instead. Both stay journal-recoverable — the refusals are the pre-journal population only.Harness defect found while pinning this
The test double ignored
orderBy, so it returned the oldest row whereassertLatestForRunasks for the newest — the guard passed on every input, and a pin naming it would have measured nothing. The double now honours it, and guard 1 is additionally driven in isolation on a row whosecreated_atis controlled, so the ordering is what is under test rather than the clock's resolution. This is why PIN 6 asserts the two-step shape's refusal as either identity guard and pins guard 1 separately: the natural shape is caught by both, and pretending to isolate would have been the same phantom-check class.Every pin, and its population
rejectstrand: restore succeeds,decide/recall/ generic-resume all refuse,cancelRunis the only verb left — plus the rawRESUME_AUTHORITY_SERVICEresume completing, on a second run, which is the mechanismreject, journal path: continuation completes, branch runs, runcompletedreject: row deep-unchanged, onerejectaction row, and all fourpending-guarded doors (decide,recall,sendBack,resubmit) asserted by namepending, unknown requestreject, rebuild path with the journal removed; and the ADR-0044 auto-reject ambiguity refusedcreated_atrecalledrow parked at the revise window; and a plain retryreturned+resubmitrow rebuilds asresubmit; without it asrevise(reverse control);recalledrefuseddecidealone while its prose claimed all four guards — a pin cited for a wider population than its cases. Corrected, and every pin above now states its population.Verification
Every exit code captured right after a single redirected command; heavy runs serialized through
os-verify-lock.shand read from its VERDICT line, never a bare$?.Suite
pnpm --filter @objectstack/plugin-approvals test— 40 files / 683 tests passed, exit 0, re-run on the final commit after a full workspace build (turbo 71/71 successful).typecheckexit 0; test-typecheck debt ledger unchanged at 8 files / 324 errors / 27 pinned signatures.Seven mutation legs, each anchor asserted to occur exactly once as a substring in the form written (a per-line
grep -cwould have miscounted the multi-line anchor and was replaced by an exact substring count), mutation proved by agit hash-objectdelta plus marker count, restore undertrap ... EXIT INT TERMproved by an emptygit diff HEADand the blob back at HEAD's8514677b:assertLatestForRuncall removedreturnedresubmit discriminator removedrecalledrefusal removedGates. Family re-derived from the actual changed files with
dispatch-gates.mjs --repo objectstack-ai/objectstack; the merge and the regenerated docs widened it, so this round runs 137 gates against revision 1's 73. 134 exit 0. Five first answered a prerequisite refusal on an unbuilt workspace —dual-build-cjs-loads,i18n,published-readme-exports,type-check-debt(exit 3) andskill-examples(exit 1, which declines to compute a verdict against a stale build rather than returning a false green) — and all five exit 0 after building the prerequisite and re-running; ⛔ none was reported as a pass while it was a refusal.check-adr-0087-registration --base origin/main --head bc0872bf0exit 0 with--self-testexit 0 as its control.check:nul-bytesexit 0.Corrections the review asked for: the pinned-ledger figures were stale — re-measured from the gate's own verdict line in a base-control worktree at the current merge-base
1375344b6: 793 pinned at base, 795 at head, +2 rows for this PR's one new pin file, DEBT unchanged at 133, nothing removed. Andcheck:tenant-audit-censusis not an npm script — it is run here asnode scripts/check-tenant-audit-census.mjs, which is how CI runs it (exit 0, 222 sites, 23 prose figures held).origin/mainwas merged, never rebased, never force-pushed. The merge conflicted in the generatedengine-double-contract.pinned.json; ⛔ it was not hand-merged — upstream's side was taken and the ledger re-derived with the gate's own--write. The pre-commitos-regenguard then refused the commit becausecontent/docs/permissions/system-context.mdxwas stale on the merge product; it was regenerated withpnpm gen:system-context-census(9 anchors) rather than hand-edited, and the deferral the merge commit took is discharged. The census reports the same 105 sites / 44 files / 140 anchors as before.Still NOT MEASURED, unchanged and independently confirmed by the reviewer:
check:single-claim-pathsneeds aGITHUB_TOKENthis shell does not hold (GitHub API 401). Pre-existing, not attributable:check:react-declaration-parity, re-confirmed red at this merge-base with the sameMANIFEST is not setreason.🤖 Generated with Claude Code
https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y