fix(approvals): recall tells its caller WHICH resume failure stranded the run - #17988
Conversation
… the run `recall` resumes directly rather than through `resumeRecordedOutcome`, so the `resumeCode` / `resumeStatus` the engine already stamps were dropped one line before the result was built: the caller got `resumed: false` plus prose and no way to learn the run is repairable. Fill the already-declared `ApprovalRecallResult.resumeFailure` slot, on the two exits whose honest code is `RESUME_FAILED` — this door's own strand, and a resume that succeeded while the subflow parent above it stranded. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
…h controls The card's own table row for row (PIN 1), a healthy recall so the absence in PIN 2 is a reading and not the fixture, the non-stranded exit that deliberately reports no code (PIN 3), and the identical strand through `decide` as the control that proves the harness discriminates the two doors. The #15556 subflow fixture gains the recall case its spec docblock already declared. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
…dger `check:engine-double-contract` names its own remedy: a new test file that pins its double to ObjectQL's dispatch predicates is new coverage the ledger has to learn about, or it never protects that file. Regenerated with `--write`; 0 seam rows added or grown, 0 lost. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
`tsconfig.test.json` covers this file, so the two `filter` callbacks were TS7006 implicit-any. Give `recordingLogger` a declared line shape instead of annotating the parameters `any` — the debt ledger is shrink-only and a new entry is a maintainer's call, not a way to get green. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 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 — 6 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 96da2f18aaf746519ef18d1085f427c7a84bffb3 && git checkout 96da2f18aaf746519ef18d1085f427c7a84bffb3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eb9334915045aa197564bcf25d6dc3eff6746efe 76ee9926e554c84f15ce1638296973ea5b487773 && git checkout -B drift-repro eb9334915045aa197564bcf25d6dc3eff6746efe && git merge --no-ff 76ee9926e554c84f15ce1638296973ea5b487773
node scripts/docs-audit/affected-docs.mjs --json eb9334915045aa197564bcf25d6dc3eff6746efe
|
Contract reviewHead reviewed: Implemented-by: ① Clause-② — re-derived in-seat, ⛔ not inherited and ⛔ not accepted from the report
⇒ Clause-②: ⭐ The delivery's framing of why is the part worth keeping: #17908 was ② 🔴 The trap this dispatch existed to prevent — navigated, ⛔ not stumbled intoThe dispatch warned that the ADR-0112 ledger row admits exactly one code: #17924's cost-direction ablation measured that stamping Measured in-seat on the delivered diff:
⭐ And the judgement behind that is the best call of the round. The ✅ Provenance guard, verbatim on the final tree: ③ The ablation predicted its direction and got itLEG 0 clean (on-disk blob ⭐ Exactly the two new assertions failed and all four controls stayed green — a predicted direction, met. And the stale- ④ Gates, and the exit-3 discipline held three times71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN, reconciled mechanically with
⭐ The test-typecheck debt ledger is unchanged (8 files / 324 errors / 27 signatures): the new file's two initial ⭐ Serial relay: this PR moves ⭐ It corrected the card it was sent to implement#15970's own table says Verdict: PASS at
|
Fixes #15970
Clause-②: no
recallresumes its run DIRECTLY rather than throughresumeRecordedOutcome, so theresumeCode/resumeStatusthe engine already stamps on the error were discarded one line before the result was built. The caller gotresumed: falseplus one sentence of prose and no way to learn the run is repairable — while the identical strand reached throughdecidehas carried{ finalized, decision, runId, repairable: true }since #13807. The difference was the door, not the strand.This fills
ApprovalRecallResult.resumeFailure, a slot@objectstack/specalready declared (contracts/approval-service.ts) and left without a producer on this door. Zeropackages/specfiles touched.The card's table, reproduced BEFORE the change
Real
AutomationEngine+InMemorySuspendedRunStore+ realApprovalService+ realregisterApprovalNode, one live process, in-memory ObjectQL double for the approvals tables only.deal_approvalparks at anapprovalnode;mark_rejectedthrows the card's own text. Verbatim from the probe's stdout, worktree atc8a006fc4:result.resumedfalseresult.resumeError"resume of run 'run_a2cb9106…' failed: update_record(crm_leave_request) failed: Record 9SEmlyRfw8D9-J7Z not found"result.resumeFailureundefinedresult.request.status"recalled"strandedDecisionDetails(result)undefinedengine.hasSuspendedRun(runId)false'resumeFailure' in resultfalseengine.restoreConsumedSuspension(runId){"restored":true,"reason":"… the run is resumable again; re-issue the continuation"}marks(downstream work)[]CONTROL in the SAME run — the identical strand through
decide:"RESUME_FAILED: the reject decision was recorded on request areq_7b2f3a61…, but its flow run 'run_f4601c41…' could not be resumed and is now stranded: …"strandedDecisionDetails(err){"finalized":true,"decision":"reject","runId":"run_f4601c41…","repairable":true}engine.hasSuspendedRun(runId)false⭐ One card row is corrected by measurement and stays corrected:
strandedDecisionDetails(result)isundefined, notnull— and that is not something this PR could have fixed. It is the ERROR-envelope reader for a door that THROWS; against a recall RESULT object it can only ever answerundefined, whatever the door does. The ruling put the success-answer carrier onresumeFailurefor exactly that reason (the spec's own "⛔ NotStrandedDecisionDetails" note).The same table AFTER, same harness, same lever
result.resumedfalseresult.resumeError"resume of run 'run_d0bad676…' failed: update_record(crm_leave_request) failed: Record 9SEmlyRfw8D9-J7Z not found"result.resumeFailure{"code":"RESUME_FAILED","runId":"run_d0bad676…","status":"stranded","repairable":true}result.request.status"recalled"strandedDecisionDetails(result)undefined(see above — correct, not unfixed)engine.hasSuspendedRun(runId)false'resumeFailure' in resulttrueengine.restoreConsumedSuspension(runId){"restored":true,…}— the repairrepairable: truepromises, cashed in the pinmarks(downstream work)[]The
decidecontrol is byte-identical in the after run, which is the point: the harness discriminates the two doors rather than passing vacuously.What changed
recall's non-revise-window arm, and nothing else:catchnow hoistsPOST /api/v1/approvals/requests/{id}/rejectreturns 500 while its effect lands AND strands the workflow run — three inconsistent outcomes from one call #13807's own derivation —const status = resumeStatusOf(err); const repairable = status === 'stranded'— instead of testing it inline, and on the repairable exit stampsresumeFailure: { code: 'RESUME_FAILED', runId, status: 'stranded', repairable: true }beside the existingjournalStrandedContinuationstash;serviceResumealready returned to it and discarded. fix(automation): tell approval decide() callers when a subflow parent strands #17908 landedtakeSubflowParentStrandand wired it intoresumeRecordedOutcome(which servesdecide/sendBack/resubmit) and said in its own body that this door "would trivially gain the same fix … but that fold-in belongs to approvals: arecallwhose resume strands reports it as an ordinary non-failure — norepairablediscriminator, where the identical strand throughdecidecarries one #15970". The spec declares this door's answer for that shape already —ApprovalRecallResult.resumed: "a resume that completed and then stranded a run further up (a subflow's parent, service-automation: a subflow'sbubbleToParentfailure is swallowed, so an approval decision answers 200resumed: truewhile the run behind it is stranded — #13807's three-outcome shape, one level up #15556) still answerstrue, with the strand told onresumeFailure" — so leaving it out would have left a declared contract with no producer;resumeFailureconditionally, omitted rather thanundefined, the same spellingdecideuses, so'resumeFailure' in resultreads presence correctly.Fences, each one measured rather than assumed
recallfail would be the wrong fix, not a stricter one.RESUME_FAILEDand only that code. PIN 1 asserts the literal. Two exits report; every other exit deliberately reports NOTHING, and PIN 3 pins that: a lost run's honest code isRESUME_TARGET_LOSTand the tolerated duplicate's isRESUME_IN_PROGRESS, and this package's ledger row admits neither — its own comment spells out that this package stamps neither in a scanned position. StampingRESUME_FAILEDthere would make the discriminator lie about WHICH failure it was, which is the defect being fixed, one field over. Absence is declared legitimate by the member's docblock ("An absent member means no report was made, never that no run is stranded").packages/specfence and nothing needed to. The honest code for both exits this PR fills isRESUME_FAILED, which the ledger already admits. See Acceptance notes for the one adjacent observation.errorlevel, same three context keys, asserted in PIN 1. The ruling left logging alone; the report is a sibling of that line, not a replacement.content/docs/releases/**untouched.packages/specuntouched (0 files).Tests
New
packages/plugins/plugin-approvals/src/recall-strand-discriminator.test.ts— PIN 1 (the card's table row for row), PIN 2 (a healthy recall, so PIN 1's presence is a reading and not the fixture), PIN 3 (the non-stranded exit that reports no code, with its reason), CONTROL (the identical strand throughdecide).subflow-hosted-approval-strand.test.tsgains the recall case for the #15556 shape.Ablation — the pins go red on the defect. Mutation = restoring the pre-fix production file from
c8a006fc4, hash-verified on disk both ways, under atrap … EXIT INT TERMwith an absolute repo root.Predicted direction was RED and RED is what it did; exactly the two new
resumeFailureassertions failed and the four controls stayed green.node scripts/ablation-dist-preflight.mjs @objectstack/plugin-approvals '…' --absentreportedno dist/ in this package, which is the correct reading here rather than a void run: both test files import the mutated module RELATIVELY (./approval-service.js), so vitest resolves it fromsrcand nodistsits on its resolution path. The hazard that script exists to stop is a false GREEN from a stale build; this ablation's observed colour is RED, which a stale build cannot produce.Commands, all under the shared verify lock,
VERDICT command-exitread rather than a bare$?:pnpm --workspace-concurrency=2 --filter '@objectstack/plugin-approvals^...' build— exit 0 (dependency closure, built before any reading).pnpm --filter @objectstack/plugin-approvals test— exit 0, 45 files / 738 tests pass (was 44 / 733 onmain).pnpm --filter @objectstack/plugin-approvals typecheck— exit 0;check:test-typecheck: OK — 8 file(s) / 324 error(s) / 27 pinned signature(s), the shrink-only ledger unchanged (the new test file is NOT in it; its two initial TS7006 were fixed by typing the recorder, not by expanding the ledger).pnpm exec turbo run build --filter=!@objectstack/docs --concurrency=2— exit 0, 73/73 tasks.pnpm lint(eslint . --no-inline-config, the whole repo, no narrowing) — exit 0.Gate families: 71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN, reconciled mechanically at
76ee9926ewithnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran FILE, every family recorded with its exit code:Three of them answered exit 3 (PREREQUISITE NOT MET) on the first pass —
check:dual-build-cjs-loads,check:i18n,check:type-check-debt— which is a statement about the tree and not a pass. The closure they name was built (theturbo run buildline above) and all three were re-run to a real verdict: exit 0 each.check:engine-double-contractanswered exit 1 with its own remedy (a new pinned engine double the ledger had not learned about); regenerated with--write, 0 seam rows added or grown, 0 lost, gate now exit 0.The gate this lane was warned about, verbatim:
Clause-② — re-derived from the DELIVERED diff, not predicted
no. Five files: the production change, two test files, the changeset, and the gate ledger JSON.export—git diff c8a006fc4..HEAD | grep '^+' | grep -w exportreturns nothing at all.src/index.tsis untouched (0 hits in the changed-file list) and so ispackage.json, whoseexportsmap is a single.pointing at./dist/index.*; the two test files are outside thesrc/index.tsbuild graph and outside the publishedfiles: ["dist", "README.md", "CHANGELOG.md"].resumeFailurewas already declared, exported and type-pinned onApprovalRecallResultin@objectstack/specbefore this PR —resume-failure-report.pin.test.tscarries a type-levelEqassertion (namedRecallCarriesTheReport) holdingApprovalRecallResult'sresumeFailuremember equal toResumeFailureReportor undefined. This PR supplies its first producer. Per the dispatch skill's own boundary test, 「条款②只指已发布契约面,拉回已声明契约不触它」 — bringing an already-declared contract into effect does not touch Clause-②.Neither the "an existing export's behaviour changed" reading nor wire compatibility is the floor, so neither was used. Changeset graded
patchaccordingly (@objectstack/plugin-approvalsonly).Acceptance notes
Noted while working, not filed and not fixed here:
RESUME_TARGET_LOSTboundary is real but is not a defect. A recall whose run is already gone is still told in prose and carries no machine-readable half, because the honest code for it is one this package's ledger row does not admit. That is the row working as designed — its comment says so in as many words — and PIN 3 records the boundary so the next reader does not "fix" it by stamping the wrong code. If a consumer ever needs to branch on that exit, it is apackages/specledger event and belongs to thedomain:specseat, exactly as spec:ResumeFailureReport.codeis a REQUIRED field namingRESUME_FAILED, but the provenance ledger owns that code under@objectstack/rest— so the only correct implementation is refused bycheck:error-code-provenance#17909 was routed. Nothing in this PR stopped at that fence.recall's revise-window arm (cancelRun, ADR-0044) tolerates its own failure with prose and no report. A cancel is not a resume, soResumeFailureReporthas no referent there; left exactly as it was.missingRunCapabilityexit already spells[RESUME_FAILED]inside its prose for a composition with no engine, andresumeRecordedOutcomedoes not stamp a report there either. Left symmetric with the sibling door rather than diverging on this card.Generated by Claude Code