11// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22
33/**
4- * REPRODUCTION PROBE for #15556 — an approval hosted inside a SUBFLOW CHILD.
5- * Not a deliverable yet: this file exists to measure what the decision door
6- * actually answers when `bubbleToParent` fails, with its controls in the
7- * same run.
4+ * #15556 — the REPRODUCTION: an approval hosted inside a SUBFLOW CHILD, whose
5+ * parent's continuation fails.
6+ *
7+ * The card was filed NOT MEASURED — the seam and its swallowing `catch` were
8+ * found by reading `engine.ts`, and nobody had driven the composition. This
9+ * file is that drive, with its controls in the same run, and it reproduces.
10+ *
11+ * ## The composition
12+ *
13+ * `deal_parent` parks at a `subflow` node; the child `deal_approval` parks at
14+ * an `approval` node, so the approvals row names the CHILD run. The decision
15+ * door resumes the child, the child completes, `bubbleToParent` resumes the
16+ * parent, and the parent's own downstream node throws.
17+ *
18+ * ## What is measured, and what is only characterised
19+ *
20+ * MEASURED FACT, now fixed engine-side: the parent lands on the engine's
21+ * stranded exit — `{ success: false, status: 'stranded' }`, journalled and
22+ * repairable — and `bubbleToParent` logged that at `warn`. The level is now
23+ * graded by that discriminator (`subflow-bubble-strand-log-level.test.ts` in
24+ * `service-automation` holds the pins, both directions).
25+ *
26+ * ⚠️ CHARACTERISED, NOT BLESSED: the decision door still answers full success.
27+ * Its resume-facing answer is IDENTICAL to the one a healthy composition
28+ * produces, so no caller can tell the two apart, and the `runId` it hands back
29+ * names the CHILD — which completed — never the stranded parent. Making that
30+ * truthful moves a public contract (`AutomationResult`,
31+ * `ApprovalDecisionResult`) and is #15556's open decision, the sibling one
32+ * level up of the #13807 ruling (maintainer 2026-09-04, decision batch #37).
33+ * ⛔ The assertions below record what the door does TODAY; whatever ruling
34+ * lands must turn them red on purpose.
35+ *
36+ * ## The control that makes the reading trustworthy
37+ *
38+ * `CONTROL` drives the #13807 shape through the SAME door in the same run — no
39+ * subflow, the child's own branch throws — and the door throws `RESUME_FAILED`
40+ * with its stranded envelope. So the absence of a throw above is a fact about
41+ * the composition, not about a mis-wired harness.
842 */
943
1044import { describe , it , expect , beforeEach } from 'vitest' ;
@@ -16,6 +50,17 @@ import { registerApprovalNode } from './approval-node.js';
1650
1751const SYSTEM_CTX = { isSystem : true , positions : [ ] , permissions : [ ] } as any ;
1852
53+ /** The card's own downstream failure text, in shape. */
54+ const DOWNSTREAM_FAILURE = 'update_record(crm_leave_request) failed: Record 9SEmlyRfw8D9-J7Z not found' ;
55+
56+ /**
57+ * The resume-facing answer a caller reads, minus the run id. Asserted by BOTH
58+ * the stranded composition and the healthy one — that shared literal, and not
59+ * a value smuggled between tests, is what carries the claim that the two are
60+ * indistinguishable at the door.
61+ */
62+ const FULL_SUCCESS = { finalized : true , decision : 'approve' , resumed : true , resumeError : undefined } ;
63+
1964/** Records every level so the "only artefact is a log line" claim is measurable. */
2065function recordingLogger ( ) {
2166 const lines : Array < { level : string ; msg : string ; meta ?: unknown } > = [ ] ;
@@ -116,7 +161,7 @@ const PARENT = {
116161 ] ,
117162} ;
118163
119- describe ( '#15556 probe — approval inside a subflow child, parent bubble fails' , ( ) => {
164+ describe ( '#15556 — an approval hosted in a subflow child, whose parent bubble fails' , ( ) => {
120165 let data : ReturnType < typeof makeFakeEngine > ;
121166 let service : ApprovalService ;
122167 let logger : ReturnType < typeof recordingLogger > ;
@@ -153,27 +198,30 @@ describe('#15556 probe — approval inside a subflow child, parent bubble fails'
153198 const pendingRequest = async ( ) =>
154199 ( await data . find ( 'sys_approval_request' , { where : { status : 'pending' } } ) ) [ 0 ] ;
155200
156- it ( 'MEASUREMENT — parent bubble fails: what does the door answer?' , async ( ) => {
157- throwOn . after_sub = 'update_record(crm_leave_request) failed: Record 9SEmlyRfw8D9-J7Z not found' ;
201+ /** The resume-facing projection of the door's answer — everything a caller reads about the run. */
202+ const resumeFacing = ( r : any ) => ( {
203+ finalized : r . finalized , decision : r . decision , resumed : r . resumed , resumeError : r . resumeError ,
204+ } ) ;
205+
206+ it ( 'the parent STRANDS while the door answers full success' , async ( ) => {
207+ throwOn . after_sub = DOWNSTREAM_FAILURE ;
158208 const automation = boot ( ) ;
159209
160210 const started = await automation . execute ( 'deal_parent' , {
161211 object : 'crm_deal' , record : { id : 'd1' , amount : 100 } , userId : 'submitter' ,
162212 } as never ) ;
163- // eslint-disable-next-line no-console
164- console . log ( 'PROBE started =' , JSON . stringify ( started ) ) ;
165213 const parentRunId = ( started as any ) . runId as string ;
214+ expect ( ( started as any ) . status , 'the parent parks at its subflow node' ) . toBe ( 'paused' ) ;
166215
167216 const req = await pendingRequest ( ) ;
168- // eslint-disable-next-line no-console
169- console . log ( 'PROBE request =' , JSON . stringify ( req && { id : req . id , run : req . flow_run_id , status : req . status } ) ) ;
170217 const childRunId = req ?. flow_run_id as string ;
171- expect ( childRunId , 'the request must name the CHILD run' ) . toBeTruthy ( ) ;
218+ expect ( childRunId , 'the request names the CHILD run, never the parent ' ) . toBeTruthy ( ) ;
172219 expect ( childRunId ) . not . toBe ( parentRunId ) ;
173220 expect ( await automation . hasSuspendedRun ( parentRunId ) ) . toBe ( true ) ;
174221
175- // Capture the envelope `bubbleToParent` receives for the PARENT resume —
176- // the thing the swallowing catch throws away.
222+ // The envelope `bubbleToParent` receives for the PARENT — the thing this
223+ // card is about. Captured through `resumeInternal` because the up-bubble
224+ // never goes through the public `resume` door.
177225 const bubbled : any [ ] = [ ] ;
178226 const realInternal = ( automation as any ) . resumeInternal . bind ( automation ) ;
179227 ( automation as any ) . resumeInternal = async ( ...args : any [ ] ) => {
@@ -186,61 +234,88 @@ describe('#15556 probe — approval inside a subflow child, parent bubble fails'
186234 . decide ( req . id , { decision : 'approve' , actorId : 'u1' } , SYSTEM_CTX )
187235 . then ( r => ( { ok : true as const , r } ) , ( e : Error ) => ( { ok : false as const , e } ) ) ;
188236
189- // eslint-disable-next-line no-console
190- console . log ( 'PROBE door =' , JSON . stringify ( outcome . ok ? outcome . r : { threw : outcome . e . message , details : strandedDecisionDetails ( outcome . e ) } ) ) ;
191- // eslint-disable-next-line no-console
192- console . log ( 'PROBE marks =' , JSON . stringify ( marks ) ) ;
193- // eslint-disable-next-line no-console
194- console . log ( 'PROBE parent suspended?' , await automation . hasSuspendedRun ( parentRunId ) ) ;
195- // eslint-disable-next-line no-console
196- console . log ( 'PROBE parent resume =' , JSON . stringify ( await automation . resume ( parentRunId ) ) ) ;
197- const parentRow = await automation . getRun ( parentRunId ) ;
198- // eslint-disable-next-line no-console
199- console . log ( 'PROBE parent run row =' , JSON . stringify ( parentRow && {
200- status : ( parentRow as any ) . status , error : ( parentRow as any ) . error ,
201- consumedSuspension : Boolean ( ( parentRow as any ) . consumedSuspension ) ,
202- } ) ) ;
203- // eslint-disable-next-line no-console
204- console . log ( 'PROBE child run row =' , JSON . stringify ( await automation . getRun ( childRunId ) . then ( r => r && { status : ( r as any ) . status } ) ) ) ;
205- // eslint-disable-next-line no-console
206- console . log ( 'PROBE request row =' , JSON . stringify ( ( await data . find ( 'sys_approval_request' , { where : { id : req . id } } ) ) [ 0 ] ?. status ) ) ;
207- // eslint-disable-next-line no-console
208- console . log ( 'PROBE parent bubble envelope =' , JSON . stringify ( bubbled . map ( b => ( { success : b . success , code : b . code , status : b . status , error : b . error } ) ) ) ) ;
209- // eslint-disable-next-line no-console
210- console . log ( 'PROBE parent restore =' , JSON . stringify ( await automation . restoreConsumedSuspension ( parentRunId , { requestedBy : 'probe' } ) ) ) ;
211- // eslint-disable-next-line no-console
212- console . log ( 'PROBE log lines =' , JSON . stringify ( logger . lines . filter ( ( l : any ) => l . level !== 'debug' && l . level !== 'info' ) . map ( ( l : any ) => [ l . level , l . msg ] ) ) ) ;
237+ // ── The parent's resume answers the #13937 discriminator, and no `code`.
238+ expect ( bubbled . length ) . toBe ( 1 ) ;
239+ expect ( bubbled [ 0 ] . success ) . toBe ( false ) ;
240+ expect ( bubbled [ 0 ] . status , "the producer's own verdict" ) . toBe ( 'stranded' ) ;
241+ expect ( bubbled [ 0 ] . code , 'and it names no code at all' ) . toBeUndefined ( ) ;
242+ expect ( bubbled [ 0 ] . error ) . toBe ( DOWNSTREAM_FAILURE ) ;
243+
244+ // ── The parent really is dead, and really is repairable.
245+ expect ( await automation . hasSuspendedRun ( parentRunId ) ) . toBe ( false ) ;
246+ expect ( ( await automation . resume ( parentRunId ) ) . code ) . toBe ( 'RUN_NOT_FOUND' ) ;
247+ expect ( ( await automation . getRun ( parentRunId ) ) ?. status ) . toBe ( 'failed' ) ;
248+
249+ // ── The child is fine, and so is the decision: both halves of the
250+ // divergence are real, which is what makes it a divergence.
251+ expect ( ( await automation . getRun ( childRunId ) ) ?. status ) . toBe ( 'completed' ) ;
252+ expect ( marks , 'the child advanced; the parent died on the node after the subflow' )
253+ . toEqual ( [ 'on_approved' ] ) ;
254+ expect ( ( await data . find ( 'sys_approval_request' , { where : { id : req . id } } ) ) [ 0 ] . status ) . toBe ( 'approved' ) ;
255+
256+ // ⚠️ CHARACTERISED, NOT BLESSED — see the file header. The door does not
257+ // throw, reports `resumed: true`, carries no `resumeError`, and the run it
258+ // names is the CHILD, which completed. Nothing in the response reaches the
259+ // stranded parent.
260+ expect ( outcome . ok , 'today the door does not throw' ) . toBe ( true ) ;
261+ const answer = outcome . ok ? outcome . r : ( undefined as never ) ;
262+ expect ( resumeFacing ( answer ) ) . toEqual ( FULL_SUCCESS ) ;
263+ expect ( answer . runId , 'the id handed back is the CHILD — the run that is fine' ) . toBe ( childRunId ) ;
264+ expect ( strandedDecisionDetails ( answer as unknown ) ) . toBeUndefined ( ) ;
265+
266+ // The one artefact the operator gets, at the level AGENTS.md's durability
267+ // rule requires, naming the run and the repair verb (#15556's shipped half).
268+ const durability = logger . lines . filter (
269+ ( l : any ) => l . level === 'error' && String ( l . msg ) . includes ( 'STRANDED' ) ,
270+ ) ;
271+ expect ( durability . length , 'exactly one, at `error`' ) . toBe ( 1 ) ;
272+ expect ( durability [ 0 ] . msg ) . toContain ( `restoreConsumedSuspension('${ parentRunId } ')` ) ;
273+
274+ // …and the repair verb it promises actually works.
275+ expect ( ( await automation . restoreConsumedSuspension ( parentRunId , { requestedBy : 'ops' } ) ) . restored ) . toBe ( true ) ;
276+
213277 } ) ;
214278
215- it ( 'CONTROL A — same composition, parent downstream node healthy ' , async ( ) => {
279+ it ( 'CONTROL A — the healthy composition answers IDENTICALLY, which is the defect ' , async ( ) => {
216280 const automation = boot ( ) ;
217281 const started = await automation . execute ( 'deal_parent' , {
218- object : 'crm_deal' , record : { id : 'd2 ' , amount : 100 } , userId : 'submitter' ,
282+ object : 'crm_deal' , record : { id : 'd1 ' , amount : 100 } , userId : 'submitter' ,
219283 } as never ) ;
220284 const parentRunId = ( started as any ) . runId as string ;
221285 const req = await pendingRequest ( ) ;
222- const outcome = await service
223- . decide ( req . id , { decision : 'approve' , actorId : 'u1' } , SYSTEM_CTX )
224- . then ( r => ( { ok : true as const , r } ) , ( e : Error ) => ( { ok : false as const , e } ) ) ;
225- // eslint-disable-next-line no-console
226- console . log ( 'CTRL-A door =' , JSON . stringify ( outcome . ok ? outcome . r : { threw : outcome . e . message } ) ) ;
227- // eslint-disable-next-line no-console
228- console . log ( 'CTRL-A marks =' , JSON . stringify ( marks ) ) ;
229- // eslint-disable-next-line no-console
230- console . log ( 'CTRL-A parent run row =' , JSON . stringify ( await automation . getRun ( parentRunId ) . then ( r => r && { status : ( r as any ) . status } ) ) ) ;
286+
287+ const answer = await service . decide ( req . id , { decision : 'approve' , actorId : 'u1' } , SYSTEM_CTX ) ;
288+
289+ // The parent ran to completion this time — the only thing that changed.
290+ expect ( marks ) . toEqual ( [ 'on_approved' , 'after_sub' ] ) ;
291+ expect ( ( await automation . getRun ( parentRunId ) ) ?. status ) . toBe ( 'completed' ) ;
292+ expect ( logger . lines . filter ( ( l : any ) => l . level === 'error' ) ) . toEqual ( [ ] ) ;
293+
294+ // ⭐ The sharpest statement of the defect: the SAME literal the stranded
295+ // composition asserted. A caller comparing the two answers has nothing to
296+ // compare — only the run ids differ, and both name a healthy child.
297+ expect ( resumeFacing ( answer ) ) . toEqual ( FULL_SUCCESS ) ;
298+ expect ( answer . runId ) . toBe ( req . flow_run_id ) ;
231299 } ) ;
232300
233- it ( 'CONTROL B — no subflow: the #13807 shape still throws at this door' , async ( ) => {
301+ it ( "CONTROL B — the #13807 shape still throws at this door, so the harness is live" , async ( ) => {
302+ // Without this control, "the door did not throw" above would be
303+ // indistinguishable from a door that was never wired to throw at all.
234304 throwOn . on_approved = 'the child branch blew up' ;
235305 const automation = boot ( ) ;
236306 await automation . execute ( 'deal_approval' , {
237307 object : 'crm_deal' , record : { id : 'd3' , amount : 100 } , userId : 'submitter' ,
238308 } as never ) ;
239309 const req = await pendingRequest ( ) ;
240- const outcome = await service
310+
311+ const err = await service
241312 . decide ( req . id , { decision : 'approve' , actorId : 'u1' } , SYSTEM_CTX )
242- . then ( r => ( { ok : true as const , r } ) , ( e : Error ) => ( { ok : false as const , e } ) ) ;
243- // eslint-disable-next-line no-console
244- console . log ( 'CTRL-B door =' , JSON . stringify ( outcome . ok ? outcome . r : { threw : outcome . e . message , details : strandedDecisionDetails ( outcome . e ) } ) ) ;
313+ . then ( ( ) => null , ( e : Error ) => e ) ;
314+
315+ expect ( err , 'the direct shape is reported — this door can fail' ) . toBeTruthy ( ) ;
316+ expect ( err ?. message ) . toMatch ( / ^ R E S U M E _ F A I L E D / ) ;
317+ expect ( strandedDecisionDetails ( err ) ) . toEqual ( {
318+ finalized : true , decision : 'approve' , runId : req . flow_run_id , repairable : true ,
319+ } ) ;
245320 } ) ;
246321} ) ;
0 commit comments