Skip to content

fix(automation): report a subflow parent stranded by a failed up-bubble at error, not warn — plus the reproduction #15556 asked for - #15903

Merged
os-warren merged 7 commits into
mainfrom
claude/issue-15556-subflow-bubble-swallow
Sep 5, 2026
Merged

fix(automation): report a subflow parent stranded by a failed up-bubble at error, not warn — plus the reproduction #15556 asked for#15903
os-warren merged 7 commits into
mainfrom
claude/issue-15556-subflow-bubble-swallow

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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 catch were found by reading engine.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_parent parks at a subflow node; the child deal_approval parks at an approval node, so the approvals row names the child run; the approvals decision door resumes the child; the child completes; bubbleToParent resumes the parent; the parent's own downstream node throws.

Measured, at HEAD:

fact
parent's resume envelope { success: false, status: 'stranded', error: THE_NODE_TEXT } — and no code at all
parent run hasSuspendedRun false · resume answers RUN_NOT_FOUND · history row failed
parent repairability restoreConsumedSuspension(parent) answers restored: true, node sub
child run completed
approval row approved, terminal
decision door no throw, resumed: true, no resumeError, runId names the child
artefact one warn line

So 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 B drives the direct (non-subflow) shape through the same door in the same run and it throws RESUME_FAILED carrying repairable: true. So "the door did not throw" above is a fact about the composition, not about a mis-wired harness. CONTROL A drives 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 the restoreConsumedSuspension verb that repairs it;
  • every other parent-resume failure → 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) and STORE_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 how error becomes 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.ts sites 12 and 13) are unchanged and still green — site 12's fixture reports no status, 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:

  • AutomationResult would have to carry the bubble failure on a success envelope, and
  • ApprovalDecisionResult.resumeError is documented as "why the run was not resumed, when resumed is false", so resumed: true plus 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_FAILED naming 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.

  • Mutation check, both directions, on this head, mutation proved on disk (git hash-object delta plus marker counts) and restore proved by an empty git diff HEAD and the blob back at HEAD's, under a trap:
    • GREEN leg: 4 passed.
    • 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): the RESUME_IN_PROGRESS reverse control goes RED.
  • Suites, on a closure rebuilt after merging origin/main: service-automation 113 files / 1356 tests passed; plugin-approvals 38 files / 668 tests passed. ⚠️ An earlier run showed condition-validator failures; those were a stale dist/ predating the merge, and a full rebuild cleared them — no red remains to attribute.
  • Typecheck: both packages, exit 0.
  • Gates: the family re-derived from the actual changed files with scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack; 30 families run locally, all exit 0. check:engine-double-contract reported RETAINED for the new test file's (correctly pinned) double and was repaired with its own --write, as the gate instructs. check:durability-log-level exit 0. check-adr-0087-registration --base origin/main --head bbeeb3170 exit 0, with --self-test exit 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

…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
…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
@github-actions github-actions Bot added the size/l label Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • 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 — 5 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 da1cffb755ab5c528badc6401eaaf602b5210d74packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2249a036f95d15b296351c1cef73057a84ea61ea — the merge of head 4e91f34bead02771d04050dbe0ba8b2be25097fe into base da1cffb755ab5c528badc6401eaaf602b5210d74, 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 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

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

Copy link
Copy Markdown
Collaborator Author

Clause-② contract review — PR #15903 (card #15556) — verdict: PASS

Tier: CONTRACT_REVIEW_TIER = claude-fable-5-1 (on this head scripts/pm/dispatch-gates.mjs:9852; the PM's :8659 is its own tree's line). Evidence: the PM's attestation that this Agent call carried an explicit model: fable override, plus my own system-prompt identity (claude-fable-5-1) — override + self-report. Not read from get_session (that describes the parent session and cannot see a per-subagent override), and not claimed as an "exact match" reading; the transcript-stamp verification is the parent's.

Independence pair (C4, machine spelling):

  • Implemented-by: claude/issue-15556-subflow-bubble-swallow
  • Reviewed-by: session_01XpTx2tbq3pZRYAdoGt6E6Y

