Skip to content

feat(runtime, spec): the resume door's 400 FLOW_FAILED details carry the engine's stranded verdict - #16587

Merged
os-sales merged 6 commits into
mainfrom
claude/issue-15221-flow-failed-stranded-details
Sep 7, 2026
Merged

feat(runtime, spec): the resume door's 400 FLOW_FAILED details carry the engine's stranded verdict#16587
os-sales merged 6 commits into
mainfrom
claude/issue-15221-flow-failed-stranded-details

Conversation

@os-sales

@os-sales os-sales commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Fixes #15221

The ruling this implements

Family ruling on #16472 (director seat, decision batch #76, maintainer verbatim 「同意」), recorded on the card at comment 5567698235option A: the resume door's 400 FLOW_FAILED details carry status: 'stranded' and repairable so a client can branch without a message regex; no FLOW_STRANDED sibling 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, the runId of the run that is actually stranded, and repairable — with one optional structure declared once in packages/spec and reused by the three carriers it names (#15556, #15970, this card).

Premise, re-confirmed on origin/main d1c86a7 before writing code

What changed

@objectstack/specResumeFailureDetailsSchema in packages/spec/src/api/automation-api.zod.ts (section 7b): { runId: string, status?: 'failed' | 'stranded', repairable: boolean }, exported with ResumeFailureDetails / ResumeFailureDetailsParsed. Non-strict on purpose: on the wire it rides inside the same error.details object as errorMessage / summary, and a client hands the whole details to the schema and gets the verdict back.

@objectstack/runtime — the resume door (POST /automation/:name/runs/:runId/resume) spreads resumeFailureDetails(runId, result) into its 400 FLOW_FAILED details 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); status is relayed.

@objectstack/clientautomation.resume docblock. Docscontent/docs/automation/flows.mdx (new subsection under Durable pause & resume), content/docs/api/client-sdk.mdx (resume paragraph after the FlowFailureDetails block). Generated — api-surface, export-origins, declaration-map, the api reference page, the strictness-ledger count (450 to 451 in api/, an untriaged directory — no ratchet moved), each regenerated by check:generated --fix for 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:

arm runId status repairable errorMessage / summary
stranded (the engine stamped 'stranded') the run the resume was addressed to 'stranded' true as before
plain terminal failure (today: the subflow-child exit, which stamps no status) same absent — never synthesised false, present as before
a producer that stamps 'failed' same 'failed', verbatim false as before
  • Present-and-false is the contract on the plain arm, not absence. An absent member would be indistinguishable from a server that predates this field; false is the honest answer for every exit that is not stranded, including the ones that report no status — the same posture StrandedDecisionDetails.repairable already fixed for the approvals door.
  • status is 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).
  • runId is 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 no runId on a terminal exit.
  • The trigger door and /actions are unchanged: their 400 FLOW_FAILED details stay { errorMessage?, summary? } with no repairable member. Neither door ever resumes, so "repairable" has no referent there — absent there means "not a resume", never "not repairable". Pinned.
  • The code stays 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 on details.repairable, never on the message. The console-side branch itself is objectui work and is not in this PR.
  • The coded refusal arms (403 / 400 / 404 / 503 / 409) and both 200 arms are byte-for-byte what they were. Pinned.

Clause ② — re-derived from the delivered diff, both limbs

Clause-②: yes

Verification (all at cde77edcc9, worktree objectstack-issue-15221)

Dependency closure built first (turbo run build --filter='@objectstack/verify^...', 32/32 tasks, then client + client-react), every heavy step under scripts/pm/os-verify-lock.sh; every exit code captured before any pipe.

  • spec: vitest run src/api/automation-api.zod.test.ts src/contracts/automation-result-status.pin.test.tsTest 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 pin ResumeFailureStatusIsTheContractsTerminalFailureSubset binds the schema's status to Extract(AutomationResult['status'], 'failed' | 'stranded').
  • runtime: full pnpm --filter @objectstack/runtime testTest Files 239 passed (239) · Tests 3375 passed (3375), VERDICT command-exit 0; typecheckVERDICT command-exit 0 (test layer 27 files / 191 errors / 69 pinned, unchanged ledger). New file domains/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.
  • verify (real AutomationEngine through the real HttpDispatcher, runtime resolved through its built dist): new automation-resume-stranded-details.test.ts — trigger parks a run, resume into a throwing node answers 400 FLOW_FAILED with { runId, status: 'stranded', repairable: true } beside errorMessage / summary, resume again answers 404, restoreConsumedSuspension(runId) answers restored: true (wire promise and verb agree); CONTROL: the non-throwing tail resumes to 200. Test Files 2 passed (2) · Tests 6 passed (6) (with automation-trigger-terminal-messages.test.ts); typecheck — OK.
  • client: typecheck — OK; check:skill-examples after building client + client-react — 257 prose examples type-check across 3 surfaces (the resume docblock example is one of the 23 client SDK blocks).
  • eslint (--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.
  • Declared narrowing: turbo ls --affected against 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 by git diff HEAD empty and git hash-object equal to the HEAD blob after each leg; trap-guarded script):

  • Leg 1 — the door disagrees with the spec declaration (repairable: 'yes'; injected=1, removed=0 on disk): runtime tsc --noEmit goes red at automation.ts(971,9): error TS2322: Type 'string' is not assignable to type 'boolean' — the door reads the rebuilt .d.ts, direction: turns red.
  • Leg 2 — the fix ablated (the ...resumeFailureDetails(...) spread removed; removed=0 on disk): the new runtime file answers 5 failed | 9 passed (14) — exactly the five new-behaviour pins red, the nine untouched-arm pins green.

Gatesnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths) at cde77edcc9: Run reconciliation — 99 derived, 99 run, 0 UNRUN. Ledger: 95 exit 0; check-system-context-census exit 1 then repaired by its own --fix and re-run OK (committed); check:skill-examples exit 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) and check:type-check-debt (exit 3, needs the full packages/* 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.

验收备注

🤖 Generated with Claude Code

https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ


Generated by Claude Code

…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
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/client, @objectstack/runtime, @objectstack/spec, touching 10 documentable anchor(s). ⚠️ 5 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/authorable-surface/api.json, packages/spec/declaration-map/api.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/client-sdk.mdx (via ResumeFailureDetailsSchema (symbol, a top-level const), automation.resume (sdk, the route ledger binds it to POST /automation/:name/runs/:runId/resume, selected by route anchor /:name/runs/:runId/resume))
  • content/docs/automation/approvals.mdx (via /:name/runs/:runId/resume (route, a path literal on a changed line))
  • content/docs/automation/flows.mdx (via ResumeFailureDetailsSchema (symbol, a top-level const), /:name/runs/:runId/resume (route, a path literal on a changed line))

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

  • content/docs/releases/v17.mdx (via automation.resume (sdk, the route ledger binds it to POST /automation/:name/runs/:runId/resume, selected by route anchor /:name/runs/:runId/resume), /:name/runs/:runId/resume (route, a path literal on a changed line))

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
  • 5 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/authorable-surface/api.json, packages/spec/declaration-map/api.json, …) — pages documenting those are invisible to this run
  • 1 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 — 136 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 b4abb0a915bbcc6ba42f6a5b2a1f46d20d3b3a9bpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 8daaffeeaf87c26e83505e5aa9f8d143023aa0df — the merge of head c3208dfe0e1b62adfe63b510a60ca8f8f8b57815 into base b4abb0a915bbcc6ba42f6a5b2a1f46d20d3b3a9b, 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 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

⚠️ 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 b4abb0a915bbcc6ba42f6a5b2a1f46d20d3b3a9b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 7, 2026
… 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
@os-sales
os-sales marked this pull request as ready for review September 7, 2026 12:14
@os-sales
os-sales enabled auto-merge September 7, 2026 12:14

os-sales commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review COMPLETE — both carriers cleared, un-drafted, armed

domain:cli execution PM seat (#6024), session session_01YFY46JydE1gMxQG1TqBcMZ, R70, 2026-09-07T12:15Z.

The review, and what it changed

Clause-② yes on both limbs (path: packages/spec/src/api/automation-api.zod.ts; content: a published error envelope widened on a published route plus a new exported wire-contract schema). This seat runs claude-opus-5, which is below CONTRACT_REVIEW_TIER, so the review was not self-served — it went to an at-tier reviewer, which returned two findings. Both were implemented, not waived:

  1. 782b92ceb5 — the relayed status is now bound to the published enum by construction: resumeFailureDetails returns ResumeFailureDetails, relaying through a guard over 'failed' | 'stranded' that is satisfies-bound to the schema's own enum. It stays a relay — the door still never synthesises a verdict the producer did not stamp (PD Add comprehensive test suite for Zod schema validation #12). The same commit adds the exact-equality negative pin on /actions that the docblock had claimed but not carried.
  2. c3208dfe0e — the census re-anchor the guard's insertion caused, rewritten by check-system-context-census --fix itself. Pure line rot: one anchor moved :1057:1079, the gate refused nothing, and the population is unchanged at 106 sites / 141 anchors. ⛔ No anchor was hand-edited.

The dev's third ablation leg is what makes the binding load-bearing rather than decorative: removing the return-type annotation reds tsc with TS2322 at automation.ts(994,5). A binding that cannot go red is not a binding.

Why the clear is a CLEAR

⚠️ needs:contract-review was removed from both carriers within seconds of each other — PR #16587 and card #15221 — which is what distinguishes a legitimate clear from a strip of one carrier. The two gates read different carriers (check-clause2-carriers --pair reads the card's claim comment; Check Changeset reads the PR body), so a green from one says nothing about the other; both were satisfied before either label came off.

CI at the head that is landing

c3208dfe0e — 34 check runs, collapsed latest-per-name: every one success, two skipped (Console Pin Gate, Packed-tarball opt-in). Lint & Repo Gates closed last at 12:11:21Z.

⚠️ Recorded because it cost real time and would have cost more: three aggregator reds on this PR were phantoms, every one on a head that had already been superseded — TypeScript Type Check on cde77edcc9 (3 of 4 lanes cancelled), Test Core on aa5b987ae2 (job log 235 bytes, cleanup only, no test body ran), and TypeScript Type Check on 782b92ceb5 (typecheck-source-gates: success, the other three lanes cancelled by the next push). The aggregator renders a cancelled lane as failure. ⇒ On this repo, read the lane conclusions or the job log, and check which head the run belongs to, before believing a check name. ⛔ No dev time was spent chasing any of the three.

Un-drafted, then armed (in that order — auto-merge does not survive a draft conversion).


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queue Sep 7, 2026
Merged via the queue into main with commit 68437d4 Sep 7, 2026
39 checks passed
@os-sales
os-sales deleted the claude/issue-15221-flow-failed-stranded-details branch September 7, 2026 12:57
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 size/l tests tooling

Projects

None yet

2 participants