Uh oh!
There was an error while loading. Please reload this page.
fix(approvals,rest,types): a stranded decision publishes finalized / decision / runId / repairable beside its 500 - #15436
Conversation
…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
📓 Docs Drift CheckThis PR changes 3 package(s): 5 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 — 16 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 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
|
os-warren
commented
Sep 4, 2026
PM pre-verification — Claim: the ledger |
| symbol | occurrences in changed lines |
|---|---|
ApprovalResumeSurface | 0 |
getRun | 0 |
StrandedRunState | 0 |
serviceResume (positive control) | 3 |
-- '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.
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 13b58ed7d→ exit 0, captured by redirect rather than through a pipe. This changeset declares no BREAKING, so noadr-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 Changesetsuccess; oneTest Coreshard 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
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}/rejectproduced three coexisting outcomes: the caller read HTTP 500, the request row wasrejectedand 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.restoreConsumedSuspension, no discarding a decision a person actually made. The contract (ApprovalDecisionResult) declares the throw-rather-than-half-state deliberate; this makes that throw truthful rather than replacing it.The change
serviceResumecarriesstatus. It read onlysuccess/code/error. The engine's stranded exit reportsstatus: 'stranded'and nocodeat 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.
resumeRecordedOutcomenow throws a carrier withfinalized(the decision stands),decision,runId, andrepairablederived 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.repairableis producer-first.trueonly 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 honestlyfalse: 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_FAILEDand presence-gated. ARESUME_FAILEDwith no carrier answers exactly the body it always did — the door never synthesises the envelope.Home rule.
strandedDecisionFailure/strandedDecisionDetailslive in@objectstack/typesbecause the producer is a plugin and@objectstack/restcannot import one — the same reasoning, and the same constructor-and-recogniser-in-one-module discipline, as thevalidation-failurepair 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
serviceResume's cast (approval-service.ts:2668on the base, not:2667) read exactlysuccess/code/error. The literal object approvals receives on the stranded exit carriesstatus: 'stranded'and nocode— 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.'stranded'had zero consumers outsideservice-automationandspec. Positive controlRESUME_IN_PROGRESS, same grep shape, finds five directories includingpackages/runtimeandpackages/client— the scan is not over-filtered.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.tscontains noserviceResume/RESUME_FAILED/handleApprovalError); a wire-shaping omission rather than a lossy relay; a different code, owner and status (400FLOW_FAILED, registered to@objectstack/runtime); three of the four fields have no referent there; and an approval node cannot reach that arm at all, sinceresumeAuthority: 'service'makes the generic resume answer 403 first.RESUME_FAILED-on-a-200-body pin is untouched, deliberately.approval-restart-resume.test.ts:272pins the token appearing inresumeErroron 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 arepairableflag there would be a lie. Renaming the token on the 200 body would be a separate contract change nobody ruled on. The fullplugin-approvalssuite (665 tests) is green.Tests
Three pins, as the ruling specified, in
decision-strand-envelope.test.ts— driven through a realAutomationEngineand a realApprovalService, with the reject-branch node throwing the card's ownupdate_record(...) not foundtext:rejectedwith its audit action written;'stranded'observed at the door, plus its own reverse control: aRUN_NOT_FOUNDfailure the engine does not call stranded reportsrepairable: falsewhile still naming its run.stranded-decision.test.tspins 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.tsgains 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.statusflipsrepairabletrue → falseand 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 bygit hash-objectequalling the HEAD blob, withgit diff HEADempty afterwards.Gates
Derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsagainst the actual diff, and re-derived after the changed-file set moved (the docs.mdxand the ledger.jsonadded 34 families). Final sweep at13b58ed7d: 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-contractandcheck:objectql-double-limitcaught the new fake engine hand-rolling its dispatch and reading the caller's bound by truthiness (limit: 0would have returned the whole table). Both verbs now open withassertEngineUpdateDispatch/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-censusline rot was repaired by its own--fix: uniform +5 onrest-server.ts(the import block) and +52 onapproval-service.ts(the docblocks), which is what makes it rot rather than a finding.Six non-zero results, none of them this diff:
PREREQUISITE NOT MET—check:dual-build-cjs-loads,check:i18n,check:type-check-debt, pluscheck:skill-examples(exit 1, whose own text says a verdict withoutclient-react/distwould be a false green). All need a full workspace build; CI performs one.check-affected-docs/check:docs-audit-scope— the known finding check:docs-audit-scope (affected-docs --self-test) answers a finding's exit 1 on an unbuilt workspace — the contract-declaration case flips with the build state, where its siblings exit 3 PREREQUISITE NOT MET #15328, not this branch.scanRouteSurfaceadmits five files from the gitignoredpackages/spec/.examples-build/into the contract population. Reverse control at this HEAD: with those 226 artifacts moved aside and nothing else changed, both gates exit 0; restored afterwards, tree clean. Zero files from this diff are in the offending population. Mechanism recorded on check:docs-audit-scope (affected-docs --self-test) answers a finding's exit 1 on an unbuilt workspace — the contract-declaration case flips with the build state, where its siblings exit 3 PREREQUISITE NOT MET #15328.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.ApprovalDecisionResultdocblock (ruling item 2) is NOT in this PR. It lives inpackages/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.plugin-approvalsplusruntimeif 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.ApprovalResumeSurface.getRunandStrandedRunState; this PR touchesserviceResume/resumeRecordedOutcomeand the four call sites, and does not change theApprovalResumeSurfaceinterface 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
afterDeletereach 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