Commit c8a006f
fix(automation): tell approval decide() callers when a subflow parent strands (#17908)
Fixes #15556
## Update (2026-09-13)
The review seat corrected this PR's contract classification after
reading the delivered diff: it adds a new public method
(`AutomationEngine.takeSubflowParentStrand`) and a new exported
interface (`SubflowParentStrand`) to `@objectstack/service-automation`,
and a matching optional member on `@objectstack/plugin-approvals`'
already-exported `ApprovalResumeSurface` — new public surface, whatever
the wire does. The changeset now grades both packages `minor` instead of
`patch`, and `needs:contract-review` is attached. Nothing else about the
delivery changed — see the changeset for exactly what grew and why.
## What changed
`bubbleToParent` (`packages/services/service-automation/src/engine.ts`)
already resumes a subflow's PARENT when its child completes. When that
parent consumes its own suspension and then fails downstream — the
engine's `'stranded'` exit — the failure was reported only to the
engine's own `error` log. The caller who resumed the child (an approvals
`decide()`) still answered `resumed: true` with nothing to distinguish
it from a fully healthy composition, and its `runId` still named the
healthy CHILD, never the stranded PARENT.
This fills the slot the #16472 family ruling (maintainer 2026-09-07,
decision batch #76, option A) already declared and left unfilled:
`ApprovalDecisionResult.resumeFailure?: ResumeFailureReport` in
`@objectstack/spec`
(`packages/spec/src/contracts/approval-service.ts:785`). The door's
status code does not move — `decide()` still never throws for this shape
— but the answer now carries the strand behind it.
```
FROM service.decide(requestId, { decision: 'approve' }, ctx)
-> { finalized: true, decision: 'approve', runId: '<child>', resumed: true }
// identical to a healthy composition's answer
TO service.decide(requestId, { decision: 'approve' }, ctx)
-> { finalized: true, decision: 'approve', runId: '<child>', resumed: true,
resumeError: "RESUME_FAILED: … its own flow run '<child>' resumed, but the " +
"subflow parent above it — run '<parent>' — consumed its suspension " +
"and is now stranded: <downstream error>",
resumeFailure: { code: 'RESUME_FAILED', runId: '<parent>', status: 'stranded', repairable: true } }
```
**Plumbing — and why it is new public surface, not a wire change.**
`bubbleToParent`'s `'stranded'` exit now also records a
`SubflowParentStrand` (`{ runId, repairable: true, error }`) in a new
bounded, per-process `Map<childRunId, SubflowParentStrand>` on
`AutomationEngine`, read once (and cleared) via a new public
`takeSubflowParentStrand(childRunId)`. `plugin-approvals`'s
`serviceResume` calls it right after its own resume reports success, and
`resumeRecordedOutcome` turns a hit into `resumeFailure` + `resumeError`
on the `decide()` result. `RESUME_IN_PROGRESS` / `STORE_UNAVAILABLE`
bubble outcomes record nothing — they stay the untouched functional
degradation — and `bubbleToParent`'s own `error` log line is
byte-for-byte unchanged (the ruling explicitly leaves logging alone).
I deliberately did **not** reuse the generic `AutomationEngine.resume()`
/ `AutomationResult` return value as the carrier: that object is served
verbatim to a raw REST `POST …/resume` caller too
(`deps.success(result)` in `packages/runtime/src/domains/automation.ts`,
out of my file surface), so adding a field there would leak an
undeclared key onto that wire path for every subflow resume, not just an
approvals-mediated one. The alternative — a new engine method mirroring
the existing `inspectConsumedSuspension` / `hasSuspendedRun` /
`listSuspendedRunsDurable` pattern already on `ApprovalResumeSurface` —
avoids that leak, but it is itself new exported surface on both packages
(see the changeset), which is the thing declared here rather than argued
away.
Updated the #15556 reproduction test
(`subflow-hosted-approval-strand.test.ts`) to its designed-to-go-red
truthful shape (its own header said the fix must turn it red on
purpose), keeping both controls: CONTROL A (healthy composition — still
the shared `FULL_SUCCESS` literal, now asserted to diverge from the
stranded case) and CONTROL B (the #13807 direct-throw shape,
unaffected). Also refreshed two now-stale "#15556 open decision" doc
comments in `service-automation` and `plugin-approvals` that this PR
itself closes.
## Scope discipline
- **Zero `packages/spec` touched.** Verified before starting:
`ApprovalDecisionResult.resumeFailure`, its docblock's absence rule, and
the widened `resumeError` docblocks are already landed and pinned
(`resume-failure-report.pin.test.ts`, re-run below, green, untouched).
This PR only produces a value for the already-declared slot.
- **`recall()` (#15970's door) not touched** — same file, deliberately
left alone per the sibling fence. It calls `serviceResume` too and would
trivially gain the same fix (its return value is just discarded today),
but that fold-in belongs to #15970, not this PR.
- Log level (`error` for `'stranded'`, `warn` for the two tolerated
arms) is untouched, per the ruling.
## Tests
- `pnpm --filter @objectstack/plugin-approvals exec vitest run
src/subflow-hosted-approval-strand.test.ts` — 3/3 pass (the fixed
reproduction + both controls).
- `pnpm --filter @objectstack/service-automation exec vitest run
src/subflow-bubble-strand-log-level.test.ts
src/nested-strand-chain-restore.test.ts
src/engine-residual-log-cause.test.ts` — 38/38 pass (log-level pins
unaffected, both directions).
- `pnpm --filter @objectstack/spec exec vitest run
src/contracts/resume-failure-report.pin.test.ts` — 6/6 pass (spec side
untouched, still green).
- `pnpm --filter @objectstack/service-automation test` — 132 files /
1564 tests pass.
- `pnpm --filter @objectstack/plugin-approvals test` — 44 files / 733
tests pass.
- `pnpm --filter @objectstack/service-automation typecheck` and `pnpm
--filter @objectstack/plugin-approvals typecheck` — both exit 0
(plugin-approvals' pre-existing 324-error test-typecheck debt ledger is
unchanged, not mine).
- `pnpm --filter @objectstack/service-automation build` / `pnpm --filter
@objectstack/plugin-approvals build` (with their dependency closures) —
both exit 0, DTS emitted clean.
- `node scripts/pm/dispatch-gates.mjs --commands` (re-derived after the
changeset landed) named 63 families; all 63 run. 60 exit 0. **3 exit `3`
(PREREQUISITE NOT MET), reported as NOT MEASURED, never as a pass**:
`check:dual-build-cjs-loads`, `check:i18n`, `check:type-check-debt` —
all three refuse because they read a full-monorepo `dist/` this local
run never built (out of the ①②③ local-verification scope; CI builds the
whole tree). None is in a family my diff plausibly affects.
## Changeset
`.changeset/15556-subflow-parent-strand-on-decide.md` — **`minor`** on
both `@objectstack/service-automation` and
`@objectstack/plugin-approvals` (corrected from an initial `patch` — see
Update above): the fix is additive with no migration, but it adds
genuinely new public surface
(`AutomationEngine.takeSubflowParentStrand`, `SubflowParentStrand`, and
a matching optional `ApprovalResumeSurface` member), which is what a
`minor` grade is for.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 225197c commit c8a006f
5 files changed
Lines changed: 294 additions & 55 deletions
File tree
- .changeset
- packages
- plugins/plugin-approvals/src
- services/service-automation/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
Lines changed: 88 additions & 5 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
47 | 47 | | |
48 | 48 | | |
49 | 49 | | |
| 50 | + | |
50 | 51 | | |
51 | 52 | | |
52 | 53 | | |
| |||
226 | 227 | | |
227 | 228 | | |
228 | 229 | | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
229 | 250 | | |
230 | 251 | | |
231 | 252 | | |
| |||
668 | 689 | | |
669 | 690 | | |
670 | 691 | | |
671 | | - | |
672 | | - | |
| 692 | + | |
| 693 | + | |
| 694 | + | |
| 695 | + | |
673 | 696 | | |
674 | 697 | | |
675 | 698 | | |
| |||
3113 | 3136 | | |
3114 | 3137 | | |
3115 | 3138 | | |
| 3139 | + | |
| 3140 | + | |
| 3141 | + | |
| 3142 | + | |
| 3143 | + | |
| 3144 | + | |
| 3145 | + | |
| 3146 | + | |
3116 | 3147 | | |
3117 | 3148 | | |
3118 | 3149 | | |
3119 | 3150 | | |
3120 | | - | |
| 3151 | + | |
3121 | 3152 | | |
3122 | 3153 | | |
3123 | 3154 | | |
| |||
3132 | 3163 | | |
3133 | 3164 | | |
3134 | 3165 | | |
| 3166 | + | |
3135 | 3167 | | |
3136 | 3168 | | |
3137 | 3169 | | |
| |||
3279 | 3311 | | |
3280 | 3312 | | |
3281 | 3313 | | |
| 3314 | + | |
| 3315 | + | |
| 3316 | + | |
| 3317 | + | |
| 3318 | + | |
| 3319 | + | |
| 3320 | + | |
| 3321 | + | |
| 3322 | + | |
| 3323 | + | |
| 3324 | + | |
| 3325 | + | |
| 3326 | + | |
| 3327 | + | |
| 3328 | + | |
| 3329 | + | |
| 3330 | + | |
| 3331 | + | |
| 3332 | + | |
| 3333 | + | |
3282 | 3334 | | |
3283 | 3335 | | |
3284 | 3336 | | |
| |||
3293 | 3345 | | |
3294 | 3346 | | |
3295 | 3347 | | |
3296 | | - | |
| 3348 | + | |
3297 | 3349 | | |
3298 | 3350 | | |
3299 | 3351 | | |
3300 | | - | |
| 3352 | + | |
| 3353 | + | |
| 3354 | + | |
| 3355 | + | |
| 3356 | + | |
| 3357 | + | |
| 3358 | + | |
| 3359 | + | |
| 3360 | + | |
| 3361 | + | |
| 3362 | + | |
| 3363 | + | |
| 3364 | + | |
| 3365 | + | |
| 3366 | + | |
| 3367 | + | |
| 3368 | + | |
| 3369 | + | |
| 3370 | + | |
| 3371 | + | |
| 3372 | + | |
| 3373 | + | |
| 3374 | + | |
| 3375 | + | |
| 3376 | + | |
3301 | 3377 | | |
3302 | 3378 | | |
3303 | 3379 | | |
| |||
3360 | 3436 | | |
3361 | 3437 | | |
3362 | 3438 | | |
| 3439 | + | |
3363 | 3440 | | |
3364 | 3441 | | |
3365 | 3442 | | |
| |||
3383 | 3460 | | |
3384 | 3461 | | |
3385 | 3462 | | |
| 3463 | + | |
3386 | 3464 | | |
3387 | 3465 | | |
3388 | 3466 | | |
| |||
3392 | 3470 | | |
3393 | 3471 | | |
3394 | 3472 | | |
| 3473 | + | |
| 3474 | + | |
| 3475 | + | |
| 3476 | + | |
| 3477 | + | |
3395 | 3478 | | |
3396 | 3479 | | |
3397 | 3480 | | |
| |||
0 commit comments