fix(automation): report a subflow parent stranded by a failed up-bubble at error, not warn — plus the reproduction #15556 asked for - #15903
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…le at `error`, not `warn` An approval hosted in a subflow child was driven end to end for the first time (the card was filed NOT MEASURED). It reproduces: the child's resume completes, `bubbleToParent` resumes the parent, the parent's downstream node throws, and `resumeInternal` answers the #13937 stranded exit — `{ success: false, status: 'stranded' }`, no `code` — which consumed the parent's suspension, journalled a repair snapshot and recorded the run `failed`. Nothing in the engine moves it again; only `restoreConsumedSuspension` can. The seam's #4632 verdict was FUNCTIONAL for the whole arm, on the enumeration "the parent either failed terminally or stays visibly parked and resumable". The measurement falsifies it: a stranded parent is neither, the approval row is durably terminal, and the decision door answered its caller success. That is AGENTS.md's DURABILITY class verbatim, and the rule's third legal answer (a failure handed to the CALLER) does not apply because no caller is told. The level is now graded by the engine's own discriminator, never by this seam's guess: `status === 'stranded'` reports at `error`, naming the run and the repair verb; every other exit stays `warn` unchanged, because escalating a parent that is still parked is how `error` becomes unreadable.⚠️ The log half only. What the child's resumer — and through it the approvals decision door — is TOLD still reads as full success; making that truthful moves a public contract and is #15556's open decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…bflow-bubble-swallow
…nned ledger `check:engine-double-contract` reported RETAINED on both write verbs for the new test file: its fake engine routes delete()/update() through ObjectQL's own dispatch predicates, but the pinned ledger had no row for the file, so the coverage protected nothing. Written by `--write`, as the gate instructs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…bflow-bubble-swallow
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 5 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 2249a036f95d15b296351c1cef73057a84ea61ea && git checkout 2249a036f95d15b296351c1cef73057a84ea61ea
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin da1cffb755ab5c528badc6401eaaf602b5210d74 4e91f34bead02771d04050dbe0ba8b2be25097fe && git checkout -B drift-repro da1cffb755ab5c528badc6401eaaf602b5210d74 && git merge --no-ff 4e91f34bead02771d04050dbe0ba8b2be25097fe
node scripts/docs-audit/affected-docs.mjs --json da1cffb755ab5c528badc6401eaaf602b5210d74 |
Clause-② contract review — PR #15903 (card #15556) — verdict: PASSTier: Independence pair (C4, machine spelling):
The dev was a Subject: head 1 · The reproduction is real, and the door is real (blocking bar — met)
2 · The narrowing — correct at the seam's level of knowledge; two caveats the ruling should see (blocking bar — met)I enumerated every exit of
A node's returned
3 · Overclaim audit (blocking bar — met, with one required text fix)
4 · Mutation legs — both discriminate on the named testRun in my worktree with no build or suite touching
Both restores: 5 · Logger call shape
6 · Honesty audit
7 · The A/B/C/D frame — stated fairly; two costs to add (⛔ not ruling)
Checklist (derived judgments · semver · flags)① Public surface: nothing moves — no new export, no new key on any published payload; Verdict — PASSThe reproduction drives the real approvals door against the built artefact and every fact in the PR table is asserted and green; the narrowing to Generated by Claude Code — reviewer for the 🤖 Generated with Claude Code https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y Generated by Claude Code |
…bflow-bubble-swallow
…gument, and name the two exits that are not healthy The changeset for this PR justifies leaving every non-stranded parent-resume exit at `warn`. The justification it now gives is the one this seam can actually stand behind: those exits carry no `'stranded'` discriminator, and `'stranded'` is the single exit that journals a repair snapshot — so it is the single exit an operator can act on, and grading by the engine's own verdict is what keeps `error` readable rather than skimmed. That is a claim about what the seam KNOWS, and the changeset now says so, plus the two exits that are known NOT to have left the parent healthy: * a THROWN parent resume carries no discriminator at all, and #15555 documents a window in which a throw landing between the journal and the stamp hides a parent that is genuinely stranded. This seam leaves that arm at `warn` deliberately and that card tracks it. * the CLAIM-PATH store failure states in its own envelope text that whether the suspension was consumed is UNKNOWN, and settles it by retry — which an up-bubble has no retrier to perform. "Not consumed" is the guarantee of the strict-load store failure alone, never of every store failure. Text only: no source, no pins and no ledger move, so the measured behaviour and both mutation legs are untouched. A blanket "the parent is still parked and resumable" would have read as a platform guarantee across all three exits, and release notes are built from this file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
PM verification of the fix-up — undrafted and armedVerified at head
The flagged clause is gone and the replacement says the right thing.
⭐ Worth naming: the original changeset justified the The dev also closed out last round's honest gaps. The three families previously declared NOT MEASURED — ⭐ And it wrote the commit message to state the corrected reason on its own terms rather than referring back to what it replaced — so it stands up inside a concatenated squash message. That is the mitigation for #15913, applied by hand one PR after the gap was filed. ⛔ This PR does not close #15556. It is Undrafted and auto-merge armed. Generated by Claude Code |
Part of #15556 — the reproduction, plus the half of it that an existing ruling already settles. ⛔ Deliberately not
Fixes: the card's headline half (the decision door answers full success) needs a maintainer ruling and is still open. See "What this does NOT do".The card asked for a measurement first. It reproduces.
The card was filed NOT MEASURED — the seam and its swallowing
catchwere found by readingengine.ts, and nobody had driven a subflow-hosted approval end to end. This PR drives it, with its controls in the same run.Composition:
deal_parentparks at asubflownode; the childdeal_approvalparks at anapprovalnode, so the approvals row names the child run; the approvals decision door resumes the child; the child completes;bubbleToParentresumes the parent; the parent's own downstream node throws.Measured, at HEAD:
{ success: false, status: 'stranded', error: THE_NODE_TEXT }— and nocodeat allhasSuspendedRunfalse ·resumeanswersRUN_NOT_FOUND· history rowfailedrestoreConsumedSuspension(parent)answersrestored: true, nodesubcompletedapproved, terminalresumed: true, noresumeError,runIdnames the childwarnlineSo the card is right on every point, and one point sharper than it claimed: the run the door hands back is the healthy one. Nothing in the response reaches the stranded parent.
The
status: 'stranded'above is the same discriminator PR #15436 taught this door to read one level down. It has a producer here and, until this PR, no consumer.The control that makes the reading trustworthy
CONTROL Bdrives the direct (non-subflow) shape through the same door in the same run and it throwsRESUME_FAILEDcarryingrepairable: true. So "the door did not throw" above is a fact about the composition, not about a mis-wired harness.CONTROL Adrives the healthy composition and the door's resume-facing answer is the same literal — which is the defect stated as a measurement: no caller can tell a dead parent from a live one.What this PR changes
One branch in
bubbleToParent, graded by the engine's own discriminator:status === 'stranded'→error, naming the parent run and therestoreConsumedSuspensionverb that repairs it;warn, unchanged.Why this half needs no ruling
The seam's recorded #4632 verdict was FUNCTIONAL for the whole arm, on this enumeration: "the parent either failed terminally (recorded in run history) or stays visibly parked and resumable". The measurement falsifies it. A stranded parent is neither: its suspension is consumed, a repair snapshot is journalled, nothing in the engine will move it again, and meanwhile the approval row is durably terminal and the caller was told success.
That is AGENTS.md's DURABILITY class verbatim — "persisted state and runtime state disagree. Nothing looks broken" — and the rule's third legal answer (a failure handed to the CALLER is not a degradation) is measurably inapplicable, because no caller is told. So this is an existing binding rule applied to a site whose classification rested on a premise nobody had tested, not a new judgement.
Why it is not the whole arm
RESUME_IN_PROGRESS(a replica is already advancing the parent) andSTORE_UNAVAILABLE(the suspension was never consumed, so the parent stays parked and the identical resume works later) are exactly the functional cases the old verdict was right about. Escalating those is howerrorbecomes unreadable — the rule says so itself. The thrown arm is untouched for the same reason: a throw carries no discriminator, so there is no measurement behind it.The two existing pins for these seams (
engine-residual-log-cause.test.tssites 12 and 13) are unchanged and still green — site 12's fixture reports nostatus, so it is the reverse control for the new branch.⛔ What this does NOT do
The child's resumer — and through it the approvals decision door — is still told full success. Making that truthful means a public contract moves:
AutomationResultwould have to carry the bubble failure on a success envelope, andApprovalDecisionResult.resumeErroris documented as "why the run was not resumed, whenresumedis false", soresumed: trueplus a reason is a shape the contract does not currently have.There are at least three defensible answers (a new field with the door still answering 200; the door throwing
RESUME_FAILEDnaming the parent as the stranded run, mirroring the #13807 ruling one level up; or leaving the caller's answer alone). They lead to different architectures, and the maintainer ruled the analogous question one level down explicitly (2026-09-04, decision batch #37, option B). ⛔ Not decided by implementation here. The reproduction test characterises today's answer with a loud comment so whatever ruling lands turns it red on purpose.Triage also reserved the family-level generalisation (must a resume failure always reach the caller in an actionable shape?) for a separate card. ⛔ Nothing here folds in #15555 or #15221; those two remain open and untouched.
Verification
Every exit code captured right after a single redirected command.
git hash-objectdelta plus marker counts) and restore proved by an emptygit diff HEADand the blob back at HEAD's, under atrap:M1— the stranded branch made unreachable (the pre-fix behaviour): the stranded pin goes RED, the three controls stay green.M2— the branch widened to the whole arm (over-application): theRESUME_IN_PROGRESSreverse control goes RED.origin/main:service-automation113 files / 1356 tests passed;plugin-approvals38 files / 668 tests passed.dist/predating the merge, and a full rebuild cleared them — no red remains to attribute.scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack; 30 families run locally, all exit 0.check:engine-double-contractreported RETAINED for the new test file's (correctly pinned) double and was repaired with its own--write, as the gate instructs.check:durability-log-levelexit 0.check-adr-0087-registration --base origin/main --head bbeeb3170exit 0, with--self-testexit 0 as its control in the same session.All of the above were re-run on the final commit.
🤖 Generated with Claude Code
https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Generated by Claude Code