The dev was a mode:subagent of this same PM session, so its identity is its branch (2026-09-02 ruling, reading a); the two grammars are disjoint, so this is not the SELF-REVIEW case by the rule's own comparison. Stated for the record rather than assumed: I am a subagent of the same seat, and my brief carried the seat's summary of the dev's claims (which contract-review.md's isolation line says not to feed a second-opinion reviewer). I treated every sentence of it as a claim to falsify and re-measured each below; nothing in this verdict rests on the brief's or the dev's word.

Subject: head bbeeb3170, merge-base 99a5bc674, detached worktree /home/user/objectstack-review-15903 (own pnpm install, own closure build: turbo 23/23 successful, 1 cached, 6m48s, exit 0). 5 files, +657/−10. Head match, merge-base, file set, the seven fences (with service-automation = 2 as the control) and check-adr-0087-registration were verified by the PM before dispatch and are not redone here.

1 · The reproduction is real, and the door is real (blocking bar — met)

  • Door: the test calls ApprovalService.decide (approval-service.ts:2888) — the public contract entrypoint — which goes resumeRecordedOutcome (:2836) → serviceResume (:2663) → this.automation.resume(...). ApprovalService and registerApprovalNode are imported from ./approval-service.js / ./approval-node.js (the real files); AutomationEngine, InMemorySuspendedRunStore, installBuiltinNodes from @objectstack/service-automation, and installBuiltinNodes registers the real subflow node (builtin/index.ts:75). The only double is the data engine (in-memory tables), and it is routed through assertEngineDeleteDispatch / assertEngineUpdateDispatch — which is what the double-contract gate pins. resumeInternal is wrapped (calls the real implementation, records the parent's envelope) only in the stranded test, never stubbed. Not a hand-rolled door.
  • Resolution: plugin-approvals/vitest.config.ts aliases only metadata-core and metadata-protocol to source, so @objectstack/service-automation resolves to the built dist/ — the reproduction ran against the artefact, on a closure I built. 3/3 green.
  • Controls hold: CONTROL B throws RESUME_FAILED with { finalized, decision, runId, repairable: true } through the same door in the same run (the door can fail); CONTROL A answers the same FULL_SUCCESS literal as the stranded composition (the indistinguishability is asserted against one shared constant, not a value smuggled between tests). The parent-side facts — hasSuspendedRun false, resumeRUN_NOT_FOUND, history failed, restoreConsumedSuspensionrestored: true, the bubbled envelope { success:false, status:'stranded' } with no code — are each asserted and each green. The card's headline reproduces, and the sharper reading (the runId handed back names the healthy child) is asserted at subflow-hosted-approval-strand.test.ts:263.

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 resumeInternal (engine.ts:5065–5575) rather than take the PR body's enumeration:

exit line suspension where the parent is
RESUME_IN_PROGRESS (in-process guard) :5081 untouched parked, or being advanced by this process's other resume
STORE_UNAVAILABLE (strict load) :5130 not consumed (its own text) parked; identical resume works later
RUN_NOT_FOUND ×3 (#8684) :5135/:5160/:5164 nothing ran gone / flow or node unregistered — visible
INVALID_SIGNAL :5316 pre-claim parked
RESUME_IN_PROGRESS (claim lost) :5379 consumed by another replica advancing there
STORE_UNAVAILABLE (claim threw) :5400 UNKNOWN (its own text) see caveat (i)
re-suspend :5480 re-persisted success:true, status:'paused' — not in this arm
catch arm :5508 journal → :5570 stamp consumed + journalled, recorded failed stranded — repairable only by restoreConsumedSuspension

A node's returned success:false with no fault edge throws (executeNode :7589:7659), so every post-consume failure lands on the catch arm. ⇒ 'stranded' is the one exit that is consumed and journalled and terminal and repairable — the dev's claim holds. Not too wide: the stamp sits after forgetSuspendedRun and the journal, hasSuspendedRun is already false and resume answers RUN_NOT_FOUND (both asserted in the new pin and held by stranded-run-status.test.ts / consumed-suspension-restore.test.ts, 60/60 green here).

3 · Overclaim audit (blocking bar — met, with one required text fix)

  • The card's headline is not claimed fixed anywhere: PR title says "report … at error, not warn"; body has a "⛔ What this does NOT do" section; the PR is Part of, not Fixes (CI "Part-of PR must not also close its card" green); the changeset ends "⚠️ This is the log half only. What the child's resumer is told is unchanged." Both carriers state the limit plainly. ✓
  • ⚠️ The one clause: changeset line "Every other parent-resume failure — a concurrent resume, an unreachable store, a thrown resume — stays at warn unchanged, because on those exits the parent is still parked and resumable." The behaviour half is true (measured, M2). The reason half is false for a thrown resume in the service-automation: a throw between journalConsumedSuspension and the status: 'stranded' stamp makes a REPAIRABLE strand report repairable: false — the operator is told not to attempt a repair that works #15555 window and unproven for the claim-path store failure (its own text says UNKNOWN). The changeset feeds release notes, and as written it states a platform guarantee an open card already falsifies. Required before undraft — PM's call, text-only, no pin or code moves: replace the reason with the PR body's, e.g. "…stays at warn unchanged: those exits carry no stranded discriminator (a thrown resume carries none at all — service-automation: a throw between journalConsumedSuspension and the status: 'stranded' stamp makes a REPAIRABLE strand report repairable: false — the operator is told not to attempt a repair that works #15555 tracks the window in which a throw can hide a journalled strand)". This is exactly the "uncovered clause" shape the session has hit three times; naming it is the point.
  • Contract claims checked: AutomationResult (packages/spec/src/contracts/automation-service.ts:222) carries success/output/error/durationMs/code/status/runId/screen/successMessage/errorMessage/summary — no field for a failed up-bubble on a success:true envelope; pressing error into it would be a semantic widening, so "no carrier" is true in contract terms. ApprovalDecisionResult.resumeError (approval-service.ts:645–648) reads "Why the run was not resumed, on the one path that tolerates it: a concurrent duplicate resume" — narrower than the dev's paraphrase, in the dev's favour: resumed:true + a reason is even further from the contract than quoted. Site 12's fixture (engine-residual-log-cause.test.ts:560) is { success:false, error } with no status — it is the reverse control the dev says it is; sites 12/13 are untouched by the diff and green.

4 · Mutation legs — both discriminate on the named test

Run in my worktree with no build or suite touching engine.ts concurrently (the build had finished; the legs ran after the suites). Mutation proved by git hash-object delta + anchor/marker counts; restore under trap … EXIT INT TERM, proved by an empty git diff HEAD and the blob back at HEAD's 102faf78.

leg engine.ts blob anchor / marker result red test
GREEN 102faf78 (= HEAD) 1 / 0 exit 0 — 4 passed
M1 === 'stranded' && false (branch unreachable) 1edbe762 0 / 1 exit 1 — 1 failed | 3 passed a STRANDED parent is reported at error, naming the run and its repair verb
M2 === 'stranded' || true (whole arm) 09eabc31 0 / 1 exit 1 — 1 failed | 3 passed REVERSE CONTROL — a parent failure the engine does NOT call stranded stays warn

Both restores: same=yes diff-empty=yes. M2 is the one that matters and it does what is claimed: the narrowing is pinned as deliberate, not incidental. (The thrown-arm reverse control stays green under M2 by construction — it lives in the catch, outside the mutated arm — which is why the RESUME_IN_PROGRESS control, not that one, is the discriminating pin.)

5 · Logger call shape

packages/spec/src/contracts/logger.ts:37error(message: string, error?: Error, meta?: Record<string, any>). Real. undefined in the Error slot with diagnostics third is the same shape the engine's own STORE_UNAVAILABLE error uses (engine.ts:5122, citing #5575 for the same reason). Meta carries { error: <parent envelope text>, parentRunId, status: 'stranded' } — what an operator needs; the new pin asserts the slot layout positionally (errorSlot undefined, meta third). Message: one physical line (pinned), consequence + fix in that line per AGENTS.md's durability rule, foreign node text kept out of it (#6499, pinned by not.toContain(DOWNSTREAM_FAILURE)). Granularity: once per stranded parent = once per repairable fact. Nit only: it is ~600 chars; acceptable for a rule that wants everything in the first line.

6 · Honesty audit

  • (a) exit 254 — reproduced the mechanism in my worktree: pnpm --filter @objectstack/service-automation exec <missing-cmd>exit 254, ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL Command "…" not found. 254 is pnpm's own code for that class; vitest exits 0/1, so a 254 can never be a test verdict. NOT MEASURED was the only honest reading of it. ✓
  • (b) injected-after=0 — no artefact survives to re-verify; a guard voiding its own leg is the mechanism working (cf. fix(plugin-security): retire seven dead { records } find-result limbs, and stop the permission-set loader inventing an empty page #15838 §6 for the grep -F multi-line trap). Taken as reported; NOT MEASURED by me.
  • (c) stale dist/ — verified two ways. First, a closure I built from scratch on this head (23/23, exit 0) runs both suites fully green at the dev's exact counts: service-automation 113 files / 1356 tests, plugin-approvals 38 files / 668 tests, typecheck exit 0 both packages — there is no red to attribute, so nothing is being masked at this head. Second, the mechanism is corroborated: the window f141e15..99a5bc6 (the dev's claim base → merge-base, 28 commits) contains service-automation: refuse a structural flow condition that is neither CEL text nor an expression #15792 9408b7f02 "refuse a structural flow condition that is neither CEL text nor an expression" — a condition-validator change in service-automation, i.e. exactly the files the dev named (engine.test.ts, canonicalize-stored-flow, run-summary). A tree with a dist/ built before that merge and tests run after it fails there. Stale-dist, not hidden breakage.
  • check:engine-double-contract re-run at HEAD (self-test + run): exit 0, 789 pinned / 133 debt / 3 exempt / 725 RETAINED rows. pinned.json diff vs merge-base: +2 rows (subflow-hosted-approval-strand.test.ts, delete and update, pinned: 1 each), 0 removed, no count decreased; the DEBT baseline is untouched. The ledger grew; nothing pre-existing was loosened. check:durability-log-level: exit 0 (29 seams, all loud).
  • NOT MEASURED trio, placed on CI for head bbeeb3170: check:dts-closure runs in Build Core (ci.yml:1506), Type Check · debt ledger (lint.yml:5037) and Type Check · consumer gates (:5225); check:dual-build-cjs-loads in Build Core (ci.yml:1592) and Type Check · workspace (lint.yml:4774); check:type-check-debt in Type Check · debt ledger (:5066). All five of those jobs are success on this head. At 14:0xZ the only run still in progress is Lint & Repo Gates, which is not the carrier of any of the three; landing pre-check ③ needs it green regardless. Not measured by me locally — the dev's reasoning (full-closure requirement, refusal ≠ pass) is right.

7 · The A/B/C/D frame — stated fairly; two costs to add (⛔ not ruling)

  • A under-states its cost: AutomationResult lives in packages/spec (a fenced surface for this lane; a spec addition carries ADR-0087 registration), and the generic resume door (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, packages/runtime, cli lane) would need to relay the same carrier for the family answer to be one answer.
  • B is mis-costed in one respect: it is not an alternative to an engine-side carrier. The door can only throw about a parent strand if the child's resumer is told about it, so B = A's carrier (or another engine→resumer signal) plus the throw. And StrandedDecisionDetails.runId would then name a run other than the request's own flow_run_id — a semantic shift in an existing field, beyond the status-code change the dev lists.
  • C and D are fairly stated. The recorded interaction (B re-opens AGENTS.md's third legal answer; the resumeRecordedOutcome precedent logs and throws at different layers) is correct and should stay in front of whoever rules. §2 caveat (i) belongs there too: a caller-facing shape is the only one that also covers the claim-path store failure during a bubble.
  • The dev's refusal to invent a decision frame was correct. I express no preference among the four.

Checklist (derived judgments · semver · flags)

Public surface: nothing moves — no new export, no new key on any published payload; AutomationResult / ApprovalDecisionResult untouched; bubbleToParent's accept-set unchanged (still best-effort, still returns void, still never throws at the child's resumer). ② Semver: patch on @objectstack/service-automation is right for a log-level change; plugin-approvals gains a test file only, no changeset owed. ③ Flags: one open_questions entry, correctly escalated to the maintainer rather than decided; no out_of_scope_findings, and I found none the dev should have listed.

Verdict — PASS

The reproduction drives the real approvals door against the built artefact and every fact in the PR table is asserted and green; the narrowing to status === 'stranded' is the only exit the engine itself marks consumed-journalled-repairable, pinned in both directions by legs that redden the named test and only that test; the logger call matches the Logger contract and its own in-file precedent; both red-first gates were repaired by growing a ledger, not loosening one; the stale-dist/ incident is corroborated by an independent rebuild and by the commit that caused it; and the card's unfixed half is stated plainly on both carriers. Requested before undraft, PM's call and text-only: correct the changeset's reason clause per §3 — it asserts a guarantee #15555 already falsifies. Non-blocking notes: PR-body STORE_UNAVAILABLE parenthetical is the load-path producer's guarantee only (§2-i); message length nit (§5); two costs to add to the decision frame (§7). ⛔ Nothing pushed, undrafted or merged; no git stash. Worktree left provably clean at bbeeb3170: git status --short 0 lines, git diff HEAD 0 lines, engine.ts blob 102faf78 = HEAD's. All scratch scripts and logs are suffixed -15903-review (17 files).


Generated by Claude Code — reviewer for the domain:services PM seat (PM session 03324ae2-0f5b-5ad2-8a2e-cf4aaff5a909)

🤖 Generated with Claude Code

https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y


Generated by Claude Code

…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
@os-warren
os-warren marked this pull request as ready for review September 5, 2026 14:26

Copy link
Copy Markdown
Collaborator Author

PM verification of the fix-up — undrafted and armed

Verified at head 4e91f34bead02771d04050dbe0ba8b2be25097fe. Text only, one file, and I proved that rather than accepting it: engine.ts is byte-identical to the reviewed head bbeeb3170 by blob hash, with the changeset as the control in the same comparison (it reports CHANGED, so the identical result is a measurement and not a broken check). The PR's own file set against its merge-base is unchanged at 5 files; only the changeset grew, +15/−2.

node scripts/check-adr-0087-registration.mjs --base origin/main --head 4e91f34be…exit 0.

The flagged clause is gone and the replacement says the right thing. "still parked and resumable"0 occurrences; the journal/discriminator argument → 3; an impossible control string → 0. Both caveats the review asked for are in the changeset itself rather than only the PR body, which is correct because the changeset is what reaches release notes:

⭐ Worth naming: the original changeset justified the warn with the same "parent is still parked and resumable" enumeration that this PR's own reproduction falsified. The reproduction is the whole point of the card, and the changeset was still arguing from the disproved premise. That is the fourth time this session the wrong clause was the clause no pin covered (#15720's rider, #15787's cost sentence, #15838's "unchanged in both directions", now this) — and the fourth time reading prose against the measurement is what caught it.

The dev also closed out last round's honest gaps. The three families previously declared NOT MEASURED — check:dts-closure, check:dual-build-cjs-loads, check:type-check-debt — were prerequisite-blocked on an unbuilt workspace, not skipped. It ran the full build (71 tasks) and re-ran all three: exit 0, 0, 0 (the last re-measuring 12 ledger entries, 140 raw tsc errors, none above its recorded number). check:i18n likewise first answered 3, had its prerequisite built as its own message instructs, and re-ran 0. ⛔ None was reported as a pass while it was a 3. Nothing remains NOT MEASURED on this head.

⭐ 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 Part of: the log half ships, the door still answers full success over a stranded parent, and the A/B/C/D contract decision is the maintainer's. The card stays open.

Undrafted and auto-merge armed.


Generated by Claude Code

@os-warren
os-warren added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit 1375344 Sep 5, 2026
35 checks passed
@os-warren
os-warren deleted the claude/issue-15556-subflow-bubble-swallow branch September 5, 2026 17:27
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

Development

Successfully merging this pull request may close these issues.

2 participants