feat(runtime, spec): the resume door's 400 FLOW_FAILED details carry the engine's stranded verdict - #16587
Conversation
…the engine's stranded verdict `POST /automation/:name/runs/:runId/resume` copied `errorMessage` and `summary` off the engine result and dropped `status`, so `AutomationResult.status: 'stranded'` (terminally failed but repairable by an operator verb) reached the wire as the same 400 FLOW_FAILED a plain terminal failure does. The #16472 family ruling (option A): carry `status` and `repairable` in the details of the existing code, no FLOW_STRANDED sibling. - spec: `ResumeFailureDetailsSchema` (`@objectstack/spec/api`) declares the structure once — `{ runId, status?: 'failed' | 'stranded', repairable }`. - runtime: the resume door forwards `status` verbatim when the engine stamped one, names the resumed run as `runId`, and answers `repairable` as `status === 'stranded'` — always present on this arm, present-and-false on the plain terminal exit. Trigger door and /actions unchanged. - client: `automation.resume` docblock; docs: flows.mdx, client-sdk.mdx. - pins: spec schema + type-level subset pin; runtime door pins (fake engine, every arm); verify wire pin through the real engine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
…ema export moves api-surface, export-origins, declaration-map, the generated api reference page, and the unknown-key strictness ledger count (450 -> 451 in api/) — each regenerated by `check:generated --fix`, only the five it proved stale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
…e-door helper moved Pure line rot: the helper and its import shift four `ec.isSystem` read sites in domains/automation.ts; rewritten by the gate's own --fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
📓 Docs Drift CheckThis PR changes 3 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 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 8daaffeeaf87c26e83505e5aa9f8d143023aa0df && git checkout 8daaffeeaf87c26e83505e5aa9f8d143023aa0df
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b4abb0a915bbcc6ba42f6a5b2a1f46d20d3b3a9b c3208dfe0e1b62adfe63b510a60ca8f8f8b57815 && git checkout -B drift-repro b4abb0a915bbcc6ba42f6a5b2a1f46d20d3b3a9b && git merge --no-ff c3208dfe0e1b62adfe63b510a60ca8f8f8b57815
node scripts/docs-audit/affected-docs.mjs --json b4abb0a915bbcc6ba42f6a5b2a1f46d20d3b3a9b
|
… not grade a package it grew as patch Check Changeset's finding on #16587: the PR declares clause-② yes and moved packages/client/src/**, and the 2026-09-04 ruling (decision batch #35, on #15294) binds per PR — at least `minor` for a package whose public surface this PR moved, whatever the commit type says. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
… on the two terminal-failure members, and the /actions negative pin Contract-review follow-ups on #16587: resumeFailureDetails now returns ResumeFailureDetails, relaying status through a guard on 'failed' | 'stranded' (satisfies-bound to the schema's enum) so the compile-time binding is true by construction — still a relay, never a synthesised verdict. actions-flow-dispatch-status.test.ts gains the exact-equality negative pin the docblock claimed for /actions, and the docblock now names both pin files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
…d moved Pure line rot again: the guard, its constant and their docblocks sit above the anonymous-deny read in domains/automation.ts, shifting it :1057 -> :1079; rewritten by the gate's own --fix, population unchanged (106 sites / 141 anchors). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
Contract review COMPLETE — both carriers cleared, un-drafted, armed
The review, and what it changedClause-②
The dev's third ablation leg is what makes the binding load-bearing rather than decorative: removing the return-type annotation reds Why the clear is a CLEAR
CI at the head that is landing
Un-drafted, then armed (in that order — auto-merge does not survive a draft conversion). Generated by Claude Code |
Fixes #15221
The ruling this implements
Family ruling on #16472 (director seat, decision batch #76, maintainer verbatim 「同意」), recorded on the card at comment
5567698235— option A: the resume door's400 FLOW_FAILEDdetails carrystatus: 'stranded'andrepairableso a client can branch without a message regex; noFLOW_STRANDEDsibling code is minted (a new code is a ledger event; a console that needs a distinct code is its own card). The platform rule the same ruling states: a resume failure told to the caller is told in a machine-readable shape — a registered code, therunIdof the run that is actually stranded, andrepairable— with one optional structure declared once inpackages/specand reused by the three carriers it names (#15556, #15970, this card).Premise, re-confirmed on
origin/maind1c86a7 before writing codeFLOW_FAILEDinpackages/runtime/src/domains/automation.ts: 7 sites;stranded: 0;serviceResume/RESUME_FAILED/handleApprovalError: 0 — soPOST /api/v1/approvals/requests/{id}/rejectreturns 500 while its effect lands AND strands the workflow run — three inconsistent outcomes from one call #13807's approvals door is a different seam, as comment5542754478measured.errorMessageandsummaryoff the engine result and forwarded nostatus.service-automation/src/engine.ts,resumeInternal): the stranded exit stampsstatus: 'stranded'and carries norunId; the other exit that reaches this arm — a subflow child that failed terminally — returns{ success: false, error, durationMs }with no status at all. That measurement decides the non-stranded arm below.strandedDecisionDetails/strandedDecisionFailurelanded in@objectstack/types(POST /api/v1/approvals/requests/{id}/rejectreturns 500 while its effect lands AND strands the workflow run — three inconsistent outcomes from one call #13807). Not reused here, deliberately: it is an all-four-or-nothing envelope whosefinalizedanddecisionare approvals facts with no referent at a generic resume, and its reader refuses a partial one by design. Only therunId/repairablevocabulary is shared, through the spec declaration.What changed
@objectstack/spec—ResumeFailureDetailsSchemainpackages/spec/src/api/automation-api.zod.ts(section 7b):{ runId: string, status?: 'failed' | 'stranded', repairable: boolean }, exported withResumeFailureDetails/ResumeFailureDetailsParsed. Non-strict on purpose: on the wire it rides inside the sameerror.detailsobject aserrorMessage/summary, and a client hands the wholedetailsto the schema and gets the verdict back.@objectstack/runtime— the resume door (POST /automation/:name/runs/:runId/resume) spreadsresumeFailureDetails(runId, result)into its400 FLOW_FAILEDdetails beside the two artefacts that were already there. The two members the door owns are bound to the spec declaration at compile time (satisfies Omit(ResumeFailureDetails, 'status'), spelled with angle brackets in the source);statusis relayed.@objectstack/client—automation.resumedocblock. Docs —content/docs/automation/flows.mdx(new subsection under Durable pause & resume),content/docs/api/client-sdk.mdx(resume paragraph after theFlowFailureDetailsblock). Generated — api-surface, export-origins, declaration-map, the api reference page, the strictness-ledger count (450 to 451 inapi/, an untriaged directory — no ratchet moved), each regenerated bycheck:generated --fixfor the five it proved stale.content/docs/permissions/system-context.mdx— four census line anchors re-pointed by the gate's own--fix(pure line rot from the helper insertion).The contract, stated arm by arm — this is the deliverable
On the resume door's
400 FLOW_FAILED:runIdstatusrepairableerrorMessage/summary'stranded')'stranded'truefalse, present'failed''failed', verbatimfalsefalseis the honest answer for every exit that is not stranded, including the ones that report no status — the same postureStrandedDecisionDetails.repairablealready fixed for the approvals door.statusis the producer's verdict, forwarded verbatim when stamped and never invented by the door. The door does not turn "no status" into'failed'(PD Add comprehensive test suite for Zod schema validation #12: read the producer's verdict, never sniff).runIdis the path's:runId. The engine stamps'stranded'on exactly one exit —resumeInternal's own catch arm for the run being resumed — so the resumed run is the run that is actually stranded; the engine result carries norunIdon a terminal exit./actionsare unchanged: their400 FLOW_FAILEDdetails stay{ errorMessage?, summary? }with norepairablemember. Neither door ever resumes, so "repairable" has no referent there — absent there means "not a resume", never "not repairable". Pinned.FLOW_FAILED. The console's terminal reading of the code (automation: the run-resume route still answers HTTP 200 wrapping an inner {success:false} — the route #3962's status-code unification left behind #8684) stays true; a client that wants to offer a repair branches ondetails.repairable, never on the message. The console-side branch itself is objectui work and is not in this PR.Clause ② — re-derived from the delivered diff, both limbs
Clause-②: yes
packages/spec/src/api/automation-api.zod.tsand its test are in the diff —packages/spec/src/**is touched. Hit.@objectstack/specminor, per the ruling). The declaration line: this PR changes what a published contract accepts and publishes a new public face. Hit.yes— my own reading agrees with the dispatch grading;needs:contract-reviewhung on this PR at creation, per the claim. Serial re-checked across all 18 open PRs before push (REST file lists):automation-api.zod.ts,domains/automation.ts,packages/types/src/**,client/src/index.ts— no holder;contracts/automation-service.ts(feat(spec): declare the operator run-lifecycle verbs cancelRun and restoreConsumedSuspension on IAutomationService (contract half of the #13953 ruling) #16563) anderror-code-ledger.zod.ts(spec: define the confirmation contract behindaction.ai.requiresConfirmation#16531) are held and untouched here; the control names five real holders ofpackages/spec/src/**.Verification (all at
cde77edcc9, worktreeobjectstack-issue-15221)Dependency closure built first (
turbo run build --filter='@objectstack/verify^...', 32/32 tasks, then client + client-react), every heavy step underscripts/pm/os-verify-lock.sh; every exit code captured before any pipe.vitest run src/api/automation-api.zod.test.ts src/contracts/automation-result-status.pin.test.ts—Test Files 2 passed (2) · Tests 58 passed (58);pnpm --filter @objectstack/spec typecheck(tsc + scripts-typecheck + test-typecheck) —VERDICT command-exit 0. New pins: schema accept/refuse cases; the type-level pinResumeFailureStatusIsTheContractsTerminalFailureSubsetbinds the schema'sstatustoExtract(AutomationResult['status'], 'failed' | 'stranded').pnpm --filter @objectstack/runtime test—Test Files 239 passed (239) · Tests 3375 passed (3375),VERDICT command-exit 0;typecheck—VERDICT command-exit 0(test layer 27 files / 191 errors / 69 pinned, unchanged ledger). New filedomains/automation-resume-stranded-details.test.ts: 14 pins — the stranded arm (details equality, schema parse, message carries no "stranded"), the plain arm ({ runId, repairable: false }exactly), the regex CONTROL, a stamped'failed', the runId source, six coded refusals with no details, both 200 arms, and the trigger door's unchanged details.AutomationEnginethrough the realHttpDispatcher, runtime resolved through its built dist): newautomation-resume-stranded-details.test.ts— trigger parks a run, resume into a throwing node answers400 FLOW_FAILEDwith{ runId, status: 'stranded', repairable: true }besideerrorMessage/summary,resumeagain answers 404,restoreConsumedSuspension(runId)answersrestored: true(wire promise and verb agree); CONTROL: the non-throwing tail resumes to 200.Test Files 2 passed (2) · Tests 6 passed (6)(withautomation-trigger-terminal-messages.test.ts);typecheck— OK.typecheck— OK;check:skill-examplesafter building client + client-react —257 prose examples type-check across 3 surfaces(the resume docblock example is one of the 23 client SDK blocks).--no-inline-config) on the six changed source/test files — exit 0.check:nul-bytes— OK; control-byte self-scan of every touched file — zero hits.turbo ls --affectedagainst BASE lists 76 of 79 packages (spec moved, so everything downstream). Locally: runtime full suite; spec, verify, client targeted as above. The remaining affected packages' suites are declared to CI — an additive export on spec, and no other package reads the resume door.Reverse verification and ablation (implementation committed first; restore by
git checkout HEAD -- path, proven bygit diff HEADempty andgit hash-objectequal to the HEAD blob after each leg; trap-guarded script):repairable: 'yes'; injected=1, removed=0 on disk): runtimetsc --noEmitgoes red atautomation.ts(971,9): error TS2322: Type 'string' is not assignable to type 'boolean'— the door reads the rebuilt.d.ts, direction: turns red....resumeFailureDetails(...)spread removed; removed=0 on disk): the new runtime file answers5 failed | 9 passed (14)— exactly the five new-behaviour pins red, the nine untouched-arm pins green.Gates —
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no paths) atcde77edcc9:Run reconciliation — 99 derived, 99 run, 0 UNRUN. Ledger: 95 exit 0;check-system-context-censusexit 1 then repaired by its own--fixand re-run OK (committed);check:skill-examplesexit 1 until client + client-react were built, then green; NOT MEASURED:check:dual-build-cjs-loads(exit 3, 36 packages outside the built closure have no dist) andcheck:type-check-debt(exit 3, needs the fullpackages/*closure) — declared to CI, not passes. The derivation printed a STALE TREE warning (origin/main is 6 commits ahead; 5 files it derives from changed) — none of those commits touch a file in this diff.验收备注
resumeInternalreturns{ success: false, error, durationMs }with nostatus, whileexecute()'s failure exits stampstatus: 'failed'. Within contract (absent means terminal) and it is why the plain arm here forwards no status; an asymmetry for the services lane to weigh, not a defect.StrandedDecisionDetails(@objectstack/types,POST /api/v1/approvals/requests/{id}/rejectreturns 500 while its effect lands AND strands the workflow run — three inconsistent outcomes from one call #13807) and the new spec structure now share therunId/repairablevocabulary as two declarations. The ruling asks the two approvals carriers (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, approvals: arecallwhose resume strands reports it as an ordinary non-failure — norepairablediscriminator, where the identical strand throughdecidecarries one #15970) to reuse the spec structure's members; whether the types-side envelope is re-based on it is the services lane's call.details.repairable(objectuiflowResponse.ts) is the client half the ruling anticipates and depends on service-automation: the two operator run-lifecycle verbs (cancelRun, restoreConsumedSuspension) have no door — no REST route, no CLI command, and not on IAutomationService #13953's operator door; the objectui tree was not readable from this container (403 on the pinned tree API), so it is cited from the card's pins, not re-read.POST /api/v1/approvals/requests/{id}/rejectreturns 500 while its effect lands AND strands the workflow run — three inconsistent outcomes from one call #13807 is not addressed here.🤖 Generated with Claude Code
https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
Generated by Claude Code