Skip to content

Commit c8a006f

Browse files
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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
"@objectstack/plugin-approvals": minor
4+
---
5+
6+
An approval `decide()` that resumes a subflow CHILD now tells the caller when that resume bubbles into a PARENT run that stranded — instead of answering full success with nothing to distinguish it from a healthy composition (#15556; the #16472 family ruling, decision batch #76, option A).
7+
8+
**The composition.** A parent flow parks at a `subflow` node whose child hosts the `approval` node, so the approvals row names the CHILD run. The decision door resumes the child, the child completes, `bubbleToParent` resumes the parent, and the parent's own downstream node throws. The parent lands on the engine's `'stranded'` exit — it consumed its suspension and is now terminal, repairable only by an operator's `restoreConsumedSuspension` — and `bubbleToParent` already logged that at `error` (unchanged by this fix). What the caller was TOLD did not: `resumed: true`, no `resumeError`, and a `runId` naming the healthy child — identical to what a fully healthy composition answers.
9+
10+
```
11+
FROM service.decide(requestId, { decision: 'approve' }, ctx)
12+
-> { finalized: true, decision: 'approve', runId: '<child>', resumed: true }
13+
// identical to a healthy composition's answer — no caller can tell
14+
15+
TO service.decide(requestId, { decision: 'approve' }, ctx)
16+
-> { finalized: true, decision: 'approve', runId: '<child>', resumed: true,
17+
resumeError: "RESUME_FAILED: … its own flow run '<child>' resumed, but the " +
18+
"subflow parent above it — run '<parent>' — consumed its suspension " +
19+
"and is now stranded: <downstream error>",
20+
resumeFailure: { code: 'RESUME_FAILED', runId: '<parent>', status: 'stranded', repairable: true } }
21+
```
22+
23+
**Additive only — no migration.** `ApprovalDecisionResult.resumeFailure` was already declared (and pinned) in `@objectstack/spec` ahead of this card; this fix is the first producer that fills it. No existing field changes shape, no status code moves (the door still never throws for this shape — `AGENTS.md`'s "a failure handed to the caller" answer does not apply here, since before this fix no caller was told at all), and the door's `error` log line is untouched. A consumer that already ignores unknown fields sees no difference; a consumer that reads `resumeFailure` can now tell a bubbled parent strand from a clean resume without diffing `runId` against a durable run history.
24+
25+
**What did not move, on purpose.** `RESUME_IN_PROGRESS` / `STORE_UNAVAILABLE` bubble outcomes stay the functional degradation they always were (`warn`, unreported on `resumeFailure`) — the #16472 ruling is scoped to the one exit the engine calls `'stranded'`. The sibling `recall` door (`ApprovalRecallResult.resumeFailure`, #15970) is a separate card and is not touched here.
26+
27+
**New public surface — the reason for `minor` on both packages, not `patch`.** Getting the parent's strand from the engine to the approvals door without touching `packages/spec` or the wire-visible `AutomationResult` (which a raw REST `POST …/resume` also serves verbatim, so a field there would leak an undeclared key onto every subflow resume, not only an approvals-mediated one) needed a small new internal channel:
28+
29+
- `@objectstack/service-automation`: `AutomationEngine` gains a new public method, `takeSubflowParentStrand(childRunId: string): SubflowParentStrand | undefined` — read-once (deletes on read), populated only by `bubbleToParent`'s `'stranded'` exit. `SubflowParentStrand` is a new exported interface (`{ runId, repairable: true, error }`).
30+
- `@objectstack/plugin-approvals`: `ApprovalResumeSurface` (already exported from the package entry) gains a matching optional member, `takeSubflowParentStrand?(childRunId): { runId, repairable, error } | undefined`.
31+
32+
Both are additive and optional; nothing existing changes shape or behaviour. Neither reaches any wire payload — `AutomationResult`, the REST resume door's response, and every other published contract are byte-for-byte unchanged.

packages/plugins/plugin-approvals/src/approval-service.ts

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import type {
4747
ApprovalResubmitResult,
4848
ApprovalStatus,
4949
ApprovalCancelReason,
50+
ResumeFailureReport,
5051
} from '@objectstack/spec/contracts';
5152
// [#7135] The full `resolveAuthzContext` envelope — what `IApprovalService`
5253
// declares for every one of these context parameters since #6523 (the #6206
@@ -226,6 +227,26 @@ export interface ApprovalResumeSurface {
226227
| { repairable: true }
227228
| { repairable: false; reason: 'RUN_SUSPENDED' | 'SNAPSHOT_DROPPED' | 'NO_CONSUMED_SUSPENSION' }
228229
>;
230+
/**
231+
* [#15556; the #16472 family ruling] The subflow PARENT strand that
232+
* `childRunId`'s own completion bubbled into, if the engine's up-bubble
233+
* hit the `'stranded'` exit — read, and CLEARED, by
234+
* {@link ApprovalService.resumeRecordedOutcome} right after a resume it
235+
* issued reports success, so a decision whose OWN run advanced can still
236+
* tell the caller a run further up the chain did not.
237+
*
238+
* ⚠️ Declares a method `AutomationEngine` ALREADY implements publicly
239+
* (`takeSubflowParentStrand`); it widens no wire surface — the engine's
240+
* generic `resume()` / `AutomationResult` answer is UNCHANGED by this
241+
* member's existence, which is exactly why a caller must ask for it by
242+
* name instead of finding it riding the resume result.
243+
*
244+
* `runId` here is the PARENT's — never `childRunId` itself, and never the
245+
* run a caller of `decide`/`resumeRecordedOutcome` was resuming. Optional:
246+
* an engine that predates this member simply never reports a bubbled
247+
* strand, exactly as before this card.
248+
*/
249+
takeSubflowParentStrand?(childRunId: string): { runId: string; repairable: boolean; error: string } | undefined;
229250
}
230251

231252
/** What {@link ApprovalResumeSurface.inspectConsumedSuspension} answers. */
@@ -668,8 +689,10 @@ export interface StrandedContinuationSignal {
668689
* Outcome of {@link ApprovalService.continueRestoredRun} (#15389).
669690
*
670691
* ⚠️ Deliberately its own shape rather than a reuse of `ApprovalDecisionResult`:
671-
* that contract is the subject of an OPEN maintainer ruling on #15556, and this
672-
* card must not pre-empt it. Nothing here changes what `decide` answers.
692+
* the #16472 family ruling settled that contract for `decide` (#15556) and
693+
* `recall` (#15970) by name, and this repair-replay verb is neither of those
694+
* doors — reusing their shape here would answer a question nobody asked.
695+
* Nothing here changes what `decide` answers.
673696
*/
674697
export interface ApprovalContinuationResult {
675698
/** True when the restored pause was consumed and the flow moved on. */
@@ -3113,11 +3136,19 @@ export class ApprovalService implements IApprovalService {
31133136
* how an approval could be recorded, reported as resumed, and leave its flow
31143137
* stranded forever (#4420). The thrown error carries {@link resumeCodeOf}'s
31153138
* `resumeCode` so callers can tell a benign duplicate from a dead run.
3139+
*
3140+
* [#15556; the #16472 family ruling] On the SUCCESS path this also asks
3141+
* {@link ApprovalResumeSurface.takeSubflowParentStrand} whether resuming
3142+
* `runId` bubbled into a parent that then stranded — a fact the engine's
3143+
* own `resume()` answer never carries (its `AutomationResult` is unchanged
3144+
* by this card, on purpose: that value can also reach a raw REST resume
3145+
* caller verbatim, and this is not a wire member). `undefined` on every
3146+
* other outcome, exactly like the surface member itself.
31163147
*/
31173148
private async serviceResume(
31183149
runId: string,
31193150
signal: { output?: Record<string, unknown>; branchLabel?: string },
3120-
): Promise<void> {
3151+
): Promise<{ runId: string; repairable: boolean; error: string } | undefined> {
31213152
const result = await this.automation!.resume!(runId, { ...signal, [RESUME_AUTHORITY_SERVICE]: true });
31223153
const reported = result as
31233154
{ success?: boolean; code?: string; error?: string; status?: string } | undefined;
@@ -3132,6 +3163,7 @@ export class ApprovalService implements IApprovalService {
31323163
err.resumeStatus = reported.status;
31333164
throw err;
31343165
}
3166+
return this.automation?.takeSubflowParentStrand?.(runId);
31353167
}
31363168

31373169
/** The engine failure code behind a {@link serviceResume} rejection, if any. */
@@ -3279,6 +3311,26 @@ export class ApprovalService implements IApprovalService {
32793311
* happen", which is the misreading that makes a caller retry or escalate
32803312
* against a decision that IS durable.
32813313
*
3314+
* ## A resume that SUCCEEDED can still carry a strand (#15556)
3315+
*
3316+
* #13807 above is about THIS run failing to resume. A DIFFERENT run can
3317+
* strand as a side effect of this one succeeding: `runId` parks inside a
3318+
* subflow, so resuming it can bubble into a PARENT that consumed ITS OWN
3319+
* suspension and then failed downstream (`AutomationEngine.bubbleToParent`,
3320+
* `service-automation`). Before the #16472 family ruling that fact reached
3321+
* nobody — `resumed: true`, no `resumeError`, and the `runId` this method
3322+
* returns names the run that is genuinely fine, never the stranded parent.
3323+
* The ruling (option A): the door's status code still does not move — this
3324+
* method still returns normally — but `resumeFailure` on the return value
3325+
* carries the PARENT's `runId` and `repairable`, read via
3326+
* {@link serviceResume}'s post-success
3327+
* {@link ApprovalResumeSurface.takeSubflowParentStrand} check, and
3328+
* `resumeError` carries the same event in prose. Absent on every OTHER
3329+
* success — a plain resume, or one whose subflow parent (if any) advanced
3330+
* cleanly — so a caller reading this member sees a report only when there
3331+
* is one to make, exactly {@link ApprovalDecisionResult.resumeFailure}'s
3332+
* absence rule.
3333+
*
32823334
* @param what - how the recorded outcome reads in the error, e.g.
32833335
* `"the approve decision"`.
32843336
* @param decision - the outcome label for the machine-readable envelope
@@ -3293,11 +3345,35 @@ export class ApprovalService implements IApprovalService {
32933345
what: string,
32943346
signal: { output?: Record<string, unknown>; branchLabel?: string },
32953347
decision: string,
3296-
): Promise<{ resumed: boolean; resumeError?: string }> {
3348+
): Promise<{ resumed: boolean; resumeError?: string; resumeFailure?: ResumeFailureReport }> {
32973349
const missing = this.missingRunCapability(runId, requestId, what, 'resume');
32983350
if (missing) return { resumed: false, resumeError: missing };
32993351
try {
3300-
await this.serviceResume(runId, signal);
3352+
const bubbleStrand = await this.serviceResume(runId, signal);
3353+
if (bubbleStrand) {
3354+
// #15556: this door's OWN resume succeeded — `runId` really did
3355+
// advance — but the subflow parent it bubbled into did not. Told on
3356+
// BOTH halves of the ONE telling (spec docblock, `ApprovalDecisionResult`):
3357+
// `resumeFailure` for a machine, `resumeError` for a human, never
3358+
// gated on `resumed`, which stays `true` here. ⛔ No NEW log line: the
3359+
// #16472 ruling left logging alone — `bubbleToParent`'s own `error`
3360+
// line (`service-automation`) already said this ONCE, with the
3361+
// consequence and the fix, per AGENTS.md's "say it once" durability
3362+
// rule; a second statement here would be the same event twice.
3363+
return {
3364+
resumed: true,
3365+
resumeError:
3366+
`RESUME_FAILED: ${what} was recorded on request ${requestId} and its own flow run '${runId}' ` +
3367+
`resumed, but the subflow parent above it — run '${bubbleStrand.runId}' — consumed its ` +
3368+
`suspension and is now stranded: ${bubbleStrand.error}`,
3369+
resumeFailure: {
3370+
code: 'RESUME_FAILED',
3371+
runId: bubbleStrand.runId,
3372+
status: 'stranded',
3373+
repairable: bubbleStrand.repairable,
3374+
},
3375+
};
3376+
}
33013377
return { resumed: true };
33023378
} catch (err: any) {
33033379
const reason = err?.message ?? String(err);
@@ -3360,6 +3436,7 @@ export class ApprovalService implements IApprovalService {
33603436

33613437
let resumed = false;
33623438
let resumeError: string | undefined;
3439+
let resumeFailure: ResumeFailureReport | undefined;
33633440
// No `typeof this.automation?.resume === 'function'` guard here (#4420):
33643441
// skipping the call when no engine is attached is precisely how a decision
33653442
// against a parked run returned 200 / `resumed: false` with nothing logged.
@@ -3383,6 +3460,7 @@ export class ApprovalService implements IApprovalService {
33833460
);
33843461
resumed = outcome.resumed;
33853462
resumeError = outcome.resumeError;
3463+
resumeFailure = outcome.resumeFailure;
33863464
}
33873465

33883466
return {
@@ -3392,6 +3470,11 @@ export class ApprovalService implements IApprovalService {
33923470
runId: result.runId,
33933471
resumed,
33943472
...(resumeError ? { resumeError } : {}),
3473+
// [#15556; #16472 ruling] Additive — see the field's own docblock in
3474+
// `@objectstack/spec/contracts`'s absence rule: omitted entirely rather
3475+
// than `undefined`, so a consumer that merely checks `'resumeFailure'
3476+
// in result` reads presence correctly.
3477+
...(resumeFailure ? { resumeFailure } : {}),
33953478
};
33963479
}
33973480

0 commit comments

Comments
 (0)