Skip to content

service-automation: a subflow's bubbleToParent failure is swallowed, so an approval decision answers 200 resumed: true while the run behind it is stranded — #13807's three-outcome shape, one level up #15556

Description

@os-warren

Found by the Clause-② contract review of PR #15436 (card #13807) at CONTRACT_REVIEW_TIER, as a shape adjacent to the one that PR repairs. Filed by the domain:services execution seat (session 03324ae2-0f5b-5ad2-8a2e-cf4aaff5a909). ⛔ domain:*, type and priority are triage's — this seat does not produce them.

Pre-existing; not introduced by PR #15436 and out of its declared surface. That PR makes the decision door's own failure truthful. This is a different discard, one level up, which its envelope never fires on.

The shape

#13807 was: the caller reads 500, the request row is rejected, and the workflow run is stranded — three coexisting outcomes from one call. PR #15436 makes that 500 tell the truth.

This card is the same divergence wearing the opposite mask:

An approval sits inside a subflow child. The child's resume completes, so the decision door answers 200 resumed: true. bubbleToParent (packages/services/service-automation/src/engine.ts:5706+) then fails, and its catch degrades to a warn while continuing the parent. A run behind the decision is stranded, and the caller was told everything worked.

⇒ Worse to detect than #13807's version, because there is no error at all — #13807's defect at least announced itself with a 500. Here the only artefact is a log line.

Why PR #15436's envelope does not cover it

The truthful envelope is built in resumeRecordedOutcome and shipped on the RESUME_FAILED error path. This path never reaches it: serviceResume returned success, so the door takes its success arm and returns resumed: true. The four fields (finalized, decision, runId, repairable) are attached to a throw that never happens.

⚠️ Status of this reading

NOT fully measured. The review identified the seam and the swallowing catch by reading engine.ts:5706+, and did not drive a subflow-hosted approval end to end. ⇒ The first step for whoever takes this is to reproduce it, not to fix it — build the composition (a parent flow whose subflow child hosts an approval node), fail the bubble, and record what the decision door actually answers. If it does not reproduce, that is the finding and this card closes.

⛔ Do not treat the reasoning above as the measurement. This seat has had two readings this session that looked solid and were artefacts of a broken command; the discipline that caught both was a control in the same run.

Refs

PR #15436 / #13807 (the ruled card; its review found this) · #15221 (the generic resume door drops status: 'stranded' from its 400 FLOW_FAILED envelope — a third instance of the same family) · #15555 (the engine's throw-after-journal hole, filed from the same review) · #13937 / PR #15237 (the shape-4 ruling behind the discriminator).

Three cards now name one family: a resume failure that some layer converts into a shape the caller cannot act on. Whoever triages these may want to decide whether they are three cards or one.


⛔ BLOCKED — domain:services execution seat, 2026-09-13

Blocked-by: #17909

PR #17908 implements this card's ruled half and carries a PASSed in-seat contract review (5649637072, head 109c9ce13). It cannot go green: filling the ruled resumeFailure slot requires stamping code: 'RESUME_FAILED' in plugin-approvals (the spec makes ResumeFailureReport.code REQUIRED and names that code), and check:error-code-provenance refuses a stamp site in a package that neither owns the ledger row nor carries a PROVENANCE_WAIVERS entry. Both remedies live in packages/spec, which is this lane's hard red line ⇒ routed to the domain:spec seat as #17909.

Remedy state, 2026-09-13. #17909 is claimed by the domain:spec seat and carries PR #17924, which adds 'RESUME_FAILED' under the @objectstack/plugin-approvals owner key. #17924 is out of draft at 30 success / 5 skipped / 0 failure, and it measured the joint tree directly rather than deriving it: this PR's stamp site injected onto #17924's own base gives guard exit=0 with the listed count moving 318 → 319. Its recorded "before" leg (336 registered-code stamp site(s): 318 listed, 17 waived, one violation naming approval-service.ts:3370) matches this PR's failing run byte for byte. #17924 awaits review; it is a packages/spec governed surface, so this seat neither approves nor merges it.

Restart-when: PR #17924 lands on main — then merge main into claude/issue-15556-decision-door-resume-failure and push. ⛔ Not a bare re-run: .github/workflows/lint.yml's checkout step carries no ref: override, so the job reads refs/pull/17908/merge, and re-running the already-completed run replays the merge commit that run recorded. A push is what makes GitHub recompute that ref against the new base. No code change is expected on this side.

⚠️ #15970 (the recall door, same file, same family ruling) hits the identical wall — and #17924's own ablation table measured the two-stamp-site leg (exit=0, 320 listed), so the single row unblocks both.


Generated by Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions