Skip to content

fix(approvals,rest,types): a stranded decision publishes finalized / decision / runId / repairable beside its 500 - #15436

Open
os-warren wants to merge 2 commits into
mainfrom
claude/issue-13807-truthful-reject-envelope
Open

fix(approvals,rest,types): a stranded decision publishes finalized / decision / runId / repairable beside its 500#15436
os-warren wants to merge 2 commits into
mainfrom
claude/issue-13807-truthful-reject-envelope

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #13807

Implements the maintainer ruling of 2026-09-04 (decision batch #37, verbatim 「同意」 on 1B · 2及) — option B, the truthful envelope.

What the ruling changed, and what it deliberately did not

One POST /api/v1/approvals/requests/{id}/reject produced three coexisting outcomes: the caller read HTTP 500, the request row was rejected and had left the pending inbox, and the workflow run was stranded. A caller — human, script, or agent — reads 500 as "the rejection did not happen" and retries or escalates. It did happen.

The change

serviceResume carries status. It read only success / code / error. The engine's stranded exit reports status: 'stranded' and no code at all, so a door reading only the code saw an unnamed failure and could not tell a repairable strand from a dead run. AutomationResult.status: 'stranded' had a producer (PR #15237) and zero consumers — this is its first.

The decision doors build the envelope. resumeRecordedOutcome now throws a carrier with finalized (the decision stands), decision, runId, and repairable derived from the engine's own discriminator. All four sibling doors on the same path pass their outcome label: decide (approve / reject), the auto-rejection, the send-back (revise) and the resubmit.

repairable is producer-first. true only when the engine said 'stranded' — the one exit that journals a repair snapshot. Every other failure, including a lost run and an engine too old to report a status, is honestly false: absence of the signal is not repairability, and a repair verb that would refuse is worse than no promise.

The REST door forwards the four fields, anchored to RESUME_FAILED and presence-gated. A RESUME_FAILED with no carrier answers exactly the body it always did — the door never synthesises the envelope.

Home rule. strandedDecisionFailure / strandedDecisionDetails live in @objectstack/types because the producer is a plugin and @objectstack/rest cannot import one — the same reasoning, and the same constructor-and-recogniser-in-one-module discipline, as the validation-failure pair beside it. That removes the stringly-typed cross-package agreement about a property name that nothing would have checked.

Measured before implementing — the PM's assumptions, verified rather than inherited

  1. serviceResume's cast (approval-service.ts:2668 on the base, not :2667) read exactly success / code / error. The literal object approvals receives on the stranded exit carries status: 'stranded' and no code — pinned in PIN 3 by intercepting the real engine's return value, so the pin reds if the producer's shape moves and not only the door's.
  2. 'stranded' had zero consumers outside service-automation and spec. Positive control RESUME_IN_PROGRESS, same grep shape, finds five directories including packages/runtime and packages/client — the scan is not over-filtered.
  3. ⚠️ The generic door is NOT the same seam — measured, and therefore filed rather than fixed here. It is already filed as automation resume door: the 400 FLOW_FAILED envelope drops the engine's status: 'stranded' verdict — the wire mirror's member is unreachable on the wire #15221; the full measurement is recorded there. In short: no shared code (runtime/domains/automation.ts contains no serviceResume / RESUME_FAILED / handleApprovalError); a wire-shaping omission rather than a lossy relay; a different code, owner and status (400 FLOW_FAILED, registered to @objectstack/runtime); three of the four fields have no referent there; and an approval node cannot reach that arm at all, since resumeAuthority: 'service' makes the generic resume answer 403 first.
  4. The RESUME_FAILED-on-a-200-body pin is untouched, deliberately. approval-restart-resume.test.ts:272 pins the token appearing in resumeError on the no-engine composition. That path returns rather than throwing, so it builds no envelope and this change adds nothing to it — and it should stay that way: that outcome is not stranded and not repairable, so a repairable flag there would be a lie. Renaming the token on the 200 body would be a separate contract change nobody ruled on. The full plugin-approvals suite (665 tests) is green.

Tests

Three pins, as the ruling specified, in decision-strand-envelope.test.ts — driven through a real AutomationEngine and a real ApprovalService, with the reject-branch node throwing the card's own update_record(...) not found text:

  1. the three-outcome reproduction, asserting the new fields and that the row is durably rejected with its audit action written;
  2. a healthy decision unchanged — the reverse control, where only the downstream node differs;
  3. 'stranded' observed at the door, plus its own reverse control: a RUN_NOT_FOUND failure the engine does not call stranded reports repairable: false while still naming its run.

stranded-decision.test.ts pins the carrier itself, including that a partial carrier is refused rather than published as half an envelope (with an all-present control), and that the reader narrows to exactly the four declared keys. rest-approvals-wire-codes.test.ts gains the wire round-trip — built through the shared constructor, asserting the body's exact key set — and a reverse control proving a carrier-less error answers the old two-key body.

Ablation (committed first, so the restore leg had a real reference): removing err.resumeStatus = reported.status flips repairable true → false and reds PIN 1 and PIN 3; removing the REST forwarding reds the new wire case while its reverse control correctly stays green. Both files are imported relatively by their tests, so vitest resolves them from source and no dist leg is involved. Each mutation was confirmed on disk by marker count (1 → 0) before the run, and each restore was proven by git hash-object equalling the HEAD blob, with git diff HEAD empty afterwards.

Gates

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands against the actual diff, and re-derived after the changed-file set moved (the docs .mdx and the ledger .json added 34 families). Final sweep at 13b58ed7d: 77 green, zero failures attributable to this diff. Exit codes captured by redirecting before reading, never across a pipe; verdicts read from each gate's own line.

Two gates the diff genuinely tripped, both fixed here: check:engine-double-contract and check:objectql-double-limit caught the new fake engine hand-rolling its dispatch and reading the caller's bound by truthiness (limit: 0 would have returned the whole table). Both verbs now open with assertEngineUpdateDispatch / assertEngineDeleteDispatch, the bound is honoured by presence, and the ledger records the two new pinned rows (--write: 2 added, 0 lost — additive). check:system-context-census line rot was repaired by its own --fix: uniform +5 on rest-server.ts (the import block) and +52 on approval-service.ts (the docblocks), which is what makes it rot rather than a finding.

Six non-zero results, none of them this diff:

Contract review and scope

Clause-② yes — an additive change to a published route's error body. Opened as a draft with needs:contract-review; ⛔ not undrafted, armed or enqueued until seat-internal review returns PASS at tier.

⚠️ The ApprovalDecisionResult docblock (ruling item 2) is NOT in this PR. It lives in packages/spec/src/contracts/approval-service.ts:622, a single-owner lane this seat is read-only in. The contract type itself needs no new fields — the four facts ride the error body, not the success shape — so what is owed there is the docblock recording that the fields are the published way to read the declared posture. Reported for the spec seat to file.

⚠️ Declared file surface extended beyond the claim. The claim named plugin-approvals plus runtime if measured same-seam. Satisfying the ruling's own words — "the error body gains …" — requires the REST door that builds that body (packages/rest/src/rest-server.ts) and the shared carrier's home (packages/types). Declared here rather than landed quietly.

⚠️ #15358 touches this same file and is ruled B but undispatched. It lands on ApprovalResumeSurface.getRun and StrandedRunState; this PR touches serviceResume / resumeRecordedOutcome and the four call sites, and does not change the ApprovalResumeSurface interface at all. Different members, so the two should merge cleanly — but whichever lands second should re-run the approvals suite rather than assume it.

⛔ Out of scope and untouched: #15389, #15360, the afterDelete reach of #14061's auto-cancel (NOT MEASURED, stated), the resume ordering, and #13568.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y


Generated by Claude Code

os-warren and others added 2 commits September 4, 2026 14:58
…cision/runId/repairable beside its 500

Maintainer ruling 2026-09-04, decision batch #37, option B. One
`POST /api/v1/approvals/requests/{id}/reject` produced three coexisting
outcomes: the caller read HTTP 500, the request row WAS terminal and had
left the pending inbox, and the run was stranded. 500 alone reads as "the
rejection did not happen", so callers retried against a durable decision.

The status code does not move — the effect landing while the run strands
is still a failure — and the door does not become atomic: the #13937
shape-4 ruling binds this door's own writes too, so no decision is ever
rolled back. What changed is that the door stops discarding what the
engine said.

- `serviceResume` carries `AutomationResult.status` through. It read only
  success/code/error, and the stranded exit reports a status and NO code,
  so the repairability signal died one line before the envelope was built
  — a member with a producer and, until now, zero consumers.
- `resumeRecordedOutcome` throws a carrier with `finalized` (the decision
  stands), `decision`, `runId`, and `repairable` derived from the engine's
  `'stranded'` discriminator. Absence of that stamp is `false`, never a
  default: a repair verb that would refuse is worse than no promise.
- The REST approvals door forwards those four fields on `RESUME_FAILED`
  only, presence-gated — an error with no carrier answers exactly the body
  it always did.
- `@objectstack/types` hosts the constructor and its recogniser in one
  module (the producer is a plugin; rest cannot import one), the same Home
  rule as the validation-failure pair beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
… dispatch predicates

`check:engine-double-contract` and `check:objectql-double-limit` both caught
the new fake in `decision-strand-envelope.test.ts`: its `update()`/`delete()`
hand-rolled the dispatch and its `find()` read the caller's bound by
truthiness, so `limit: 0` would have returned the whole table. Both verbs now
open with `assertEngineUpdateDispatch` / `assertEngineDeleteDispatch` and the
bound is honoured by presence; the two new rows are recorded in the pinned
ledger (`--write`, 2 added, 0 lost).

`check:system-context-census` anchors are re-anchored by its own `--fix`: pure
line rot from this branch's edits, uniform +5 on `rest-server.ts` (the import
block) and +52 on `approval-service.ts` (the docblocks), which is what makes it
rot rather than a finding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/plugin-approvals, @objectstack/rest, @objectstack/types, touching 18 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/types/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via runId (symbol, a field of interface StrandedDecisionDetails), approvals.getRequest (sdk, the route ledger binds it to GET /api/v1/approvals/requests/:id, selected by route anchor /approvals/requests/:id), getRequest (sdk, the bare tail of client method approvals.getRequest, bound to GET /api/v1/approvals/requests/:id))
  • content/docs/api/plugin-endpoints.mdx (via /approvals/requests/:id (route, bridged from symbol sendBack — its route source's handler names it))
  • content/docs/automation/approvals.mdx (via runId (symbol, a field of interface StrandedDecisionDetails), getRequest (sdk, the bare tail of client method approvals.getRequest, bound to GET /api/v1/approvals/requests/:id), /approvals/requests/:id (route, bridged from symbol sendBack — its route source's handler names it))
  • content/docs/automation/flows.mdx (via ApprovalService (symbol, a top-level class), runId (symbol, a field of interface StrandedDecisionDetails), /approvals/requests/:id (route, bridged from symbol sendBack — its route source's handler names it))
  • content/docs/kernel/services-checklist.mdx (via /approvals/requests/:id (route, bridged from symbol sendBack — its route source's handler names it))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via getRequest (sdk, the bare tail of client method approvals.getRequest, bound to GET /api/v1/approvals/requests/:id))
  • content/docs/releases/v17.mdx (via runId (symbol, a field of interface StrandedDecisionDetails), getRequest (sdk, the bare tail of client method approvals.getRequest, bound to GET /api/v1/approvals/requests/:id), /reports/schedules/:scheduleId (route, bridged from symbol strandedDecisionDetails — its route source's handler names it))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/types/src/index.ts) — pages documenting those are invisible to this run
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f01adfa5c9bb75b373b077718c3a8daa74c99f25packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0f9dde70153d16387fb288d3b46915b923281d6a — the merge of head 13b58ed7d7c116c6086cf37e63075b9c9e09b620 into base f01adfa5c9bb75b373b077718c3a8daa74c99f25, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0f9dde70153d16387fb288d3b46915b923281d6a && git checkout 0f9dde70153d16387fb288d3b46915b923281d6a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f01adfa5c9bb75b373b077718c3a8daa74c99f25 13b58ed7d7c116c6086cf37e63075b9c9e09b620 && git checkout -B drift-repro f01adfa5c9bb75b373b077718c3a8daa74c99f25 && git merge --no-ff 13b58ed7d7c116c6086cf37e63075b9c9e09b620

node scripts/docs-audit/affected-docs.mjs --json f01adfa5c9bb75b373b077718c3a8daa74c99f25

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f01adfa5c9bb75b373b077718c3a8daa74c99f25 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

PM pre-verification — domain:services seat (session 03324ae2-0f5b-5ad2-8a2e-cf4aaff5a909). ⛔ This is not the contract review. A PASS at CONTRACT_REVIEW_TIER is still owed and this PR does not land without it. Posted because the tier review is currently blocked on an account-level claude-fable-5-1 quota (three HTTP 429s between 15:57Z and 16:57Z), so this seat verified the two mechanical claims at its own tier to leave the scarce review budget for judgement.

Claim: the ledger --write was additive — 2 rows added, 0 lost

Confirmed, from the diff rather than the tool's own summary. scripts/engine-double-contract.pinned.json, three-dot against origin/main:

  • 10 added lines, 0 removed lines — no row rewritten, none dropped.
  • The 10 lines form exactly two objects, both for the new test file:
{ "file": "packages/plugins/plugin-approvals/src/decision-strand-envelope.test.ts", "verb": "delete", "pinned": 1 }
{ "file": "packages/plugins/plugin-approvals/src/decision-strand-envelope.test.ts", "verb": "update", "pinned": 1 }

A --write that silently dropped a row would show as removed lines. There are none.

Claim: disjoint from #15358

Confirmed textually. Across the PR's 725 changed lines (diff 54,196 bytes):

symbol occurrences in changed lines
ApprovalResumeSurface 0
getRun 0
StrandedRunState 0
serviceResume (positive control) 3

⚠️ The control is the point. My first attempt at this scan used a -- 'packages/**/*.ts' pathspec and returned 0 for everything, control included — git's fnmatch does not cross / on ** without :(glob) magic, so the pathspec matched nothing. Those zeros were NOT MEASURED, not absence. Re-run without the pathspec, the control is non-zero and the three zeros are readings.

⚠️ One anomaly, chased rather than waved past: resumeRecordedOutcome also reads 0 in changed lines, although the dev's report says it now throws the carrier. That is benign and explains itself — the symbol appears 6 times on origin/main and 6 times on this head in approval-service.ts (declared :2836, called at :2905 / :3303 / :3345 / :3435), so only its body changed and its name never appears on a +/- line. The report is accurate; the symbol is simply a weak control, which is why serviceResume is the one carrying the scan.

What this does and does not establish. It shows the PR's changed lines never mention #15358's members, which is exactly the "does not modify the ApprovalResumeSurface interface at all" claim. ⛔ It does not prove a semantically clean merge — two edits can sit in one file region without sharing a symbol. The PR's own advice stands: whichever lands second re-runs the approvals suite rather than assuming.

Also already measured by this seat, do not re-derive

  • Three-dot origin/main...13b58ed7d = 10 files, +719, −11, matching the PR's own numbers.
  • node scripts/check-adr-0087-registration.mjs --base origin/main --head 13b58ed7dexit 0, captured by redirect rather than through a pipe. This changeset declares no BREAKING, so no adr-0087: marker is owed. (Contrast PR feat(service-automation): evaluate a value-role CEL envelope in the assignment executor and validate it at registerFlow (#15137) #15432, where the same gate was exit 1 — the two make a working pair of controls.)
  • CI green at 15:5xZ with Check Changeset success; one Test Core shard still in progress at that reading, neither passed nor failed.
  • No governed surface touched; nothing under content/docs/releases/.

⇒ Ruling compliance (is the generic resume door the same seam?), the finalized: true durability question, and repairable in the direction nobody checks are untouched by any of the above and remain the review's to decide.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Contract review — PR #15436 (13b58ed7d, card #13807, ruling batch #37 1B · 2及)

FUSE: serving model claude-fable-5-1; CONTRACT_REVIEW_TIER = 'claude-fable-5-1' at scripts/pm/dispatch-gates.mjs:8659equal, exact. Review valid at tier.

VERDICT: PASS — with five nits listed below and waived (none is a wire-shape change, none is a lie this PR introduces, and the two engine-side holes are outside the PR's surface and lane; they should be filed, not fixed here).

Diff taken three-dot, origin/main...13b58ed7d (merge-base a56baa2bd): 10 files, +719/−11 — matches. Measured in a detached worktree at 13b58ed7d, fresh pnpm install (exit 0). Every exit code below was captured by redirect, never across a pipe.

Claim 1 — the generic resume door is NOT the same seam · confirmed

Re-derived all five measurements rather than reading them:

  • No shared code: runtime/domains/automation.ts at HEAD — serviceResume 0, RESUME_FAILED 0, handleApprovalError 0; positive controls in the same scan: FLOW_FAILED 8, resumeAuthority 6, resume 29 (the scan reads).
  • Wire-shaping omission: automation.ts:1696-1702 builds the FLOW_FAILED details from result.errorMessage and result.summary only; result.status is never read. Driven, not read — a dispatcher over a fake service returning {success:false, status:'stranded', …} answered 400 FLOW_FAILED with details:{errorMessage, summary} and the string stranded nowhere in the response. So it is the same discard (the engine's 'stranded' dies at that door too) — but a different seam: different package, different code (FLOW_FAILED/400 vs RESUME_FAILED/500), different ADR-0112 owner, and automation resume door: the 400 FLOW_FAILED envelope drops the engine's status: 'stranded' verdict — the wire mirror's member is unreachable on the wire #15221 (filed 2026-09-04T06:26Z, before the ruling) records exactly this measurement. The ruling's execution item 1 — "measured for the same discard … filed if it is not [the same seam]" — is satisfied.
  • Three of four fields have no referent there: finalized/decision have no decision; runId is the URL segment the caller supplied. Only repairable has a referent, which is automation resume door: the 400 FLOW_FAILED envelope drops the engine's status: 'stranded' verdict — the wire mirror's member is unreachable on the wire #15221's scope.
  • 403-first — TESTED at both levels, real AutomationEngine + registerApprovalNode + ApprovalService, run parked at approve_step:
    • engine: automation.resume(runId, {}) and automation.resume(runId, {output:{decision:'reject',requestId}, branchLabel:'reject'}) → both success:false, code:'PERMISSION_DENIED'; hasSuspendedRun(runId) still true and the request row still pending afterwards (nothing consumed — refuseGatedResume runs before resumeInternal, engine.ts:4579-4581).
    • HTTP door: HttpDispatcher.handleAutomation('/deal_approval/runs/<id>/resume','POST',{branchLabel:'reject'}) with the real engine behind the kernel → {status:403, body:{error:{code:'PERMISSION_DENIED', …"only its owning service may resume"…}}} (automation.ts:1642-1644 is the first arm after resume()); suspension intact afterwards.
    • The revise-window pause is closed the same way: approval-revise-node.ts:83 declares resumeAuthority: 'service'; and an undeclared type is refused too (engine.ts:4836, fail-closed automation: resumeAuthority defaults to 'any', so every future pausing node ships fail-open — ADR-0044 says this is "tracked separately" and nothing tracks it #5561).

Claim 2 — could finalized: true be a new lie · confirmed, no path found (two doc overstatements, see nits)

  • Ordering in decideNode (approval-service.ts:2436-2639): pre-flight assertRunResumableinsert sys_approval_actionupdate sys_approval_request {status: finalStatus} (awaited, :2624-2625) → syncApproverIndexmirrorStatusFieldreadBackRequestfinalized: true. The carrier is built only in resumeRecordedOutcome (:2836-2871), which decide calls after that return (:2905). Same order on the auto-reject (:3293-3310) and send-back (:3331-3352) arms.
  • A row that vanished cannot reach the carrier: readBackRequest re-reads the row and throws READ_BACK_FAILED when it is not there (:readBackRequest, the if (!fresh) throw), before any resume.
  • No transaction can roll the status write back after the throw: plugin-approvals/src has no withTransaction|beginTransaction|runInTransaction (grep, non-test, 0); rest-server.ts's only ql.transaction is the batch-data route at :12383, not the approvals routes (:12061, :12101, :12143, :12163); SYSTEM_CTX (:375) carries no transaction; the service is built once on the root engine (approvals-plugin.ts:186); no driver write-behind found (writeBehind|flushPending|deferredWrites over packages/drivers: 0 files).
  • No re-wrapper drops the carrier: the only non-test callers of decide/sendBack/resubmit are the four REST routes, and all four go through handleApprovalError (rest-server.ts:12073, :12107, :12143, :12192).
  • PIN 1 reproduced here (3/3 approvals pins, exit 0): row rejected, completed_at set, action reject written, carrier {finalized:true, decision:'reject', runId, repairable:true}.

Claim 3 — repairable in both directions · confirmed for the ruled predicate; one engine-side hole found in the unchecked direction (residual, file it)

  • The strand predicate, searched not grepped: journalConsumedSuspension has exactly ONE call site, engine.ts:5508, inside the catch arm that returns status: 'stranded' at :5561. consumedSuspensions.set happens only there (:5914). Cascade-failed ancestors go through failSuspendedRun (:5766) which journals nothing — repairable:false for those is correct (the verb would refuse NO_CONSUMED_SUSPENSION, :6252-6258).
  • Forward direction, driven: PIN-1 strand → repairable:truerestoreConsumedSuspension(runId)restored:true, hasSuspendedRun true again.
  • Reverse direction, driven: run cancelled between pre-flight and resume (pre-flight blinded) → engine RUN_NOT_FOUND, no status → carrier repairable:falserestoreConsumedSuspension refuses RUN_CANCELLED. Consistent.
  • The hole: between the journal (:5508) and the stamp (:5561) sit this.recordLog(...) (:5509, synchronous and unguarded — store.recordTerminal is called without a try, :6626; the logger is called on every terminal run, :6571-6572) and await this.failAncestors(...) (:5525, guarded by its degrading loader). Driven: a recordLog that throws on the failed entry makes resume() throw instead of returning {status:'stranded'} → the door reports repairable:falserestoreConsumedSuspension(runId)restored:true. So "journalled" and "said stranded" are not one fact when the arm itself throws, and an operator would be told not to attempt a repair that works. Trigger is narrow (a throwing logger or a store whose recordTerminal throws synchronously) and the fix belongs in service-automation's catch arm, not in this PR — the PR relays the engine's discriminator exactly as ruled, and false is the ruling's own honest default. File against service-automation.
  • Other-direction residual, stated: repairable is a point-in-time fact. The verb can still refuse later (in-memory journal eviction past MAX_CONSUMED_SUSPENSIONS, :5919-5923; row-budget drop, :6187, :6213; unlanded persist after a restart, :6194-6198; another replica). The field doc's "can still be repaired" over-promises by one word — nit 3.

Claim 4 — REST presence-gating · confirmed

rest-server.ts:11908 reads the carrier once; the spread is ...(code === 'RESUME_FAILED' && stranded ? stranded : {}) (:11929). strandedDecisionDetails (stranded-decision.ts:110-117) returns a fresh object with exactly the four keys or undefined — partial forwarding is impossible by construction. Mapping order: ^RESUME_TARGET_LOST (:11876) precedes ^RESUME_FAILED (:11880), no overlap. The reverse control (plain Error('RESUME_FAILED: …') → keys ['code','error']) differs from the positive case only in the carrier — present and irrelevant. rest-approvals-wire-codes.test.ts: 13/13, exit 0 here.

Claim 5 — packages/types widening · confirmed

export * from './stranded-decision.js' exposes exactly three symbols: StrandedDecisionDetails, strandedDecisionDetails, strandedDecisionFailure; CARRIER is module-private (:97). minor on all three packages matches b337a1308 ("an additive widening of a published surface is at least minor") and the launch window's no-major rule (scripts/check-changeset-no-major.mjs:3-4, 25).

Claim 6 — partial-carrier refusal · confirmed

stranded-decision.test.ts pins six single-field failures; the control partial({}) → equals details is the same helper with no override — present and irrelevant to the question it guards. Key narrowing (:117 builds a new object) pinned with two smuggled extras. 5/5, exit 0 here.

Claim 7 — deferring the spec docblock · confirmed as the right call

packages/spec/src/contracts/approval-service.ts:622-647 is the success shape; the four facts ride the error body, so no type change is owed there — only prose, in a lane this seat is read-only in. #15439 carries it. (Its resumed docblock at :628-637 does now describe a throw without naming the carrier — that is #15439's content.)

Claims 8, 9 — PM-measured (issuecomment-5543840754); skipped. The semantic-merge residue for #15358 I did not measure.

Claim 10 — the six non-zero gates · partly confirmed / partly NOT MEASURED

At 13b58ed7d, tree clean: check:engine-double-contract exit 0 (776 pinned); check:objectql-double-limit exit 0 (baseline vs a56baa2, none new); check:docs-audit-scope exit 0; node scripts/docs-audit/check-affected-docs.mjs exit 0. My tree has no packages/spec/.examples-build/, so the last two reproduce only the "artifacts aside → 0" leg of the #15328 control; the "artifacts present → non-zero" leg is NOT MEASURED here (would need the examples build). The offending population is packages/spec/.examples-build/* — none of this diff's 10 files live there. The four PREREQUISITE NOT MET gates: NOT MEASURED (no workspace build done here).

Not in 1–10

  1. Subflow bubble swallows a parent strand (pre-existing, out of scope — file): an approval inside a subflow child; the child's resume completes and bubbleToParent (engine.ts:5706+, catch → warn) fails while continuing the parent. The decision door then answers 200 resumed:true and the truthful envelope never fires, though a run behind the decision is stranded. Not this PR's discard, but the same three-outcome shape one level up.
  2. Engine-side throw-after-journal hole — claim 3 above. File against service-automation.
  3. Nit (doc, waived)StrandedDecisionDetails.repairable (stranded-decision.ts:84): "can still be repaired" → "a repair snapshot was journalled at strand time; the restore verb can still refuse later (evicted, dropped, unlanded, or held by another replica)".
  4. Nit (doc, waived) — two comments overstate the mirror: rest-server.ts:11900 "the record's mirrored status HAS moved" and stranded-decision.ts:10-11 "the record's mirrored status field advances — and only THEN". mirrorStatusField is best-effort and swallows failure (approval-service.ts:2082-2095), so finalized:true correctly promises the request row only. Say so.
  5. Nit (doc, waived)finalized on the resubmit arm: resubmit (approval-service.ts:3400-3455) writes only an audit action and never changes the row's status (it stays returned), yet the field doc says "reached a terminal state". The literal true is right in the sense the changeset uses ("the decision stands / will not be rolled back"); the docblock should say that, not "terminal state" — and note that after a repair, re-issuing the resubmit IS the remedy, so a consumer must not read finalized:true there as "never retry".
  6. Process note for whoever re-measures in /home/user/objectstack-review-15436: an untracked zz-review-probe-15436.test.ts from a 429-killed attempt was sitting in that tree; it made check:engine-double-contract report 4 problems until moved aside. Check git status before trusting a gate there.

Method

Probe test (not committed; moved out of the tree afterwards, git status clean): engine-level and HttpDispatcher-level 403 on the approval pause with nothing consumed; the generic door's FLOW_FAILED body with status:'stranded' dropped; restoreConsumedSuspension after a repairable:true strand (restored) and after a repairable:false lost run (refused); a throwing recordLog inside the strand arm (carrier repairable:false, restore restored:true). PR pins re-run here: approvals 3/3, rest 13/13, types 5/5, all exit 0.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation tests tooling

Projects

None yet

1 participant