fix(service-automation): answer a delegated subflow child refusal as a refusal, not a terminal failure - #14567

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-14379-subflow-child-refusal-propagation
Sep 2, 2026
Merged

fix(service-automation): answer a delegated subflow child refusal as a refusal, not a terminal failure#14567
os-sales merged 2 commits into
mainfrom
claude/issue-14379-subflow-child-refusal-propagation

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14379

A parent run paused at a subflow node forwards a resume down to the child it is parked on — the screen-flow path, where the caller holds ONE stable run id (the parent's) and posts every wizard step to it. When the child refused that bag, the delegation block read it as a child that ran and died: it called failSuspendedRun on the parent and answered a code-less{ success: false, error }. One mistyped form field therefore destroyed a running workflow — the parent's suspension consumed and a failure recorded, the still-paused child orphaned with nothing left to bubble into, the caller told 400 FLOW_FAILED ("it ran and was rejected") for something that never ran, and their corrected retry on the same run id answered RUN_NOT_FOUND.

The delegation now branches on the child's own code. A retryable refusal is answered as a refusal, with its code intact and both pauses untouched; failSuspendedRun is reserved for a child that genuinely ran and failed.

Head at the time of every measurement below: 4ae326704.

Ruling of record (14379#issuecomment-5504400729, verbatim)

The fix criterion — prefer the producer-first one. The card offers two discriminators; take the second. ⛔ Do not branch on "is the child's suspension still live" — that is a second store read whose answer can race, and it infers intent from state. Branch on the child's own code being one of the refusal codes the engine itself answers (INVALID_SCREEN_INPUT, INVALID_SIGNAL, RESUME_IN_PROGRESS, STORE_UNAVAILABLE), which is the producer naming the condition — the same rule the platform applies everywhere else. failSuspendedRun is then reserved for a child that genuinely ran and failed.

⚠️And propagate the code. The current envelope carries none, which is why the transport lands on 400 FLOW_FAILED. Returning the child's envelope with its code intact is half the fix; a fix that leaves both pauses alive but still answers a code-less envelope has repaired the state and left the caller equally misled.

The pins the card names are the right ones, and one more: refused parent resume ⇒ code: 'INVALID_SCREEN_INPUT', parent and child still suspended, corrected retry on the parent completes. Add a negative control — a child that terminally throws must still fail the parent — or the fix can pass by never failing anything.

⚠️ Serialisation: three cards are live on these same 40 lines

#14392 — the "child run … is gone — continuing without child output" line, which sits in the else of the very if (childRun) this card's arm lives inside. Graded p3 this round. Two separate diffs: a log-text fix disappearing inside a state-machine repair is how the state-machine repair stops getting reviewed on its own merits.

Serialisation honoured: the branch is cut from origin/mainafter PR #14388 merged, and the else arm carrying the "child run … is gone" log line is byte-untouched — #14392 stays a separate diff, still open.

The change

packages/services/service-automation/src/engine.ts, two additions and nothing else:

  1. A module-private closed set, RETRYABLE_RESUME_REFUSAL_CODES, naming the codes resumeInternal itself answers for a resume that never ranINVALID_SCREEN_INPUT, INVALID_SIGNAL, RESUME_IN_PROGRESS, STORE_UNAVAILABLE — plus the one-line predicate that reads it. RUN_NOT_FOUND is deliberately absent and the docblock says why: it is the engine's terminal "this pause is gone for good" class (the automation: the run-resume route still answers HTTP 200 wrapping an inner {success:false} — the route #3962's status-code unification left behind #8684 comment sitting 30 lines above), which a transport answers 404 and no retry can fix.
  2. A new arm placed before the terminal-failure arm, answering the child's envelope verbatim but for the parent's durationMs, consuming neither pause and refreshing nothing (the child did not advance, so the parent's surfaced screen is already current).

No log site was added at any level: a refusal is a response, not a degradation, and the child already logged its own warn where it produced the refusal.

Premise checks on origin/main (all verified before the first edit)

#PremiseVerdict
P1resumeInternal's subflow block still has exactly three arms, no refusal armholds — located at engine.ts:4798 by run.correlation.startsWith('subflow:'); the three arms read exactly as the card quotes them
P2The card's measurement reproduces on the current treeholds — written as a failing test first and run before any source edit: 4 failed, 1 passed, every failure expected undefined to be 'INVALID_SCREEN_INPUT' / 'INVALID_SIGNAL'
P3PR #14388 is on origin/main; refuseInvalidScreenInput / ENGINE_BUILT_SIGNAL untouchedholds — the signal-less normalisation to {} is at the public door; neither symbol appears in the diff
P4The refusal codes are the four the ruling namesholds, with one reachability note below — grepped the producers inside resumeInternal: RESUME_IN_PROGRESS, STORE_UNAVAILABLE, RUN_NOT_FOUND (three sites), INVALID_SCREEN_INPUT (via refuseInvalidScreenInput), INVALID_SIGNAL. No other code is produced there. Nothing was invented and packages/spec was not touched
P5packages/spec, content/docs/releases/** and the #14392 log line untouchedholds — the diff is 3 files: engine.ts, one new test file, one changeset

P4 reachability note (reported, not acted on). Two of the four are reachable through delegation today and are pinned end to end: INVALID_SCREEN_INPUT and INVALID_SIGNAL. The other two are in the set because the producer answers them from this method, but neither has a deterministic fixture: RESUME_IN_PROGRESS needs a real race window against a concurrent direct child resume, and a STORE_UNAVAILABLE outage trips the parent's own loadSuspendedRunStrict several frames earlier, so the parent never reaches the delegation block at all. Keeping them in the set is the producer-first rule applied whole; the pins claim only what was measured.

Hypotheses (each falsifiable, each with its evidence)

  • H1 — one new arm before the terminal one, no failSuspendedRun, no pause consumed: HOLDS. That is the whole source change.
  • H2 — is the parent's pause consumed before the delegation block? NO, so nothing needed restoring.forgetSuspendedRun(run, 'resumed') — the one consumption on this path — sits ~90 lines after the delegation block, and the only other consumer is the failSuspendedRun the new arm bypasses. creditChildRun returns immediately when childSummary is absent, which it is on a direct parent resume. Proven behaviourally, not just by reading: hasSuspendedRun(parentRunId) is true after the refusal in every refusal pin.
  • H3 — the corrected retry completes end to end: HOLDS. The child's screen accepts the corrected bag, the child completes, the engine-built signal maps its output into the parent, and the downstream node observes { kind: 'normal' }. Both suspensions are gone afterwards.
  • H4 — the negative control still fails the parent through failSuspendedRun, envelope shape unchanged: HOLDS. A child whose node throws after the screen accepted the bag still answers success: false, codeundefined, error matching subflow run '…' (child_flow) failed: …, with both suspensions consumed. This test is green on both sides of the change — it is the control that stops the arm from passing by never failing anything.

Tests

New file packages/services/service-automation/src/builtin/subflow-child-refusal.test.ts — 5 pins, built on installBuiltinNodes with real subflow and screen nodes, exactly the composition the card measured.

pinasserts
arefused parent resume ⇒ success: false, code: 'INVALID_SCREEN_INPUT', the child's own actionable text, parent and child still suspended, the parent's surfaced screen unchanged, downstream never ran
bcorrected retry on the same parent run id ⇒ completes, child output mapped into the parent, both suspensions gone
cthe signal-less gesture resume(parentRunId) ⇒ the same refusal, both pauses intact, corrected retry still lands (the population the scope note 14379#issuecomment-5504169090 adds)
da second refusal code on the same path — INVALID_SIGNAL from a reserved variable name against a child pause that declares no screen contract ⇒ same shape, then the legitimate submission lands
enegative control — a child that genuinely ran and threw ⇒ parent failed terminally, code undefined, error text unchanged

Every refusal pin asserts the ADR-0112 code (and the error text), never a bare "it failed".

Verdict lines, quoted from the runs, all at 4ae326704:

BEFORE the source edit (the red half, same test file):
Test Files 1 failed (1)
Tests 4 failed | 1 passed (5)
AssertionError: expected undefined to be 'INVALID_SCREEN_INPUT'
Targeted suites (new pins + subflow-node + both screen-resume suites):
Test Files 4 passed (4)
Tests 41 passed (41)
os-verify-lock: VERDICT command-exit 0
Whole package:
Test Files 99 passed (99)
Tests 1171 passed (1171)
os-verify-lock: VERDICT command-exit 0
Downstream consumer `@objectstack/plugin-approvals` (closure built first):
Test Files 35 passed (35)
Tests 652 passed (652)
os-verify-lock: VERDICT command-exit 0

Type check: this package declares no typecheck script and carries a frozen DEBT ledger entry of 3. tsc --noEmit -p tsconfig.json reports exactly those 3 pre-existing TS2341 in src/nested-region-parity.test.ts (a file this diff does not touch) — unchanged. --listFiles confirms both edited files really are in that program (engine.ts and subflow-child-refusal.test.ts both listed), so this is a measurement and not a green over source nothing read.

Ablation (on the committed tree, both legs proven on disk)

Mutation: the new arm's guard neutralised in place, with a greppable sentinel so the on-disk change is provable from two directions.

HEAD_BLOB=111b7185441f3eabb94e62c8390155c7efe2b625
PRE marker=1 sentinel=0
POST marker=0 sentinel=1
MUTATED_BLOB=5b463c5d48b7d523513e8b01963cf3fad9fa9b7e
ABLATION_VITEST_EXIT=1
Test Files 1 failed (1)
Tests 4 failed | 1 passed (5)
RESTORED_BLOB=111b7185441f3eabb94e62c8390155c7efe2b625
RESTORE marker=1 sentinel=0
git diff HEAD -- $TARGET (expect empty): [empty]
git status --porcelain -- $TARGET (expect empty): [empty]
ABLATION_DONE

Predicted direction, observed: pins a–d red, pin e (the negative control) green. Restore verified by blob-hash equality against the HEAD blob and an empty git diff HEAD, not by an exit code; the script carried a trap … EXIT INT TERM with absolute paths throughout, and an earlier attempt that produced a zero-match substitution aborted at the guard rather than reporting a green ablation — it is reported here as an admitted no-op run, not quietly retried.

No rebuild is required for this ablation and none was performed. The pin imports the subject relatively (../engine.js, same package), which vitest resolves to src/engine.ts, and the package's only resolve.alias entry is an unrelated one for @objectstack/platform-objects. That is proven positively rather than asserted: mutating src/engine.ts alone, with no build, flipped the suite red, and restoring it alone flipped it green.

Gates

Derived on the final head from the actual change set, never a hand-written list: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 4ae326704 — 36 commands over the 3 changed paths. 33 exited 0. Three are PREREQUISITE NOT MET, recorded as NOT MEASURED and not as passes, each with the gate's own words:

  • check-test-completeness (exit 3) — "this gate grades a saved turbo run test log, and no log was named … the local reading for this gate is NOT MEASURED. ⛔ It is not a red".
  • check:dual-build-cjs-loads (exit 3) — "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured." (Its own self-test passed: "93 cases pass".)
  • check:type-check-debt (exit 3) — "--re-measure cannot run: 33 workspace dependenc(ies) … have no built type entry point on disk … ⛔ This is NOT a pass and NOT a finding". Its sibling check:type-check-coveragedid run and passed: "OK — 68/78 workspace packages type-checked".

Every exit code above was captured before any pipe (cmd > file 2>&1; EXIT=$?), and each verdict is quoted from the gate's own output rather than read off a bare $?.

Beyond the derived family: check:nul-bytes passed ("scanned 7940 text file(s) … no raw ASCII control bytes"), and a direct control-byte scan over the three changed files returned no hits.

Repo-wide lint was run in full, not narrowedpnpm lint (eslint . --no-inline-config over the whole repo) exited 0.

git merge-tree --write-tree --name-only origin/main HEAD returned a clean tree with no file list, so content/docs/permissions/system-context.mdx is not implicated and no regeneration is owed.

One honest caveat on the derivation: re-running it after a fresh fetch warned "STALE TREE — this answer is derived from a tree at least 7 commit(s) behind origin/main, and 2 file(s) it derives from CHANGED across that range … .github/workflows/lint.ymlscripts/role-word-baseline.json". Both were inspected: the lint.yml change is comment-only (no step added or removed) and role-word-baseline.json moved by one line, so the family for these paths is unchanged. CI runs the real farm regardless.

Clause-②: no

Declared from the actual diff, not from the plan: git diff -U0 origin/main...HEAD | grep export returns exactly one line, a comment in the new test file ("parks on a real screen node and exports what it collected"). No export was added, removed or renamed, and no accept set moved — RETRYABLE_RESUME_REFUSAL_CODES and isRetryableResumeRefusal are module-private. The public resume contract already declares all four codes as answers (packages/spec/src/contracts/automation-service.ts); this change makes the parent's resume return one instead of swallowing it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

Generated by Claude Code


Generated by Claude Code

…ntract (#14379)
Red half of the reproduction: a parent resume delegated to a child paused on a
screen with a `required` field answers a code-less envelope, fails the parent
and orphans the still-paused child. The negative control (a child that really
ran and threw) is green on both sides.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…l instead of failing the parent (#14379)
The subflow delegation block read every `!childRes.success` as a child that ran
and died. A retryable refusal — the codes `resumeInternal` itself answers for a
resume that never ran — left the child parked where it was, but consumed the
PARENT's pause, recorded a failure, and answered a code-less envelope the
transport maps to `400 FLOW_FAILED`; the corrected retry then answered
`RUN_NOT_FOUND`.
Branch on the child's own `code` (producer-first, per the triage ruling), return
the child's envelope with the code intact and both pauses untouched, and reserve
`failSuspendedRun` for a child that genuinely ran and failed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 7 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx(via INVALID_SCREEN_INPUT (literal, a string literal in RETRYABLE_RESUME_REFUSAL_CODES))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 0e68ed25cc45c15ce296c299614b2b51a3296e52packageMentionDocs.

Which tree this was computed on

This run read content/docs from 12ede636a4b77f1c4df9993fade21b1996dd7e3a — the merge of head 4ae326704950de2991b47cf21bd71c5601bc536f into base 0e68ed25cc45c15ce296c299614b2b51a3296e52, 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 12ede636a4b77f1c4df9993fade21b1996dd7e3a && git checkout 12ede636a4b77f1c4df9993fade21b1996dd7e3a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0e68ed25cc45c15ce296c299614b2b51a3296e52 4ae326704950de2991b47cf21bd71c5601bc536f && git checkout -B drift-repro 0e68ed25cc45c15ce296c299614b2b51a3296e52 && git merge --no-ff 4ae326704950de2991b47cf21bd71c5601bc536f
node scripts/docs-audit/affected-docs.mjs --json 0e68ed25cc45c15ce296c299614b2b51a3296e52

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0e68ed25cc45c15ce296c299614b2b51a3296e52 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-salesClaude

Copy link
Copy Markdown
Collaborator

Landing provenance — ready + auto-merge at head 4ae326704


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 5563bfbSep 2, 2026
34 of 35 checks passed
@os-sales
os-sales deleted the claude/issue-14379-subflow-child-refusal-propagation branch September 2, 2026 13:21
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(service-automation): answer a delegated subflow child refusal as a refusal, not a terminal failure - #14567

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-14379-subflow-child-refusal-propagation
Sep 2, 2026
Merged

fix(service-automation): answer a delegated subflow child refusal as a refusal, not a terminal failure#14567
os-sales merged 2 commits into
mainfrom
claude/issue-14379-subflow-child-refusal-propagation

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14379

A parent run paused at a subflow node forwards a resume down to the child it is parked on — the screen-flow path, where the caller holds ONE stable run id (the parent's) and posts every wizard step to it. When the child refused that bag, the delegation block read it as a child that ran and died: it called failSuspendedRun on the parent and answered a code-less{ success: false, error }. One mistyped form field therefore destroyed a running workflow — the parent's suspension consumed and a failure recorded, the still-paused child orphaned with nothing left to bubble into, the caller told 400 FLOW_FAILED ("it ran and was rejected") for something that never ran, and their corrected retry on the same run id answered RUN_NOT_FOUND.

The delegation now branches on the child's own code. A retryable refusal is answered as a refusal, with its code intact and both pauses untouched; failSuspendedRun is reserved for a child that genuinely ran and failed.

Head at the time of every measurement below: 4ae326704.

Ruling of record (14379#issuecomment-5504400729, verbatim)

The fix criterion — prefer the producer-first one. The card offers two discriminators; take the second. ⛔ Do not branch on "is the child's suspension still live" — that is a second store read whose answer can race, and it infers intent from state. Branch on the child's own code being one of the refusal codes the engine itself answers (INVALID_SCREEN_INPUT, INVALID_SIGNAL, RESUME_IN_PROGRESS, STORE_UNAVAILABLE), which is the producer naming the condition — the same rule the platform applies everywhere else. failSuspendedRun is then reserved for a child that genuinely ran and failed.

⚠️And propagate the code. The current envelope carries none, which is why the transport lands on 400 FLOW_FAILED. Returning the child's envelope with its code intact is half the fix; a fix that leaves both pauses alive but still answers a code-less envelope has repaired the state and left the caller equally misled.

The pins the card names are the right ones, and one more: refused parent resume ⇒ code: 'INVALID_SCREEN_INPUT', parent and child still suspended, corrected retry on the parent completes. Add a negative control — a child that terminally throws must still fail the parent — or the fix can pass by never failing anything.

⚠️ Serialisation: three cards are live on these same 40 lines

#14392 — the "child run … is gone — continuing without child output" line, which sits in the else of the very if (childRun) this card's arm lives inside. Graded p3 this round. Two separate diffs: a log-text fix disappearing inside a state-machine repair is how the state-machine repair stops getting reviewed on its own merits.

Serialisation honoured: the branch is cut from origin/mainafter PR #14388 merged, and the else arm carrying the "child run … is gone" log line is byte-untouched — #14392 stays a separate diff, still open.

The change

packages/services/service-automation/src/engine.ts, two additions and nothing else:

  1. A module-private closed set, RETRYABLE_RESUME_REFUSAL_CODES, naming the codes resumeInternal itself answers for a resume that never ranINVALID_SCREEN_INPUT, INVALID_SIGNAL, RESUME_IN_PROGRESS, STORE_UNAVAILABLE — plus the one-line predicate that reads it. RUN_NOT_FOUND is deliberately absent and the docblock says why: it is the engine's terminal "this pause is gone for good" class (the automation: the run-resume route still answers HTTP 200 wrapping an inner {success:false} — the route #3962's status-code unification left behind #8684 comment sitting 30 lines above), which a transport answers 404 and no retry can fix.
  2. A new arm placed before the terminal-failure arm, answering the child's envelope verbatim but for the parent's durationMs, consuming neither pause and refreshing nothing (the child did not advance, so the parent's surfaced screen is already current).

No log site was added at any level: a refusal is a response, not a degradation, and the child already logged its own warn where it produced the refusal.

Premise checks on origin/main (all verified before the first edit)

#PremiseVerdict
P1resumeInternal's subflow block still has exactly three arms, no refusal armholds — located at engine.ts:4798 by run.correlation.startsWith('subflow:'); the three arms read exactly as the card quotes them
P2The card's measurement reproduces on the current treeholds — written as a failing test first and run before any source edit: 4 failed, 1 passed, every failure expected undefined to be 'INVALID_SCREEN_INPUT' / 'INVALID_SIGNAL'
P3PR #14388 is on origin/main; refuseInvalidScreenInput / ENGINE_BUILT_SIGNAL untouchedholds — the signal-less normalisation to {} is at the public door; neither symbol appears in the diff
P4The refusal codes are the four the ruling namesholds, with one reachability note below — grepped the producers inside resumeInternal: RESUME_IN_PROGRESS, STORE_UNAVAILABLE, RUN_NOT_FOUND (three sites), INVALID_SCREEN_INPUT (via refuseInvalidScreenInput), INVALID_SIGNAL. No other code is produced there. Nothing was invented and packages/spec was not touched
P5packages/spec, content/docs/releases/** and the #14392 log line untouchedholds — the diff is 3 files: engine.ts, one new test file, one changeset

P4 reachability note (reported, not acted on). Two of the four are reachable through delegation today and are pinned end to end: INVALID_SCREEN_INPUT and INVALID_SIGNAL. The other two are in the set because the producer answers them from this method, but neither has a deterministic fixture: RESUME_IN_PROGRESS needs a real race window against a concurrent direct child resume, and a STORE_UNAVAILABLE outage trips the parent's own loadSuspendedRunStrict several frames earlier, so the parent never reaches the delegation block at all. Keeping them in the set is the producer-first rule applied whole; the pins claim only what was measured.

Hypotheses (each falsifiable, each with its evidence)

  • H1 — one new arm before the terminal one, no failSuspendedRun, no pause consumed: HOLDS. That is the whole source change.
  • H2 — is the parent's pause consumed before the delegation block? NO, so nothing needed restoring.forgetSuspendedRun(run, 'resumed') — the one consumption on this path — sits ~90 lines after the delegation block, and the only other consumer is the failSuspendedRun the new arm bypasses. creditChildRun returns immediately when childSummary is absent, which it is on a direct parent resume. Proven behaviourally, not just by reading: hasSuspendedRun(parentRunId) is true after the refusal in every refusal pin.
  • H3 — the corrected retry completes end to end: HOLDS. The child's screen accepts the corrected bag, the child completes, the engine-built signal maps its output into the parent, and the downstream node observes { kind: 'normal' }. Both suspensions are gone afterwards.
  • H4 — the negative control still fails the parent through failSuspendedRun, envelope shape unchanged: HOLDS. A child whose node throws after the screen accepted the bag still answers success: false, codeundefined, error matching subflow run '…' (child_flow) failed: …, with both suspensions consumed. This test is green on both sides of the change — it is the control that stops the arm from passing by never failing anything.

Tests

New file packages/services/service-automation/src/builtin/subflow-child-refusal.test.ts — 5 pins, built on installBuiltinNodes with real subflow and screen nodes, exactly the composition the card measured.

pinasserts
arefused parent resume ⇒ success: false, code: 'INVALID_SCREEN_INPUT', the child's own actionable text, parent and child still suspended, the parent's surfaced screen unchanged, downstream never ran
bcorrected retry on the same parent run id ⇒ completes, child output mapped into the parent, both suspensions gone
cthe signal-less gesture resume(parentRunId) ⇒ the same refusal, both pauses intact, corrected retry still lands (the population the scope note 14379#issuecomment-5504169090 adds)
da second refusal code on the same path — INVALID_SIGNAL from a reserved variable name against a child pause that declares no screen contract ⇒ same shape, then the legitimate submission lands
enegative control — a child that genuinely ran and threw ⇒ parent failed terminally, code undefined, error text unchanged

Every refusal pin asserts the ADR-0112 code (and the error text), never a bare "it failed".

Verdict lines, quoted from the runs, all at 4ae326704:

BEFORE the source edit (the red half, same test file):
Test Files 1 failed (1)
Tests 4 failed | 1 passed (5)
AssertionError: expected undefined to be 'INVALID_SCREEN_INPUT'
Targeted suites (new pins + subflow-node + both screen-resume suites):
Test Files 4 passed (4)
Tests 41 passed (41)
os-verify-lock: VERDICT command-exit 0
Whole package:
Test Files 99 passed (99)
Tests 1171 passed (1171)
os-verify-lock: VERDICT command-exit 0
Downstream consumer `@objectstack/plugin-approvals` (closure built first):
Test Files 35 passed (35)
Tests 652 passed (652)
os-verify-lock: VERDICT command-exit 0

Type check: this package declares no typecheck script and carries a frozen DEBT ledger entry of 3. tsc --noEmit -p tsconfig.json reports exactly those 3 pre-existing TS2341 in src/nested-region-parity.test.ts (a file this diff does not touch) — unchanged. --listFiles confirms both edited files really are in that program (engine.ts and subflow-child-refusal.test.ts both listed), so this is a measurement and not a green over source nothing read.

Ablation (on the committed tree, both legs proven on disk)

Mutation: the new arm's guard neutralised in place, with a greppable sentinel so the on-disk change is provable from two directions.

HEAD_BLOB=111b7185441f3eabb94e62c8390155c7efe2b625
PRE marker=1 sentinel=0
POST marker=0 sentinel=1
MUTATED_BLOB=5b463c5d48b7d523513e8b01963cf3fad9fa9b7e
ABLATION_VITEST_EXIT=1
Test Files 1 failed (1)
Tests 4 failed | 1 passed (5)
RESTORED_BLOB=111b7185441f3eabb94e62c8390155c7efe2b625
RESTORE marker=1 sentinel=0
git diff HEAD -- $TARGET (expect empty): [empty]
git status --porcelain -- $TARGET (expect empty): [empty]
ABLATION_DONE

Predicted direction, observed: pins a–d red, pin e (the negative control) green. Restore verified by blob-hash equality against the HEAD blob and an empty git diff HEAD, not by an exit code; the script carried a trap … EXIT INT TERM with absolute paths throughout, and an earlier attempt that produced a zero-match substitution aborted at the guard rather than reporting a green ablation — it is reported here as an admitted no-op run, not quietly retried.

No rebuild is required for this ablation and none was performed. The pin imports the subject relatively (../engine.js, same package), which vitest resolves to src/engine.ts, and the package's only resolve.alias entry is an unrelated one for @objectstack/platform-objects. That is proven positively rather than asserted: mutating src/engine.ts alone, with no build, flipped the suite red, and restoring it alone flipped it green.

Gates

Derived on the final head from the actual change set, never a hand-written list: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 4ae326704 — 36 commands over the 3 changed paths. 33 exited 0. Three are PREREQUISITE NOT MET, recorded as NOT MEASURED and not as passes, each with the gate's own words:

  • check-test-completeness (exit 3) — "this gate grades a saved turbo run test log, and no log was named … the local reading for this gate is NOT MEASURED. ⛔ It is not a red".
  • check:dual-build-cjs-loads (exit 3) — "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured." (Its own self-test passed: "93 cases pass".)
  • check:type-check-debt (exit 3) — "--re-measure cannot run: 33 workspace dependenc(ies) … have no built type entry point on disk … ⛔ This is NOT a pass and NOT a finding". Its sibling check:type-check-coveragedid run and passed: "OK — 68/78 workspace packages type-checked".

Every exit code above was captured before any pipe (cmd > file 2>&1; EXIT=$?), and each verdict is quoted from the gate's own output rather than read off a bare $?.

Beyond the derived family: check:nul-bytes passed ("scanned 7940 text file(s) … no raw ASCII control bytes"), and a direct control-byte scan over the three changed files returned no hits.

Repo-wide lint was run in full, not narrowedpnpm lint (eslint . --no-inline-config over the whole repo) exited 0.

git merge-tree --write-tree --name-only origin/main HEAD returned a clean tree with no file list, so content/docs/permissions/system-context.mdx is not implicated and no regeneration is owed.

One honest caveat on the derivation: re-running it after a fresh fetch warned "STALE TREE — this answer is derived from a tree at least 7 commit(s) behind origin/main, and 2 file(s) it derives from CHANGED across that range … .github/workflows/lint.ymlscripts/role-word-baseline.json". Both were inspected: the lint.yml change is comment-only (no step added or removed) and role-word-baseline.json moved by one line, so the family for these paths is unchanged. CI runs the real farm regardless.

Clause-②: no

Declared from the actual diff, not from the plan: git diff -U0 origin/main...HEAD | grep export returns exactly one line, a comment in the new test file ("parks on a real screen node and exports what it collected"). No export was added, removed or renamed, and no accept set moved — RETRYABLE_RESUME_REFUSAL_CODES and isRetryableResumeRefusal are module-private. The public resume contract already declares all four codes as answers (packages/spec/src/contracts/automation-service.ts); this change makes the parent's resume return one instead of swallowing it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

Generated by Claude Code


Generated by Claude Code

…ntract (#14379)
Red half of the reproduction: a parent resume delegated to a child paused on a
screen with a `required` field answers a code-less envelope, fails the parent
and orphans the still-paused child. The negative control (a child that really
ran and threw) is green on both sides.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…l instead of failing the parent (#14379)
The subflow delegation block read every `!childRes.success` as a child that ran
and died. A retryable refusal — the codes `resumeInternal` itself answers for a
resume that never ran — left the child parked where it was, but consumed the
PARENT's pause, recorded a failure, and answered a code-less envelope the
transport maps to `400 FLOW_FAILED`; the corrected retry then answered
`RUN_NOT_FOUND`.
Branch on the child's own `code` (producer-first, per the triage ruling), return
the child's envelope with the code intact and both pauses untouched, and reserve
`failSuspendedRun` for a child that genuinely ran and failed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 7 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx(via INVALID_SCREEN_INPUT (literal, a string literal in RETRYABLE_RESUME_REFUSAL_CODES))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 0e68ed25cc45c15ce296c299614b2b51a3296e52packageMentionDocs.

Which tree this was computed on

This run read content/docs from 12ede636a4b77f1c4df9993fade21b1996dd7e3a — the merge of head 4ae326704950de2991b47cf21bd71c5601bc536f into base 0e68ed25cc45c15ce296c299614b2b51a3296e52, 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 12ede636a4b77f1c4df9993fade21b1996dd7e3a && git checkout 12ede636a4b77f1c4df9993fade21b1996dd7e3a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0e68ed25cc45c15ce296c299614b2b51a3296e52 4ae326704950de2991b47cf21bd71c5601bc536f && git checkout -B drift-repro 0e68ed25cc45c15ce296c299614b2b51a3296e52 && git merge --no-ff 4ae326704950de2991b47cf21bd71c5601bc536f
node scripts/docs-audit/affected-docs.mjs --json 0e68ed25cc45c15ce296c299614b2b51a3296e52

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0e68ed25cc45c15ce296c299614b2b51a3296e52 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-salesClaude

Copy link
Copy Markdown
Collaborator

Landing provenance — ready + auto-merge at head 4ae326704


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 5563bfbSep 2, 2026
34 of 35 checks passed
@os-sales
os-sales deleted the claude/issue-14379-subflow-child-refusal-propagation branch September 2, 2026 13:21
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(service-automation): answer a delegated subflow child refusal as a refusal, not a terminal failure - #14567

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-14379-subflow-child-refusal-propagation
Sep 2, 2026
Merged

fix(service-automation): answer a delegated subflow child refusal as a refusal, not a terminal failure#14567
os-sales merged 2 commits into
mainfrom
claude/issue-14379-subflow-child-refusal-propagation

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14379

A parent run paused at a subflow node forwards a resume down to the child it is parked on — the screen-flow path, where the caller holds ONE stable run id (the parent's) and posts every wizard step to it. When the child refused that bag, the delegation block read it as a child that ran and died: it called failSuspendedRun on the parent and answered a code-less{ success: false, error }. One mistyped form field therefore destroyed a running workflow — the parent's suspension consumed and a failure recorded, the still-paused child orphaned with nothing left to bubble into, the caller told 400 FLOW_FAILED ("it ran and was rejected") for something that never ran, and their corrected retry on the same run id answered RUN_NOT_FOUND.

The delegation now branches on the child's own code. A retryable refusal is answered as a refusal, with its code intact and both pauses untouched; failSuspendedRun is reserved for a child that genuinely ran and failed.

Head at the time of every measurement below: 4ae326704.

Ruling of record (14379#issuecomment-5504400729, verbatim)

The fix criterion — prefer the producer-first one. The card offers two discriminators; take the second. ⛔ Do not branch on "is the child's suspension still live" — that is a second store read whose answer can race, and it infers intent from state. Branch on the child's own code being one of the refusal codes the engine itself answers (INVALID_SCREEN_INPUT, INVALID_SIGNAL, RESUME_IN_PROGRESS, STORE_UNAVAILABLE), which is the producer naming the condition — the same rule the platform applies everywhere else. failSuspendedRun is then reserved for a child that genuinely ran and failed.

⚠️And propagate the code. The current envelope carries none, which is why the transport lands on 400 FLOW_FAILED. Returning the child's envelope with its code intact is half the fix; a fix that leaves both pauses alive but still answers a code-less envelope has repaired the state and left the caller equally misled.

The pins the card names are the right ones, and one more: refused parent resume ⇒ code: 'INVALID_SCREEN_INPUT', parent and child still suspended, corrected retry on the parent completes. Add a negative control — a child that terminally throws must still fail the parent — or the fix can pass by never failing anything.

⚠️ Serialisation: three cards are live on these same 40 lines

#14392 — the "child run … is gone — continuing without child output" line, which sits in the else of the very if (childRun) this card's arm lives inside. Graded p3 this round. Two separate diffs: a log-text fix disappearing inside a state-machine repair is how the state-machine repair stops getting reviewed on its own merits.

Serialisation honoured: the branch is cut from origin/mainafter PR #14388 merged, and the else arm carrying the "child run … is gone" log line is byte-untouched — #14392 stays a separate diff, still open.

The change

packages/services/service-automation/src/engine.ts, two additions and nothing else:

  1. A module-private closed set, RETRYABLE_RESUME_REFUSAL_CODES, naming the codes resumeInternal itself answers for a resume that never ranINVALID_SCREEN_INPUT, INVALID_SIGNAL, RESUME_IN_PROGRESS, STORE_UNAVAILABLE — plus the one-line predicate that reads it. RUN_NOT_FOUND is deliberately absent and the docblock says why: it is the engine's terminal "this pause is gone for good" class (the automation: the run-resume route still answers HTTP 200 wrapping an inner {success:false} — the route #3962's status-code unification left behind #8684 comment sitting 30 lines above), which a transport answers 404 and no retry can fix.
  2. A new arm placed before the terminal-failure arm, answering the child's envelope verbatim but for the parent's durationMs, consuming neither pause and refreshing nothing (the child did not advance, so the parent's surfaced screen is already current).

No log site was added at any level: a refusal is a response, not a degradation, and the child already logged its own warn where it produced the refusal.

Premise checks on origin/main (all verified before the first edit)

#PremiseVerdict
P1resumeInternal's subflow block still has exactly three arms, no refusal armholds — located at engine.ts:4798 by run.correlation.startsWith('subflow:'); the three arms read exactly as the card quotes them
P2The card's measurement reproduces on the current treeholds — written as a failing test first and run before any source edit: 4 failed, 1 passed, every failure expected undefined to be 'INVALID_SCREEN_INPUT' / 'INVALID_SIGNAL'
P3PR #14388 is on origin/main; refuseInvalidScreenInput / ENGINE_BUILT_SIGNAL untouchedholds — the signal-less normalisation to {} is at the public door; neither symbol appears in the diff
P4The refusal codes are the four the ruling namesholds, with one reachability note below — grepped the producers inside resumeInternal: RESUME_IN_PROGRESS, STORE_UNAVAILABLE, RUN_NOT_FOUND (three sites), INVALID_SCREEN_INPUT (via refuseInvalidScreenInput), INVALID_SIGNAL. No other code is produced there. Nothing was invented and packages/spec was not touched
P5packages/spec, content/docs/releases/** and the #14392 log line untouchedholds — the diff is 3 files: engine.ts, one new test file, one changeset

P4 reachability note (reported, not acted on). Two of the four are reachable through delegation today and are pinned end to end: INVALID_SCREEN_INPUT and INVALID_SIGNAL. The other two are in the set because the producer answers them from this method, but neither has a deterministic fixture: RESUME_IN_PROGRESS needs a real race window against a concurrent direct child resume, and a STORE_UNAVAILABLE outage trips the parent's own loadSuspendedRunStrict several frames earlier, so the parent never reaches the delegation block at all. Keeping them in the set is the producer-first rule applied whole; the pins claim only what was measured.

Hypotheses (each falsifiable, each with its evidence)

  • H1 — one new arm before the terminal one, no failSuspendedRun, no pause consumed: HOLDS. That is the whole source change.
  • H2 — is the parent's pause consumed before the delegation block? NO, so nothing needed restoring.forgetSuspendedRun(run, 'resumed') — the one consumption on this path — sits ~90 lines after the delegation block, and the only other consumer is the failSuspendedRun the new arm bypasses. creditChildRun returns immediately when childSummary is absent, which it is on a direct parent resume. Proven behaviourally, not just by reading: hasSuspendedRun(parentRunId) is true after the refusal in every refusal pin.
  • H3 — the corrected retry completes end to end: HOLDS. The child's screen accepts the corrected bag, the child completes, the engine-built signal maps its output into the parent, and the downstream node observes { kind: 'normal' }. Both suspensions are gone afterwards.
  • H4 — the negative control still fails the parent through failSuspendedRun, envelope shape unchanged: HOLDS. A child whose node throws after the screen accepted the bag still answers success: false, codeundefined, error matching subflow run '…' (child_flow) failed: …, with both suspensions consumed. This test is green on both sides of the change — it is the control that stops the arm from passing by never failing anything.

Tests

New file packages/services/service-automation/src/builtin/subflow-child-refusal.test.ts — 5 pins, built on installBuiltinNodes with real subflow and screen nodes, exactly the composition the card measured.

pinasserts
arefused parent resume ⇒ success: false, code: 'INVALID_SCREEN_INPUT', the child's own actionable text, parent and child still suspended, the parent's surfaced screen unchanged, downstream never ran
bcorrected retry on the same parent run id ⇒ completes, child output mapped into the parent, both suspensions gone
cthe signal-less gesture resume(parentRunId) ⇒ the same refusal, both pauses intact, corrected retry still lands (the population the scope note 14379#issuecomment-5504169090 adds)
da second refusal code on the same path — INVALID_SIGNAL from a reserved variable name against a child pause that declares no screen contract ⇒ same shape, then the legitimate submission lands
enegative control — a child that genuinely ran and threw ⇒ parent failed terminally, code undefined, error text unchanged

Every refusal pin asserts the ADR-0112 code (and the error text), never a bare "it failed".

Verdict lines, quoted from the runs, all at 4ae326704:

BEFORE the source edit (the red half, same test file):
Test Files 1 failed (1)
Tests 4 failed | 1 passed (5)
AssertionError: expected undefined to be 'INVALID_SCREEN_INPUT'
Targeted suites (new pins + subflow-node + both screen-resume suites):
Test Files 4 passed (4)
Tests 41 passed (41)
os-verify-lock: VERDICT command-exit 0
Whole package:
Test Files 99 passed (99)
Tests 1171 passed (1171)
os-verify-lock: VERDICT command-exit 0
Downstream consumer `@objectstack/plugin-approvals` (closure built first):
Test Files 35 passed (35)
Tests 652 passed (652)
os-verify-lock: VERDICT command-exit 0

Type check: this package declares no typecheck script and carries a frozen DEBT ledger entry of 3. tsc --noEmit -p tsconfig.json reports exactly those 3 pre-existing TS2341 in src/nested-region-parity.test.ts (a file this diff does not touch) — unchanged. --listFiles confirms both edited files really are in that program (engine.ts and subflow-child-refusal.test.ts both listed), so this is a measurement and not a green over source nothing read.

Ablation (on the committed tree, both legs proven on disk)

Mutation: the new arm's guard neutralised in place, with a greppable sentinel so the on-disk change is provable from two directions.

HEAD_BLOB=111b7185441f3eabb94e62c8390155c7efe2b625
PRE marker=1 sentinel=0
POST marker=0 sentinel=1
MUTATED_BLOB=5b463c5d48b7d523513e8b01963cf3fad9fa9b7e
ABLATION_VITEST_EXIT=1
Test Files 1 failed (1)
Tests 4 failed | 1 passed (5)
RESTORED_BLOB=111b7185441f3eabb94e62c8390155c7efe2b625
RESTORE marker=1 sentinel=0
git diff HEAD -- $TARGET (expect empty): [empty]
git status --porcelain -- $TARGET (expect empty): [empty]
ABLATION_DONE

Predicted direction, observed: pins a–d red, pin e (the negative control) green. Restore verified by blob-hash equality against the HEAD blob and an empty git diff HEAD, not by an exit code; the script carried a trap … EXIT INT TERM with absolute paths throughout, and an earlier attempt that produced a zero-match substitution aborted at the guard rather than reporting a green ablation — it is reported here as an admitted no-op run, not quietly retried.

No rebuild is required for this ablation and none was performed. The pin imports the subject relatively (../engine.js, same package), which vitest resolves to src/engine.ts, and the package's only resolve.alias entry is an unrelated one for @objectstack/platform-objects. That is proven positively rather than asserted: mutating src/engine.ts alone, with no build, flipped the suite red, and restoring it alone flipped it green.

Gates

Derived on the final head from the actual change set, never a hand-written list: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 4ae326704 — 36 commands over the 3 changed paths. 33 exited 0. Three are PREREQUISITE NOT MET, recorded as NOT MEASURED and not as passes, each with the gate's own words:

  • check-test-completeness (exit 3) — "this gate grades a saved turbo run test log, and no log was named … the local reading for this gate is NOT MEASURED. ⛔ It is not a red".
  • check:dual-build-cjs-loads (exit 3) — "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured." (Its own self-test passed: "93 cases pass".)
  • check:type-check-debt (exit 3) — "--re-measure cannot run: 33 workspace dependenc(ies) … have no built type entry point on disk … ⛔ This is NOT a pass and NOT a finding". Its sibling check:type-check-coveragedid run and passed: "OK — 68/78 workspace packages type-checked".

Every exit code above was captured before any pipe (cmd > file 2>&1; EXIT=$?), and each verdict is quoted from the gate's own output rather than read off a bare $?.

Beyond the derived family: check:nul-bytes passed ("scanned 7940 text file(s) … no raw ASCII control bytes"), and a direct control-byte scan over the three changed files returned no hits.

Repo-wide lint was run in full, not narrowedpnpm lint (eslint . --no-inline-config over the whole repo) exited 0.

git merge-tree --write-tree --name-only origin/main HEAD returned a clean tree with no file list, so content/docs/permissions/system-context.mdx is not implicated and no regeneration is owed.

One honest caveat on the derivation: re-running it after a fresh fetch warned "STALE TREE — this answer is derived from a tree at least 7 commit(s) behind origin/main, and 2 file(s) it derives from CHANGED across that range … .github/workflows/lint.ymlscripts/role-word-baseline.json". Both were inspected: the lint.yml change is comment-only (no step added or removed) and role-word-baseline.json moved by one line, so the family for these paths is unchanged. CI runs the real farm regardless.

Clause-②: no

Declared from the actual diff, not from the plan: git diff -U0 origin/main...HEAD | grep export returns exactly one line, a comment in the new test file ("parks on a real screen node and exports what it collected"). No export was added, removed or renamed, and no accept set moved — RETRYABLE_RESUME_REFUSAL_CODES and isRetryableResumeRefusal are module-private. The public resume contract already declares all four codes as answers (packages/spec/src/contracts/automation-service.ts); this change makes the parent's resume return one instead of swallowing it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

Generated by Claude Code


Generated by Claude Code

…ntract (#14379)
Red half of the reproduction: a parent resume delegated to a child paused on a
screen with a `required` field answers a code-less envelope, fails the parent
and orphans the still-paused child. The negative control (a child that really
ran and threw) is green on both sides.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…l instead of failing the parent (#14379)
The subflow delegation block read every `!childRes.success` as a child that ran
and died. A retryable refusal — the codes `resumeInternal` itself answers for a
resume that never ran — left the child parked where it was, but consumed the
PARENT's pause, recorded a failure, and answered a code-less envelope the
transport maps to `400 FLOW_FAILED`; the corrected retry then answered
`RUN_NOT_FOUND`.
Branch on the child's own `code` (producer-first, per the triage ruling), return
the child's envelope with the code intact and both pauses untouched, and reserve
`failSuspendedRun` for a child that genuinely ran and failed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 7 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx(via INVALID_SCREEN_INPUT (literal, a string literal in RETRYABLE_RESUME_REFUSAL_CODES))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 0e68ed25cc45c15ce296c299614b2b51a3296e52packageMentionDocs.

Which tree this was computed on

This run read content/docs from 12ede636a4b77f1c4df9993fade21b1996dd7e3a — the merge of head 4ae326704950de2991b47cf21bd71c5601bc536f into base 0e68ed25cc45c15ce296c299614b2b51a3296e52, 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 12ede636a4b77f1c4df9993fade21b1996dd7e3a && git checkout 12ede636a4b77f1c4df9993fade21b1996dd7e3a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0e68ed25cc45c15ce296c299614b2b51a3296e52 4ae326704950de2991b47cf21bd71c5601bc536f && git checkout -B drift-repro 0e68ed25cc45c15ce296c299614b2b51a3296e52 && git merge --no-ff 4ae326704950de2991b47cf21bd71c5601bc536f
node scripts/docs-audit/affected-docs.mjs --json 0e68ed25cc45c15ce296c299614b2b51a3296e52

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0e68ed25cc45c15ce296c299614b2b51a3296e52 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-salesClaude

Copy link
Copy Markdown
Collaborator

Landing provenance — ready + auto-merge at head 4ae326704


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 5563bfbSep 2, 2026
34 of 35 checks passed
@os-sales
os-sales deleted the claude/issue-14379-subflow-child-refusal-propagation branch September 2, 2026 13:21
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(service-automation): answer a delegated subflow child refusal as a refusal, not a terminal failure - #14567

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-14379-subflow-child-refusal-propagation
Sep 2, 2026
Merged

fix(service-automation): answer a delegated subflow child refusal as a refusal, not a terminal failure#14567
os-sales merged 2 commits into
mainfrom
claude/issue-14379-subflow-child-refusal-propagation

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14379

A parent run paused at a subflow node forwards a resume down to the child it is parked on — the screen-flow path, where the caller holds ONE stable run id (the parent's) and posts every wizard step to it. When the child refused that bag, the delegation block read it as a child that ran and died: it called failSuspendedRun on the parent and answered a code-less{ success: false, error }. One mistyped form field therefore destroyed a running workflow — the parent's suspension consumed and a failure recorded, the still-paused child orphaned with nothing left to bubble into, the caller told 400 FLOW_FAILED ("it ran and was rejected") for something that never ran, and their corrected retry on the same run id answered RUN_NOT_FOUND.

The delegation now branches on the child's own code. A retryable refusal is answered as a refusal, with its code intact and both pauses untouched; failSuspendedRun is reserved for a child that genuinely ran and failed.

Head at the time of every measurement below: 4ae326704.

Ruling of record (14379#issuecomment-5504400729, verbatim)

The fix criterion — prefer the producer-first one. The card offers two discriminators; take the second. ⛔ Do not branch on "is the child's suspension still live" — that is a second store read whose answer can race, and it infers intent from state. Branch on the child's own code being one of the refusal codes the engine itself answers (INVALID_SCREEN_INPUT, INVALID_SIGNAL, RESUME_IN_PROGRESS, STORE_UNAVAILABLE), which is the producer naming the condition — the same rule the platform applies everywhere else. failSuspendedRun is then reserved for a child that genuinely ran and failed.

⚠️And propagate the code. The current envelope carries none, which is why the transport lands on 400 FLOW_FAILED. Returning the child's envelope with its code intact is half the fix; a fix that leaves both pauses alive but still answers a code-less envelope has repaired the state and left the caller equally misled.

The pins the card names are the right ones, and one more: refused parent resume ⇒ code: 'INVALID_SCREEN_INPUT', parent and child still suspended, corrected retry on the parent completes. Add a negative control — a child that terminally throws must still fail the parent — or the fix can pass by never failing anything.

⚠️ Serialisation: three cards are live on these same 40 lines

#14392 — the "child run … is gone — continuing without child output" line, which sits in the else of the very if (childRun) this card's arm lives inside. Graded p3 this round. Two separate diffs: a log-text fix disappearing inside a state-machine repair is how the state-machine repair stops getting reviewed on its own merits.

Serialisation honoured: the branch is cut from origin/mainafter PR #14388 merged, and the else arm carrying the "child run … is gone" log line is byte-untouched — #14392 stays a separate diff, still open.

The change

packages/services/service-automation/src/engine.ts, two additions and nothing else:

  1. A module-private closed set, RETRYABLE_RESUME_REFUSAL_CODES, naming the codes resumeInternal itself answers for a resume that never ranINVALID_SCREEN_INPUT, INVALID_SIGNAL, RESUME_IN_PROGRESS, STORE_UNAVAILABLE — plus the one-line predicate that reads it. RUN_NOT_FOUND is deliberately absent and the docblock says why: it is the engine's terminal "this pause is gone for good" class (the automation: the run-resume route still answers HTTP 200 wrapping an inner {success:false} — the route #3962's status-code unification left behind #8684 comment sitting 30 lines above), which a transport answers 404 and no retry can fix.
  2. A new arm placed before the terminal-failure arm, answering the child's envelope verbatim but for the parent's durationMs, consuming neither pause and refreshing nothing (the child did not advance, so the parent's surfaced screen is already current).

No log site was added at any level: a refusal is a response, not a degradation, and the child already logged its own warn where it produced the refusal.

Premise checks on origin/main (all verified before the first edit)

#PremiseVerdict
P1resumeInternal's subflow block still has exactly three arms, no refusal armholds — located at engine.ts:4798 by run.correlation.startsWith('subflow:'); the three arms read exactly as the card quotes them
P2The card's measurement reproduces on the current treeholds — written as a failing test first and run before any source edit: 4 failed, 1 passed, every failure expected undefined to be 'INVALID_SCREEN_INPUT' / 'INVALID_SIGNAL'
P3PR #14388 is on origin/main; refuseInvalidScreenInput / ENGINE_BUILT_SIGNAL untouchedholds — the signal-less normalisation to {} is at the public door; neither symbol appears in the diff
P4The refusal codes are the four the ruling namesholds, with one reachability note below — grepped the producers inside resumeInternal: RESUME_IN_PROGRESS, STORE_UNAVAILABLE, RUN_NOT_FOUND (three sites), INVALID_SCREEN_INPUT (via refuseInvalidScreenInput), INVALID_SIGNAL. No other code is produced there. Nothing was invented and packages/spec was not touched
P5packages/spec, content/docs/releases/** and the #14392 log line untouchedholds — the diff is 3 files: engine.ts, one new test file, one changeset

P4 reachability note (reported, not acted on). Two of the four are reachable through delegation today and are pinned end to end: INVALID_SCREEN_INPUT and INVALID_SIGNAL. The other two are in the set because the producer answers them from this method, but neither has a deterministic fixture: RESUME_IN_PROGRESS needs a real race window against a concurrent direct child resume, and a STORE_UNAVAILABLE outage trips the parent's own loadSuspendedRunStrict several frames earlier, so the parent never reaches the delegation block at all. Keeping them in the set is the producer-first rule applied whole; the pins claim only what was measured.

Hypotheses (each falsifiable, each with its evidence)

  • H1 — one new arm before the terminal one, no failSuspendedRun, no pause consumed: HOLDS. That is the whole source change.
  • H2 — is the parent's pause consumed before the delegation block? NO, so nothing needed restoring.forgetSuspendedRun(run, 'resumed') — the one consumption on this path — sits ~90 lines after the delegation block, and the only other consumer is the failSuspendedRun the new arm bypasses. creditChildRun returns immediately when childSummary is absent, which it is on a direct parent resume. Proven behaviourally, not just by reading: hasSuspendedRun(parentRunId) is true after the refusal in every refusal pin.
  • H3 — the corrected retry completes end to end: HOLDS. The child's screen accepts the corrected bag, the child completes, the engine-built signal maps its output into the parent, and the downstream node observes { kind: 'normal' }. Both suspensions are gone afterwards.
  • H4 — the negative control still fails the parent through failSuspendedRun, envelope shape unchanged: HOLDS. A child whose node throws after the screen accepted the bag still answers success: false, codeundefined, error matching subflow run '…' (child_flow) failed: …, with both suspensions consumed. This test is green on both sides of the change — it is the control that stops the arm from passing by never failing anything.

Tests

New file packages/services/service-automation/src/builtin/subflow-child-refusal.test.ts — 5 pins, built on installBuiltinNodes with real subflow and screen nodes, exactly the composition the card measured.

pinasserts
arefused parent resume ⇒ success: false, code: 'INVALID_SCREEN_INPUT', the child's own actionable text, parent and child still suspended, the parent's surfaced screen unchanged, downstream never ran
bcorrected retry on the same parent run id ⇒ completes, child output mapped into the parent, both suspensions gone
cthe signal-less gesture resume(parentRunId) ⇒ the same refusal, both pauses intact, corrected retry still lands (the population the scope note 14379#issuecomment-5504169090 adds)
da second refusal code on the same path — INVALID_SIGNAL from a reserved variable name against a child pause that declares no screen contract ⇒ same shape, then the legitimate submission lands
enegative control — a child that genuinely ran and threw ⇒ parent failed terminally, code undefined, error text unchanged

Every refusal pin asserts the ADR-0112 code (and the error text), never a bare "it failed".

Verdict lines, quoted from the runs, all at 4ae326704:

BEFORE the source edit (the red half, same test file):
Test Files 1 failed (1)
Tests 4 failed | 1 passed (5)
AssertionError: expected undefined to be 'INVALID_SCREEN_INPUT'
Targeted suites (new pins + subflow-node + both screen-resume suites):
Test Files 4 passed (4)
Tests 41 passed (41)
os-verify-lock: VERDICT command-exit 0
Whole package:
Test Files 99 passed (99)
Tests 1171 passed (1171)
os-verify-lock: VERDICT command-exit 0
Downstream consumer `@objectstack/plugin-approvals` (closure built first):
Test Files 35 passed (35)
Tests 652 passed (652)
os-verify-lock: VERDICT command-exit 0

Type check: this package declares no typecheck script and carries a frozen DEBT ledger entry of 3. tsc --noEmit -p tsconfig.json reports exactly those 3 pre-existing TS2341 in src/nested-region-parity.test.ts (a file this diff does not touch) — unchanged. --listFiles confirms both edited files really are in that program (engine.ts and subflow-child-refusal.test.ts both listed), so this is a measurement and not a green over source nothing read.

Ablation (on the committed tree, both legs proven on disk)

Mutation: the new arm's guard neutralised in place, with a greppable sentinel so the on-disk change is provable from two directions.

HEAD_BLOB=111b7185441f3eabb94e62c8390155c7efe2b625
PRE marker=1 sentinel=0
POST marker=0 sentinel=1
MUTATED_BLOB=5b463c5d48b7d523513e8b01963cf3fad9fa9b7e
ABLATION_VITEST_EXIT=1
Test Files 1 failed (1)
Tests 4 failed | 1 passed (5)
RESTORED_BLOB=111b7185441f3eabb94e62c8390155c7efe2b625
RESTORE marker=1 sentinel=0
git diff HEAD -- $TARGET (expect empty): [empty]
git status --porcelain -- $TARGET (expect empty): [empty]
ABLATION_DONE

Predicted direction, observed: pins a–d red, pin e (the negative control) green. Restore verified by blob-hash equality against the HEAD blob and an empty git diff HEAD, not by an exit code; the script carried a trap … EXIT INT TERM with absolute paths throughout, and an earlier attempt that produced a zero-match substitution aborted at the guard rather than reporting a green ablation — it is reported here as an admitted no-op run, not quietly retried.

No rebuild is required for this ablation and none was performed. The pin imports the subject relatively (../engine.js, same package), which vitest resolves to src/engine.ts, and the package's only resolve.alias entry is an unrelated one for @objectstack/platform-objects. That is proven positively rather than asserted: mutating src/engine.ts alone, with no build, flipped the suite red, and restoring it alone flipped it green.

Gates

Derived on the final head from the actual change set, never a hand-written list: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 4ae326704 — 36 commands over the 3 changed paths. 33 exited 0. Three are PREREQUISITE NOT MET, recorded as NOT MEASURED and not as passes, each with the gate's own words:

  • check-test-completeness (exit 3) — "this gate grades a saved turbo run test log, and no log was named … the local reading for this gate is NOT MEASURED. ⛔ It is not a red".
  • check:dual-build-cjs-loads (exit 3) — "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured." (Its own self-test passed: "93 cases pass".)
  • check:type-check-debt (exit 3) — "--re-measure cannot run: 33 workspace dependenc(ies) … have no built type entry point on disk … ⛔ This is NOT a pass and NOT a finding". Its sibling check:type-check-coveragedid run and passed: "OK — 68/78 workspace packages type-checked".

Every exit code above was captured before any pipe (cmd > file 2>&1; EXIT=$?), and each verdict is quoted from the gate's own output rather than read off a bare $?.

Beyond the derived family: check:nul-bytes passed ("scanned 7940 text file(s) … no raw ASCII control bytes"), and a direct control-byte scan over the three changed files returned no hits.

Repo-wide lint was run in full, not narrowedpnpm lint (eslint . --no-inline-config over the whole repo) exited 0.

git merge-tree --write-tree --name-only origin/main HEAD returned a clean tree with no file list, so content/docs/permissions/system-context.mdx is not implicated and no regeneration is owed.

One honest caveat on the derivation: re-running it after a fresh fetch warned "STALE TREE — this answer is derived from a tree at least 7 commit(s) behind origin/main, and 2 file(s) it derives from CHANGED across that range … .github/workflows/lint.ymlscripts/role-word-baseline.json". Both were inspected: the lint.yml change is comment-only (no step added or removed) and role-word-baseline.json moved by one line, so the family for these paths is unchanged. CI runs the real farm regardless.

Clause-②: no

Declared from the actual diff, not from the plan: git diff -U0 origin/main...HEAD | grep export returns exactly one line, a comment in the new test file ("parks on a real screen node and exports what it collected"). No export was added, removed or renamed, and no accept set moved — RETRYABLE_RESUME_REFUSAL_CODES and isRetryableResumeRefusal are module-private. The public resume contract already declares all four codes as answers (packages/spec/src/contracts/automation-service.ts); this change makes the parent's resume return one instead of swallowing it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

Generated by Claude Code


Generated by Claude Code

…ntract (#14379)
Red half of the reproduction: a parent resume delegated to a child paused on a
screen with a `required` field answers a code-less envelope, fails the parent
and orphans the still-paused child. The negative control (a child that really
ran and threw) is green on both sides.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…l instead of failing the parent (#14379)
The subflow delegation block read every `!childRes.success` as a child that ran
and died. A retryable refusal — the codes `resumeInternal` itself answers for a
resume that never ran — left the child parked where it was, but consumed the
PARENT's pause, recorded a failure, and answered a code-less envelope the
transport maps to `400 FLOW_FAILED`; the corrected retry then answered
`RUN_NOT_FOUND`.
Branch on the child's own `code` (producer-first, per the triage ruling), return
the child's envelope with the code intact and both pauses untouched, and reserve
`failSuspendedRun` for a child that genuinely ran and failed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 7 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx(via INVALID_SCREEN_INPUT (literal, a string literal in RETRYABLE_RESUME_REFUSAL_CODES))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 0e68ed25cc45c15ce296c299614b2b51a3296e52packageMentionDocs.

Which tree this was computed on

This run read content/docs from 12ede636a4b77f1c4df9993fade21b1996dd7e3a — the merge of head 4ae326704950de2991b47cf21bd71c5601bc536f into base 0e68ed25cc45c15ce296c299614b2b51a3296e52, 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 12ede636a4b77f1c4df9993fade21b1996dd7e3a && git checkout 12ede636a4b77f1c4df9993fade21b1996dd7e3a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0e68ed25cc45c15ce296c299614b2b51a3296e52 4ae326704950de2991b47cf21bd71c5601bc536f && git checkout -B drift-repro 0e68ed25cc45c15ce296c299614b2b51a3296e52 && git merge --no-ff 4ae326704950de2991b47cf21bd71c5601bc536f
node scripts/docs-audit/affected-docs.mjs --json 0e68ed25cc45c15ce296c299614b2b51a3296e52

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0e68ed25cc45c15ce296c299614b2b51a3296e52 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-salesClaude

Copy link
Copy Markdown
Collaborator

Landing provenance — ready + auto-merge at head 4ae326704


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 5563bfbSep 2, 2026
34 of 35 checks passed
@os-sales
os-sales deleted the claude/issue-14379-subflow-child-refusal-propagation branch September 2, 2026 13:21
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(service-automation): answer a delegated subflow child refusal as a refusal, not a terminal failure - #14567

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-14379-subflow-child-refusal-propagation
Sep 2, 2026
Merged

fix(service-automation): answer a delegated subflow child refusal as a refusal, not a terminal failure#14567
os-sales merged 2 commits into
mainfrom
claude/issue-14379-subflow-child-refusal-propagation

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14379

A parent run paused at a subflow node forwards a resume down to the child it is parked on — the screen-flow path, where the caller holds ONE stable run id (the parent's) and posts every wizard step to it. When the child refused that bag, the delegation block read it as a child that ran and died: it called failSuspendedRun on the parent and answered a code-less{ success: false, error }. One mistyped form field therefore destroyed a running workflow — the parent's suspension consumed and a failure recorded, the still-paused child orphaned with nothing left to bubble into, the caller told 400 FLOW_FAILED ("it ran and was rejected") for something that never ran, and their corrected retry on the same run id answered RUN_NOT_FOUND.

The delegation now branches on the child's own code. A retryable refusal is answered as a refusal, with its code intact and both pauses untouched; failSuspendedRun is reserved for a child that genuinely ran and failed.

Head at the time of every measurement below: 4ae326704.

Ruling of record (14379#issuecomment-5504400729, verbatim)

The fix criterion — prefer the producer-first one. The card offers two discriminators; take the second. ⛔ Do not branch on "is the child's suspension still live" — that is a second store read whose answer can race, and it infers intent from state. Branch on the child's own code being one of the refusal codes the engine itself answers (INVALID_SCREEN_INPUT, INVALID_SIGNAL, RESUME_IN_PROGRESS, STORE_UNAVAILABLE), which is the producer naming the condition — the same rule the platform applies everywhere else. failSuspendedRun is then reserved for a child that genuinely ran and failed.

⚠️And propagate the code. The current envelope carries none, which is why the transport lands on 400 FLOW_FAILED. Returning the child's envelope with its code intact is half the fix; a fix that leaves both pauses alive but still answers a code-less envelope has repaired the state and left the caller equally misled.

The pins the card names are the right ones, and one more: refused parent resume ⇒ code: 'INVALID_SCREEN_INPUT', parent and child still suspended, corrected retry on the parent completes. Add a negative control — a child that terminally throws must still fail the parent — or the fix can pass by never failing anything.

⚠️ Serialisation: three cards are live on these same 40 lines

#14392 — the "child run … is gone — continuing without child output" line, which sits in the else of the very if (childRun) this card's arm lives inside. Graded p3 this round. Two separate diffs: a log-text fix disappearing inside a state-machine repair is how the state-machine repair stops getting reviewed on its own merits.

Serialisation honoured: the branch is cut from origin/mainafter PR #14388 merged, and the else arm carrying the "child run … is gone" log line is byte-untouched — #14392 stays a separate diff, still open.

The change

packages/services/service-automation/src/engine.ts, two additions and nothing else:

  1. A module-private closed set, RETRYABLE_RESUME_REFUSAL_CODES, naming the codes resumeInternal itself answers for a resume that never ranINVALID_SCREEN_INPUT, INVALID_SIGNAL, RESUME_IN_PROGRESS, STORE_UNAVAILABLE — plus the one-line predicate that reads it. RUN_NOT_FOUND is deliberately absent and the docblock says why: it is the engine's terminal "this pause is gone for good" class (the automation: the run-resume route still answers HTTP 200 wrapping an inner {success:false} — the route #3962's status-code unification left behind #8684 comment sitting 30 lines above), which a transport answers 404 and no retry can fix.
  2. A new arm placed before the terminal-failure arm, answering the child's envelope verbatim but for the parent's durationMs, consuming neither pause and refreshing nothing (the child did not advance, so the parent's surfaced screen is already current).

No log site was added at any level: a refusal is a response, not a degradation, and the child already logged its own warn where it produced the refusal.

Premise checks on origin/main (all verified before the first edit)

#PremiseVerdict
P1resumeInternal's subflow block still has exactly three arms, no refusal armholds — located at engine.ts:4798 by run.correlation.startsWith('subflow:'); the three arms read exactly as the card quotes them
P2The card's measurement reproduces on the current treeholds — written as a failing test first and run before any source edit: 4 failed, 1 passed, every failure expected undefined to be 'INVALID_SCREEN_INPUT' / 'INVALID_SIGNAL'
P3PR #14388 is on origin/main; refuseInvalidScreenInput / ENGINE_BUILT_SIGNAL untouchedholds — the signal-less normalisation to {} is at the public door; neither symbol appears in the diff
P4The refusal codes are the four the ruling namesholds, with one reachability note below — grepped the producers inside resumeInternal: RESUME_IN_PROGRESS, STORE_UNAVAILABLE, RUN_NOT_FOUND (three sites), INVALID_SCREEN_INPUT (via refuseInvalidScreenInput), INVALID_SIGNAL. No other code is produced there. Nothing was invented and packages/spec was not touched
P5packages/spec, content/docs/releases/** and the #14392 log line untouchedholds — the diff is 3 files: engine.ts, one new test file, one changeset

P4 reachability note (reported, not acted on). Two of the four are reachable through delegation today and are pinned end to end: INVALID_SCREEN_INPUT and INVALID_SIGNAL. The other two are in the set because the producer answers them from this method, but neither has a deterministic fixture: RESUME_IN_PROGRESS needs a real race window against a concurrent direct child resume, and a STORE_UNAVAILABLE outage trips the parent's own loadSuspendedRunStrict several frames earlier, so the parent never reaches the delegation block at all. Keeping them in the set is the producer-first rule applied whole; the pins claim only what was measured.

Hypotheses (each falsifiable, each with its evidence)

  • H1 — one new arm before the terminal one, no failSuspendedRun, no pause consumed: HOLDS. That is the whole source change.
  • H2 — is the parent's pause consumed before the delegation block? NO, so nothing needed restoring.forgetSuspendedRun(run, 'resumed') — the one consumption on this path — sits ~90 lines after the delegation block, and the only other consumer is the failSuspendedRun the new arm bypasses. creditChildRun returns immediately when childSummary is absent, which it is on a direct parent resume. Proven behaviourally, not just by reading: hasSuspendedRun(parentRunId) is true after the refusal in every refusal pin.
  • H3 — the corrected retry completes end to end: HOLDS. The child's screen accepts the corrected bag, the child completes, the engine-built signal maps its output into the parent, and the downstream node observes { kind: 'normal' }. Both suspensions are gone afterwards.
  • H4 — the negative control still fails the parent through failSuspendedRun, envelope shape unchanged: HOLDS. A child whose node throws after the screen accepted the bag still answers success: false, codeundefined, error matching subflow run '…' (child_flow) failed: …, with both suspensions consumed. This test is green on both sides of the change — it is the control that stops the arm from passing by never failing anything.

Tests

New file packages/services/service-automation/src/builtin/subflow-child-refusal.test.ts — 5 pins, built on installBuiltinNodes with real subflow and screen nodes, exactly the composition the card measured.

pinasserts
arefused parent resume ⇒ success: false, code: 'INVALID_SCREEN_INPUT', the child's own actionable text, parent and child still suspended, the parent's surfaced screen unchanged, downstream never ran
bcorrected retry on the same parent run id ⇒ completes, child output mapped into the parent, both suspensions gone
cthe signal-less gesture resume(parentRunId) ⇒ the same refusal, both pauses intact, corrected retry still lands (the population the scope note 14379#issuecomment-5504169090 adds)
da second refusal code on the same path — INVALID_SIGNAL from a reserved variable name against a child pause that declares no screen contract ⇒ same shape, then the legitimate submission lands
enegative control — a child that genuinely ran and threw ⇒ parent failed terminally, code undefined, error text unchanged

Every refusal pin asserts the ADR-0112 code (and the error text), never a bare "it failed".

Verdict lines, quoted from the runs, all at 4ae326704:

BEFORE the source edit (the red half, same test file):
Test Files 1 failed (1)
Tests 4 failed | 1 passed (5)
AssertionError: expected undefined to be 'INVALID_SCREEN_INPUT'
Targeted suites (new pins + subflow-node + both screen-resume suites):
Test Files 4 passed (4)
Tests 41 passed (41)
os-verify-lock: VERDICT command-exit 0
Whole package:
Test Files 99 passed (99)
Tests 1171 passed (1171)
os-verify-lock: VERDICT command-exit 0
Downstream consumer `@objectstack/plugin-approvals` (closure built first):
Test Files 35 passed (35)
Tests 652 passed (652)
os-verify-lock: VERDICT command-exit 0

Type check: this package declares no typecheck script and carries a frozen DEBT ledger entry of 3. tsc --noEmit -p tsconfig.json reports exactly those 3 pre-existing TS2341 in src/nested-region-parity.test.ts (a file this diff does not touch) — unchanged. --listFiles confirms both edited files really are in that program (engine.ts and subflow-child-refusal.test.ts both listed), so this is a measurement and not a green over source nothing read.

Ablation (on the committed tree, both legs proven on disk)

Mutation: the new arm's guard neutralised in place, with a greppable sentinel so the on-disk change is provable from two directions.

HEAD_BLOB=111b7185441f3eabb94e62c8390155c7efe2b625
PRE marker=1 sentinel=0
POST marker=0 sentinel=1
MUTATED_BLOB=5b463c5d48b7d523513e8b01963cf3fad9fa9b7e
ABLATION_VITEST_EXIT=1
Test Files 1 failed (1)
Tests 4 failed | 1 passed (5)
RESTORED_BLOB=111b7185441f3eabb94e62c8390155c7efe2b625
RESTORE marker=1 sentinel=0
git diff HEAD -- $TARGET (expect empty): [empty]
git status --porcelain -- $TARGET (expect empty): [empty]
ABLATION_DONE

Predicted direction, observed: pins a–d red, pin e (the negative control) green. Restore verified by blob-hash equality against the HEAD blob and an empty git diff HEAD, not by an exit code; the script carried a trap … EXIT INT TERM with absolute paths throughout, and an earlier attempt that produced a zero-match substitution aborted at the guard rather than reporting a green ablation — it is reported here as an admitted no-op run, not quietly retried.

No rebuild is required for this ablation and none was performed. The pin imports the subject relatively (../engine.js, same package), which vitest resolves to src/engine.ts, and the package's only resolve.alias entry is an unrelated one for @objectstack/platform-objects. That is proven positively rather than asserted: mutating src/engine.ts alone, with no build, flipped the suite red, and restoring it alone flipped it green.

Gates

Derived on the final head from the actual change set, never a hand-written list: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 4ae326704 — 36 commands over the 3 changed paths. 33 exited 0. Three are PREREQUISITE NOT MET, recorded as NOT MEASURED and not as passes, each with the gate's own words:

  • check-test-completeness (exit 3) — "this gate grades a saved turbo run test log, and no log was named … the local reading for this gate is NOT MEASURED. ⛔ It is not a red".
  • check:dual-build-cjs-loads (exit 3) — "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured." (Its own self-test passed: "93 cases pass".)
  • check:type-check-debt (exit 3) — "--re-measure cannot run: 33 workspace dependenc(ies) … have no built type entry point on disk … ⛔ This is NOT a pass and NOT a finding". Its sibling check:type-check-coveragedid run and passed: "OK — 68/78 workspace packages type-checked".

Every exit code above was captured before any pipe (cmd > file 2>&1; EXIT=$?), and each verdict is quoted from the gate's own output rather than read off a bare $?.

Beyond the derived family: check:nul-bytes passed ("scanned 7940 text file(s) … no raw ASCII control bytes"), and a direct control-byte scan over the three changed files returned no hits.

Repo-wide lint was run in full, not narrowedpnpm lint (eslint . --no-inline-config over the whole repo) exited 0.

git merge-tree --write-tree --name-only origin/main HEAD returned a clean tree with no file list, so content/docs/permissions/system-context.mdx is not implicated and no regeneration is owed.

One honest caveat on the derivation: re-running it after a fresh fetch warned "STALE TREE — this answer is derived from a tree at least 7 commit(s) behind origin/main, and 2 file(s) it derives from CHANGED across that range … .github/workflows/lint.ymlscripts/role-word-baseline.json". Both were inspected: the lint.yml change is comment-only (no step added or removed) and role-word-baseline.json moved by one line, so the family for these paths is unchanged. CI runs the real farm regardless.

Clause-②: no

Declared from the actual diff, not from the plan: git diff -U0 origin/main...HEAD | grep export returns exactly one line, a comment in the new test file ("parks on a real screen node and exports what it collected"). No export was added, removed or renamed, and no accept set moved — RETRYABLE_RESUME_REFUSAL_CODES and isRetryableResumeRefusal are module-private. The public resume contract already declares all four codes as answers (packages/spec/src/contracts/automation-service.ts); this change makes the parent's resume return one instead of swallowing it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

Generated by Claude Code


Generated by Claude Code

…ntract (#14379)
Red half of the reproduction: a parent resume delegated to a child paused on a
screen with a `required` field answers a code-less envelope, fails the parent
and orphans the still-paused child. The negative control (a child that really
ran and threw) is green on both sides.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…l instead of failing the parent (#14379)
The subflow delegation block read every `!childRes.success` as a child that ran
and died. A retryable refusal — the codes `resumeInternal` itself answers for a
resume that never ran — left the child parked where it was, but consumed the
PARENT's pause, recorded a failure, and answered a code-less envelope the
transport maps to `400 FLOW_FAILED`; the corrected retry then answered
`RUN_NOT_FOUND`.
Branch on the child's own `code` (producer-first, per the triage ruling), return
the child's envelope with the code intact and both pauses untouched, and reserve
`failSuspendedRun` for a child that genuinely ran and failed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 7 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx(via INVALID_SCREEN_INPUT (literal, a string literal in RETRYABLE_RESUME_REFUSAL_CODES))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 0e68ed25cc45c15ce296c299614b2b51a3296e52packageMentionDocs.

Which tree this was computed on

This run read content/docs from 12ede636a4b77f1c4df9993fade21b1996dd7e3a — the merge of head 4ae326704950de2991b47cf21bd71c5601bc536f into base 0e68ed25cc45c15ce296c299614b2b51a3296e52, 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 12ede636a4b77f1c4df9993fade21b1996dd7e3a && git checkout 12ede636a4b77f1c4df9993fade21b1996dd7e3a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0e68ed25cc45c15ce296c299614b2b51a3296e52 4ae326704950de2991b47cf21bd71c5601bc536f && git checkout -B drift-repro 0e68ed25cc45c15ce296c299614b2b51a3296e52 && git merge --no-ff 4ae326704950de2991b47cf21bd71c5601bc536f
node scripts/docs-audit/affected-docs.mjs --json 0e68ed25cc45c15ce296c299614b2b51a3296e52

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0e68ed25cc45c15ce296c299614b2b51a3296e52 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-salesClaude

Copy link
Copy Markdown
Collaborator

Landing provenance — ready + auto-merge at head 4ae326704


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 5563bfbSep 2, 2026
34 of 35 checks passed
@os-sales
os-sales deleted the claude/issue-14379-subflow-child-refusal-propagation branch September 2, 2026 13:21
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(service-automation): answer a delegated subflow child refusal as a refusal, not a terminal failure - #14567

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-14379-subflow-child-refusal-propagation
Sep 2, 2026
Merged

fix(service-automation): answer a delegated subflow child refusal as a refusal, not a terminal failure#14567
os-sales merged 2 commits into
mainfrom
claude/issue-14379-subflow-child-refusal-propagation

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14379

A parent run paused at a subflow node forwards a resume down to the child it is parked on — the screen-flow path, where the caller holds ONE stable run id (the parent's) and posts every wizard step to it. When the child refused that bag, the delegation block read it as a child that ran and died: it called failSuspendedRun on the parent and answered a code-less{ success: false, error }. One mistyped form field therefore destroyed a running workflow — the parent's suspension consumed and a failure recorded, the still-paused child orphaned with nothing left to bubble into, the caller told 400 FLOW_FAILED ("it ran and was rejected") for something that never ran, and their corrected retry on the same run id answered RUN_NOT_FOUND.

The delegation now branches on the child's own code. A retryable refusal is answered as a refusal, with its code intact and both pauses untouched; failSuspendedRun is reserved for a child that genuinely ran and failed.

Head at the time of every measurement below: 4ae326704.

Ruling of record (14379#issuecomment-5504400729, verbatim)

The fix criterion — prefer the producer-first one. The card offers two discriminators; take the second. ⛔ Do not branch on "is the child's suspension still live" — that is a second store read whose answer can race, and it infers intent from state. Branch on the child's own code being one of the refusal codes the engine itself answers (INVALID_SCREEN_INPUT, INVALID_SIGNAL, RESUME_IN_PROGRESS, STORE_UNAVAILABLE), which is the producer naming the condition — the same rule the platform applies everywhere else. failSuspendedRun is then reserved for a child that genuinely ran and failed.

⚠️And propagate the code. The current envelope carries none, which is why the transport lands on 400 FLOW_FAILED. Returning the child's envelope with its code intact is half the fix; a fix that leaves both pauses alive but still answers a code-less envelope has repaired the state and left the caller equally misled.

The pins the card names are the right ones, and one more: refused parent resume ⇒ code: 'INVALID_SCREEN_INPUT', parent and child still suspended, corrected retry on the parent completes. Add a negative control — a child that terminally throws must still fail the parent — or the fix can pass by never failing anything.

⚠️ Serialisation: three cards are live on these same 40 lines

#14392 — the "child run … is gone — continuing without child output" line, which sits in the else of the very if (childRun) this card's arm lives inside. Graded p3 this round. Two separate diffs: a log-text fix disappearing inside a state-machine repair is how the state-machine repair stops getting reviewed on its own merits.

Serialisation honoured: the branch is cut from origin/mainafter PR #14388 merged, and the else arm carrying the "child run … is gone" log line is byte-untouched — #14392 stays a separate diff, still open.

The change

packages/services/service-automation/src/engine.ts, two additions and nothing else:

  1. A module-private closed set, RETRYABLE_RESUME_REFUSAL_CODES, naming the codes resumeInternal itself answers for a resume that never ranINVALID_SCREEN_INPUT, INVALID_SIGNAL, RESUME_IN_PROGRESS, STORE_UNAVAILABLE — plus the one-line predicate that reads it. RUN_NOT_FOUND is deliberately absent and the docblock says why: it is the engine's terminal "this pause is gone for good" class (the automation: the run-resume route still answers HTTP 200 wrapping an inner {success:false} — the route #3962's status-code unification left behind #8684 comment sitting 30 lines above), which a transport answers 404 and no retry can fix.
  2. A new arm placed before the terminal-failure arm, answering the child's envelope verbatim but for the parent's durationMs, consuming neither pause and refreshing nothing (the child did not advance, so the parent's surfaced screen is already current).

No log site was added at any level: a refusal is a response, not a degradation, and the child already logged its own warn where it produced the refusal.

Premise checks on origin/main (all verified before the first edit)

#PremiseVerdict
P1resumeInternal's subflow block still has exactly three arms, no refusal armholds — located at engine.ts:4798 by run.correlation.startsWith('subflow:'); the three arms read exactly as the card quotes them
P2The card's measurement reproduces on the current treeholds — written as a failing test first and run before any source edit: 4 failed, 1 passed, every failure expected undefined to be 'INVALID_SCREEN_INPUT' / 'INVALID_SIGNAL'
P3PR #14388 is on origin/main; refuseInvalidScreenInput / ENGINE_BUILT_SIGNAL untouchedholds — the signal-less normalisation to {} is at the public door; neither symbol appears in the diff
P4The refusal codes are the four the ruling namesholds, with one reachability note below — grepped the producers inside resumeInternal: RESUME_IN_PROGRESS, STORE_UNAVAILABLE, RUN_NOT_FOUND (three sites), INVALID_SCREEN_INPUT (via refuseInvalidScreenInput), INVALID_SIGNAL. No other code is produced there. Nothing was invented and packages/spec was not touched
P5packages/spec, content/docs/releases/** and the #14392 log line untouchedholds — the diff is 3 files: engine.ts, one new test file, one changeset

P4 reachability note (reported, not acted on). Two of the four are reachable through delegation today and are pinned end to end: INVALID_SCREEN_INPUT and INVALID_SIGNAL. The other two are in the set because the producer answers them from this method, but neither has a deterministic fixture: RESUME_IN_PROGRESS needs a real race window against a concurrent direct child resume, and a STORE_UNAVAILABLE outage trips the parent's own loadSuspendedRunStrict several frames earlier, so the parent never reaches the delegation block at all. Keeping them in the set is the producer-first rule applied whole; the pins claim only what was measured.

Hypotheses (each falsifiable, each with its evidence)

  • H1 — one new arm before the terminal one, no failSuspendedRun, no pause consumed: HOLDS. That is the whole source change.
  • H2 — is the parent's pause consumed before the delegation block? NO, so nothing needed restoring.forgetSuspendedRun(run, 'resumed') — the one consumption on this path — sits ~90 lines after the delegation block, and the only other consumer is the failSuspendedRun the new arm bypasses. creditChildRun returns immediately when childSummary is absent, which it is on a direct parent resume. Proven behaviourally, not just by reading: hasSuspendedRun(parentRunId) is true after the refusal in every refusal pin.
  • H3 — the corrected retry completes end to end: HOLDS. The child's screen accepts the corrected bag, the child completes, the engine-built signal maps its output into the parent, and the downstream node observes { kind: 'normal' }. Both suspensions are gone afterwards.
  • H4 — the negative control still fails the parent through failSuspendedRun, envelope shape unchanged: HOLDS. A child whose node throws after the screen accepted the bag still answers success: false, codeundefined, error matching subflow run '…' (child_flow) failed: …, with both suspensions consumed. This test is green on both sides of the change — it is the control that stops the arm from passing by never failing anything.

Tests

New file packages/services/service-automation/src/builtin/subflow-child-refusal.test.ts — 5 pins, built on installBuiltinNodes with real subflow and screen nodes, exactly the composition the card measured.

pinasserts
arefused parent resume ⇒ success: false, code: 'INVALID_SCREEN_INPUT', the child's own actionable text, parent and child still suspended, the parent's surfaced screen unchanged, downstream never ran
bcorrected retry on the same parent run id ⇒ completes, child output mapped into the parent, both suspensions gone
cthe signal-less gesture resume(parentRunId) ⇒ the same refusal, both pauses intact, corrected retry still lands (the population the scope note 14379#issuecomment-5504169090 adds)
da second refusal code on the same path — INVALID_SIGNAL from a reserved variable name against a child pause that declares no screen contract ⇒ same shape, then the legitimate submission lands
enegative control — a child that genuinely ran and threw ⇒ parent failed terminally, code undefined, error text unchanged

Every refusal pin asserts the ADR-0112 code (and the error text), never a bare "it failed".

Verdict lines, quoted from the runs, all at 4ae326704:

BEFORE the source edit (the red half, same test file):
Test Files 1 failed (1)
Tests 4 failed | 1 passed (5)
AssertionError: expected undefined to be 'INVALID_SCREEN_INPUT'
Targeted suites (new pins + subflow-node + both screen-resume suites):
Test Files 4 passed (4)
Tests 41 passed (41)
os-verify-lock: VERDICT command-exit 0
Whole package:
Test Files 99 passed (99)
Tests 1171 passed (1171)
os-verify-lock: VERDICT command-exit 0
Downstream consumer `@objectstack/plugin-approvals` (closure built first):
Test Files 35 passed (35)
Tests 652 passed (652)
os-verify-lock: VERDICT command-exit 0

Type check: this package declares no typecheck script and carries a frozen DEBT ledger entry of 3. tsc --noEmit -p tsconfig.json reports exactly those 3 pre-existing TS2341 in src/nested-region-parity.test.ts (a file this diff does not touch) — unchanged. --listFiles confirms both edited files really are in that program (engine.ts and subflow-child-refusal.test.ts both listed), so this is a measurement and not a green over source nothing read.

Ablation (on the committed tree, both legs proven on disk)

Mutation: the new arm's guard neutralised in place, with a greppable sentinel so the on-disk change is provable from two directions.

HEAD_BLOB=111b7185441f3eabb94e62c8390155c7efe2b625
PRE marker=1 sentinel=0
POST marker=0 sentinel=1
MUTATED_BLOB=5b463c5d48b7d523513e8b01963cf3fad9fa9b7e
ABLATION_VITEST_EXIT=1
Test Files 1 failed (1)
Tests 4 failed | 1 passed (5)
RESTORED_BLOB=111b7185441f3eabb94e62c8390155c7efe2b625
RESTORE marker=1 sentinel=0
git diff HEAD -- $TARGET (expect empty): [empty]
git status --porcelain -- $TARGET (expect empty): [empty]
ABLATION_DONE

Predicted direction, observed: pins a–d red, pin e (the negative control) green. Restore verified by blob-hash equality against the HEAD blob and an empty git diff HEAD, not by an exit code; the script carried a trap … EXIT INT TERM with absolute paths throughout, and an earlier attempt that produced a zero-match substitution aborted at the guard rather than reporting a green ablation — it is reported here as an admitted no-op run, not quietly retried.

No rebuild is required for this ablation and none was performed. The pin imports the subject relatively (../engine.js, same package), which vitest resolves to src/engine.ts, and the package's only resolve.alias entry is an unrelated one for @objectstack/platform-objects. That is proven positively rather than asserted: mutating src/engine.ts alone, with no build, flipped the suite red, and restoring it alone flipped it green.

Gates

Derived on the final head from the actual change set, never a hand-written list: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 4ae326704 — 36 commands over the 3 changed paths. 33 exited 0. Three are PREREQUISITE NOT MET, recorded as NOT MEASURED and not as passes, each with the gate's own words:

  • check-test-completeness (exit 3) — "this gate grades a saved turbo run test log, and no log was named … the local reading for this gate is NOT MEASURED. ⛔ It is not a red".
  • check:dual-build-cjs-loads (exit 3) — "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured." (Its own self-test passed: "93 cases pass".)
  • check:type-check-debt (exit 3) — "--re-measure cannot run: 33 workspace dependenc(ies) … have no built type entry point on disk … ⛔ This is NOT a pass and NOT a finding". Its sibling check:type-check-coveragedid run and passed: "OK — 68/78 workspace packages type-checked".

Every exit code above was captured before any pipe (cmd > file 2>&1; EXIT=$?), and each verdict is quoted from the gate's own output rather than read off a bare $?.

Beyond the derived family: check:nul-bytes passed ("scanned 7940 text file(s) … no raw ASCII control bytes"), and a direct control-byte scan over the three changed files returned no hits.

Repo-wide lint was run in full, not narrowedpnpm lint (eslint . --no-inline-config over the whole repo) exited 0.

git merge-tree --write-tree --name-only origin/main HEAD returned a clean tree with no file list, so content/docs/permissions/system-context.mdx is not implicated and no regeneration is owed.

One honest caveat on the derivation: re-running it after a fresh fetch warned "STALE TREE — this answer is derived from a tree at least 7 commit(s) behind origin/main, and 2 file(s) it derives from CHANGED across that range … .github/workflows/lint.ymlscripts/role-word-baseline.json". Both were inspected: the lint.yml change is comment-only (no step added or removed) and role-word-baseline.json moved by one line, so the family for these paths is unchanged. CI runs the real farm regardless.

Clause-②: no

Declared from the actual diff, not from the plan: git diff -U0 origin/main...HEAD | grep export returns exactly one line, a comment in the new test file ("parks on a real screen node and exports what it collected"). No export was added, removed or renamed, and no accept set moved — RETRYABLE_RESUME_REFUSAL_CODES and isRetryableResumeRefusal are module-private. The public resume contract already declares all four codes as answers (packages/spec/src/contracts/automation-service.ts); this change makes the parent's resume return one instead of swallowing it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

Generated by Claude Code


Generated by Claude Code

…ntract (#14379)
Red half of the reproduction: a parent resume delegated to a child paused on a
screen with a `required` field answers a code-less envelope, fails the parent
and orphans the still-paused child. The negative control (a child that really
ran and threw) is green on both sides.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…l instead of failing the parent (#14379)
The subflow delegation block read every `!childRes.success` as a child that ran
and died. A retryable refusal — the codes `resumeInternal` itself answers for a
resume that never ran — left the child parked where it was, but consumed the
PARENT's pause, recorded a failure, and answered a code-less envelope the
transport maps to `400 FLOW_FAILED`; the corrected retry then answered
`RUN_NOT_FOUND`.
Branch on the child's own `code` (producer-first, per the triage ruling), return
the child's envelope with the code intact and both pauses untouched, and reserve
`failSuspendedRun` for a child that genuinely ran and failed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 7 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx(via INVALID_SCREEN_INPUT (literal, a string literal in RETRYABLE_RESUME_REFUSAL_CODES))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 0e68ed25cc45c15ce296c299614b2b51a3296e52packageMentionDocs.

Which tree this was computed on

This run read content/docs from 12ede636a4b77f1c4df9993fade21b1996dd7e3a — the merge of head 4ae326704950de2991b47cf21bd71c5601bc536f into base 0e68ed25cc45c15ce296c299614b2b51a3296e52, 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 12ede636a4b77f1c4df9993fade21b1996dd7e3a && git checkout 12ede636a4b77f1c4df9993fade21b1996dd7e3a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0e68ed25cc45c15ce296c299614b2b51a3296e52 4ae326704950de2991b47cf21bd71c5601bc536f && git checkout -B drift-repro 0e68ed25cc45c15ce296c299614b2b51a3296e52 && git merge --no-ff 4ae326704950de2991b47cf21bd71c5601bc536f
node scripts/docs-audit/affected-docs.mjs --json 0e68ed25cc45c15ce296c299614b2b51a3296e52

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0e68ed25cc45c15ce296c299614b2b51a3296e52 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-salesClaude

Copy link
Copy Markdown
Collaborator

Landing provenance — ready + auto-merge at head 4ae326704


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 5563bfbSep 2, 2026
34 of 35 checks passed
@os-sales
os-sales deleted the claude/issue-14379-subflow-child-refusal-propagation branch September 2, 2026 13:21
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(service-automation): answer a delegated subflow child refusal as a refusal, not a terminal failure - #14567

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-14379-subflow-child-refusal-propagation
Sep 2, 2026
Merged

fix(service-automation): answer a delegated subflow child refusal as a refusal, not a terminal failure#14567
os-sales merged 2 commits into
mainfrom
claude/issue-14379-subflow-child-refusal-propagation

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14379

A parent run paused at a subflow node forwards a resume down to the child it is parked on — the screen-flow path, where the caller holds ONE stable run id (the parent's) and posts every wizard step to it. When the child refused that bag, the delegation block read it as a child that ran and died: it called failSuspendedRun on the parent and answered a code-less{ success: false, error }. One mistyped form field therefore destroyed a running workflow — the parent's suspension consumed and a failure recorded, the still-paused child orphaned with nothing left to bubble into, the caller told 400 FLOW_FAILED ("it ran and was rejected") for something that never ran, and their corrected retry on the same run id answered RUN_NOT_FOUND.

The delegation now branches on the child's own code. A retryable refusal is answered as a refusal, with its code intact and both pauses untouched; failSuspendedRun is reserved for a child that genuinely ran and failed.

Head at the time of every measurement below: 4ae326704.

Ruling of record (14379#issuecomment-5504400729, verbatim)

The fix criterion — prefer the producer-first one. The card offers two discriminators; take the second. ⛔ Do not branch on "is the child's suspension still live" — that is a second store read whose answer can race, and it infers intent from state. Branch on the child's own code being one of the refusal codes the engine itself answers (INVALID_SCREEN_INPUT, INVALID_SIGNAL, RESUME_IN_PROGRESS, STORE_UNAVAILABLE), which is the producer naming the condition — the same rule the platform applies everywhere else. failSuspendedRun is then reserved for a child that genuinely ran and failed.

⚠️And propagate the code. The current envelope carries none, which is why the transport lands on 400 FLOW_FAILED. Returning the child's envelope with its code intact is half the fix; a fix that leaves both pauses alive but still answers a code-less envelope has repaired the state and left the caller equally misled.

The pins the card names are the right ones, and one more: refused parent resume ⇒ code: 'INVALID_SCREEN_INPUT', parent and child still suspended, corrected retry on the parent completes. Add a negative control — a child that terminally throws must still fail the parent — or the fix can pass by never failing anything.

⚠️ Serialisation: three cards are live on these same 40 lines

#14392 — the "child run … is gone — continuing without child output" line, which sits in the else of the very if (childRun) this card's arm lives inside. Graded p3 this round. Two separate diffs: a log-text fix disappearing inside a state-machine repair is how the state-machine repair stops getting reviewed on its own merits.

Serialisation honoured: the branch is cut from origin/mainafter PR #14388 merged, and the else arm carrying the "child run … is gone" log line is byte-untouched — #14392 stays a separate diff, still open.

The change

packages/services/service-automation/src/engine.ts, two additions and nothing else:

  1. A module-private closed set, RETRYABLE_RESUME_REFUSAL_CODES, naming the codes resumeInternal itself answers for a resume that never ranINVALID_SCREEN_INPUT, INVALID_SIGNAL, RESUME_IN_PROGRESS, STORE_UNAVAILABLE — plus the one-line predicate that reads it. RUN_NOT_FOUND is deliberately absent and the docblock says why: it is the engine's terminal "this pause is gone for good" class (the automation: the run-resume route still answers HTTP 200 wrapping an inner {success:false} — the route #3962's status-code unification left behind #8684 comment sitting 30 lines above), which a transport answers 404 and no retry can fix.
  2. A new arm placed before the terminal-failure arm, answering the child's envelope verbatim but for the parent's durationMs, consuming neither pause and refreshing nothing (the child did not advance, so the parent's surfaced screen is already current).

No log site was added at any level: a refusal is a response, not a degradation, and the child already logged its own warn where it produced the refusal.

Premise checks on origin/main (all verified before the first edit)

#PremiseVerdict
P1resumeInternal's subflow block still has exactly three arms, no refusal armholds — located at engine.ts:4798 by run.correlation.startsWith('subflow:'); the three arms read exactly as the card quotes them
P2The card's measurement reproduces on the current treeholds — written as a failing test first and run before any source edit: 4 failed, 1 passed, every failure expected undefined to be 'INVALID_SCREEN_INPUT' / 'INVALID_SIGNAL'
P3PR #14388 is on origin/main; refuseInvalidScreenInput / ENGINE_BUILT_SIGNAL untouchedholds — the signal-less normalisation to {} is at the public door; neither symbol appears in the diff
P4The refusal codes are the four the ruling namesholds, with one reachability note below — grepped the producers inside resumeInternal: RESUME_IN_PROGRESS, STORE_UNAVAILABLE, RUN_NOT_FOUND (three sites), INVALID_SCREEN_INPUT (via refuseInvalidScreenInput), INVALID_SIGNAL. No other code is produced there. Nothing was invented and packages/spec was not touched
P5packages/spec, content/docs/releases/** and the #14392 log line untouchedholds — the diff is 3 files: engine.ts, one new test file, one changeset

P4 reachability note (reported, not acted on). Two of the four are reachable through delegation today and are pinned end to end: INVALID_SCREEN_INPUT and INVALID_SIGNAL. The other two are in the set because the producer answers them from this method, but neither has a deterministic fixture: RESUME_IN_PROGRESS needs a real race window against a concurrent direct child resume, and a STORE_UNAVAILABLE outage trips the parent's own loadSuspendedRunStrict several frames earlier, so the parent never reaches the delegation block at all. Keeping them in the set is the producer-first rule applied whole; the pins claim only what was measured.

Hypotheses (each falsifiable, each with its evidence)

  • H1 — one new arm before the terminal one, no failSuspendedRun, no pause consumed: HOLDS. That is the whole source change.
  • H2 — is the parent's pause consumed before the delegation block? NO, so nothing needed restoring.forgetSuspendedRun(run, 'resumed') — the one consumption on this path — sits ~90 lines after the delegation block, and the only other consumer is the failSuspendedRun the new arm bypasses. creditChildRun returns immediately when childSummary is absent, which it is on a direct parent resume. Proven behaviourally, not just by reading: hasSuspendedRun(parentRunId) is true after the refusal in every refusal pin.
  • H3 — the corrected retry completes end to end: HOLDS. The child's screen accepts the corrected bag, the child completes, the engine-built signal maps its output into the parent, and the downstream node observes { kind: 'normal' }. Both suspensions are gone afterwards.
  • H4 — the negative control still fails the parent through failSuspendedRun, envelope shape unchanged: HOLDS. A child whose node throws after the screen accepted the bag still answers success: false, codeundefined, error matching subflow run '…' (child_flow) failed: …, with both suspensions consumed. This test is green on both sides of the change — it is the control that stops the arm from passing by never failing anything.

Tests

New file packages/services/service-automation/src/builtin/subflow-child-refusal.test.ts — 5 pins, built on installBuiltinNodes with real subflow and screen nodes, exactly the composition the card measured.

pinasserts
arefused parent resume ⇒ success: false, code: 'INVALID_SCREEN_INPUT', the child's own actionable text, parent and child still suspended, the parent's surfaced screen unchanged, downstream never ran
bcorrected retry on the same parent run id ⇒ completes, child output mapped into the parent, both suspensions gone
cthe signal-less gesture resume(parentRunId) ⇒ the same refusal, both pauses intact, corrected retry still lands (the population the scope note 14379#issuecomment-5504169090 adds)
da second refusal code on the same path — INVALID_SIGNAL from a reserved variable name against a child pause that declares no screen contract ⇒ same shape, then the legitimate submission lands
enegative control — a child that genuinely ran and threw ⇒ parent failed terminally, code undefined, error text unchanged

Every refusal pin asserts the ADR-0112 code (and the error text), never a bare "it failed".

Verdict lines, quoted from the runs, all at 4ae326704:

BEFORE the source edit (the red half, same test file):
Test Files 1 failed (1)
Tests 4 failed | 1 passed (5)
AssertionError: expected undefined to be 'INVALID_SCREEN_INPUT'
Targeted suites (new pins + subflow-node + both screen-resume suites):
Test Files 4 passed (4)
Tests 41 passed (41)
os-verify-lock: VERDICT command-exit 0
Whole package:
Test Files 99 passed (99)
Tests 1171 passed (1171)
os-verify-lock: VERDICT command-exit 0
Downstream consumer `@objectstack/plugin-approvals` (closure built first):
Test Files 35 passed (35)
Tests 652 passed (652)
os-verify-lock: VERDICT command-exit 0

Type check: this package declares no typecheck script and carries a frozen DEBT ledger entry of 3. tsc --noEmit -p tsconfig.json reports exactly those 3 pre-existing TS2341 in src/nested-region-parity.test.ts (a file this diff does not touch) — unchanged. --listFiles confirms both edited files really are in that program (engine.ts and subflow-child-refusal.test.ts both listed), so this is a measurement and not a green over source nothing read.

Ablation (on the committed tree, both legs proven on disk)

Mutation: the new arm's guard neutralised in place, with a greppable sentinel so the on-disk change is provable from two directions.

HEAD_BLOB=111b7185441f3eabb94e62c8390155c7efe2b625
PRE marker=1 sentinel=0
POST marker=0 sentinel=1
MUTATED_BLOB=5b463c5d48b7d523513e8b01963cf3fad9fa9b7e
ABLATION_VITEST_EXIT=1
Test Files 1 failed (1)
Tests 4 failed | 1 passed (5)
RESTORED_BLOB=111b7185441f3eabb94e62c8390155c7efe2b625
RESTORE marker=1 sentinel=0
git diff HEAD -- $TARGET (expect empty): [empty]
git status --porcelain -- $TARGET (expect empty): [empty]
ABLATION_DONE

Predicted direction, observed: pins a–d red, pin e (the negative control) green. Restore verified by blob-hash equality against the HEAD blob and an empty git diff HEAD, not by an exit code; the script carried a trap … EXIT INT TERM with absolute paths throughout, and an earlier attempt that produced a zero-match substitution aborted at the guard rather than reporting a green ablation — it is reported here as an admitted no-op run, not quietly retried.

No rebuild is required for this ablation and none was performed. The pin imports the subject relatively (../engine.js, same package), which vitest resolves to src/engine.ts, and the package's only resolve.alias entry is an unrelated one for @objectstack/platform-objects. That is proven positively rather than asserted: mutating src/engine.ts alone, with no build, flipped the suite red, and restoring it alone flipped it green.

Gates

Derived on the final head from the actual change set, never a hand-written list: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 4ae326704 — 36 commands over the 3 changed paths. 33 exited 0. Three are PREREQUISITE NOT MET, recorded as NOT MEASURED and not as passes, each with the gate's own words:

  • check-test-completeness (exit 3) — "this gate grades a saved turbo run test log, and no log was named … the local reading for this gate is NOT MEASURED. ⛔ It is not a red".
  • check:dual-build-cjs-loads (exit 3) — "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured." (Its own self-test passed: "93 cases pass".)
  • check:type-check-debt (exit 3) — "--re-measure cannot run: 33 workspace dependenc(ies) … have no built type entry point on disk … ⛔ This is NOT a pass and NOT a finding". Its sibling check:type-check-coveragedid run and passed: "OK — 68/78 workspace packages type-checked".

Every exit code above was captured before any pipe (cmd > file 2>&1; EXIT=$?), and each verdict is quoted from the gate's own output rather than read off a bare $?.

Beyond the derived family: check:nul-bytes passed ("scanned 7940 text file(s) … no raw ASCII control bytes"), and a direct control-byte scan over the three changed files returned no hits.

Repo-wide lint was run in full, not narrowedpnpm lint (eslint . --no-inline-config over the whole repo) exited 0.

git merge-tree --write-tree --name-only origin/main HEAD returned a clean tree with no file list, so content/docs/permissions/system-context.mdx is not implicated and no regeneration is owed.

One honest caveat on the derivation: re-running it after a fresh fetch warned "STALE TREE — this answer is derived from a tree at least 7 commit(s) behind origin/main, and 2 file(s) it derives from CHANGED across that range … .github/workflows/lint.ymlscripts/role-word-baseline.json". Both were inspected: the lint.yml change is comment-only (no step added or removed) and role-word-baseline.json moved by one line, so the family for these paths is unchanged. CI runs the real farm regardless.

Clause-②: no

Declared from the actual diff, not from the plan: git diff -U0 origin/main...HEAD | grep export returns exactly one line, a comment in the new test file ("parks on a real screen node and exports what it collected"). No export was added, removed or renamed, and no accept set moved — RETRYABLE_RESUME_REFUSAL_CODES and isRetryableResumeRefusal are module-private. The public resume contract already declares all four codes as answers (packages/spec/src/contracts/automation-service.ts); this change makes the parent's resume return one instead of swallowing it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

Generated by Claude Code


Generated by Claude Code

…ntract (#14379)
Red half of the reproduction: a parent resume delegated to a child paused on a
screen with a `required` field answers a code-less envelope, fails the parent
and orphans the still-paused child. The negative control (a child that really
ran and threw) is green on both sides.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…l instead of failing the parent (#14379)
The subflow delegation block read every `!childRes.success` as a child that ran
and died. A retryable refusal — the codes `resumeInternal` itself answers for a
resume that never ran — left the child parked where it was, but consumed the
PARENT's pause, recorded a failure, and answered a code-less envelope the
transport maps to `400 FLOW_FAILED`; the corrected retry then answered
`RUN_NOT_FOUND`.
Branch on the child's own `code` (producer-first, per the triage ruling), return
the child's envelope with the code intact and both pauses untouched, and reserve
`failSuspendedRun` for a child that genuinely ran and failed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 7 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx(via INVALID_SCREEN_INPUT (literal, a string literal in RETRYABLE_RESUME_REFUSAL_CODES))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 0e68ed25cc45c15ce296c299614b2b51a3296e52packageMentionDocs.

Which tree this was computed on

This run read content/docs from 12ede636a4b77f1c4df9993fade21b1996dd7e3a — the merge of head 4ae326704950de2991b47cf21bd71c5601bc536f into base 0e68ed25cc45c15ce296c299614b2b51a3296e52, 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 12ede636a4b77f1c4df9993fade21b1996dd7e3a && git checkout 12ede636a4b77f1c4df9993fade21b1996dd7e3a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0e68ed25cc45c15ce296c299614b2b51a3296e52 4ae326704950de2991b47cf21bd71c5601bc536f && git checkout -B drift-repro 0e68ed25cc45c15ce296c299614b2b51a3296e52 && git merge --no-ff 4ae326704950de2991b47cf21bd71c5601bc536f
node scripts/docs-audit/affected-docs.mjs --json 0e68ed25cc45c15ce296c299614b2b51a3296e52

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0e68ed25cc45c15ce296c299614b2b51a3296e52 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-salesClaude

Copy link
Copy Markdown
Collaborator

Landing provenance — ready + auto-merge at head 4ae326704


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 5563bfbSep 2, 2026
34 of 35 checks passed
@os-sales
os-sales deleted the claude/issue-14379-subflow-child-refusal-propagation branch September 2, 2026 13:21
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-sales@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(service-automation): answer a delegated subflow child refusal as a refusal, not a terminal failure - #14567

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-14379-subflow-child-refusal-propagation
Sep 2, 2026
Merged

fix(service-automation): answer a delegated subflow child refusal as a refusal, not a terminal failure#14567
os-sales merged 2 commits into
mainfrom
claude/issue-14379-subflow-child-refusal-propagation

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14379

A parent run paused at a subflow node forwards a resume down to the child it is parked on — the screen-flow path, where the caller holds ONE stable run id (the parent's) and posts every wizard step to it. When the child refused that bag, the delegation block read it as a child that ran and died: it called failSuspendedRun on the parent and answered a code-less{ success: false, error }. One mistyped form field therefore destroyed a running workflow — the parent's suspension consumed and a failure recorded, the still-paused child orphaned with nothing left to bubble into, the caller told 400 FLOW_FAILED ("it ran and was rejected") for something that never ran, and their corrected retry on the same run id answered RUN_NOT_FOUND.

The delegation now branches on the child's own code. A retryable refusal is answered as a refusal, with its code intact and both pauses untouched; failSuspendedRun is reserved for a child that genuinely ran and failed.

Head at the time of every measurement below: 4ae326704.

Ruling of record (14379#issuecomment-5504400729, verbatim)

The fix criterion — prefer the producer-first one. The card offers two discriminators; take the second. ⛔ Do not branch on "is the child's suspension still live" — that is a second store read whose answer can race, and it infers intent from state. Branch on the child's own code being one of the refusal codes the engine itself answers (INVALID_SCREEN_INPUT, INVALID_SIGNAL, RESUME_IN_PROGRESS, STORE_UNAVAILABLE), which is the producer naming the condition — the same rule the platform applies everywhere else. failSuspendedRun is then reserved for a child that genuinely ran and failed.

⚠️And propagate the code. The current envelope carries none, which is why the transport lands on 400 FLOW_FAILED. Returning the child's envelope with its code intact is half the fix; a fix that leaves both pauses alive but still answers a code-less envelope has repaired the state and left the caller equally misled.

The pins the card names are the right ones, and one more: refused parent resume ⇒ code: 'INVALID_SCREEN_INPUT', parent and child still suspended, corrected retry on the parent completes. Add a negative control — a child that terminally throws must still fail the parent — or the fix can pass by never failing anything.

⚠️ Serialisation: three cards are live on these same 40 lines

#14392 — the "child run … is gone — continuing without child output" line, which sits in the else of the very if (childRun) this card's arm lives inside. Graded p3 this round. Two separate diffs: a log-text fix disappearing inside a state-machine repair is how the state-machine repair stops getting reviewed on its own merits.

Serialisation honoured: the branch is cut from origin/mainafter PR #14388 merged, and the else arm carrying the "child run … is gone" log line is byte-untouched — #14392 stays a separate diff, still open.

The change

packages/services/service-automation/src/engine.ts, two additions and nothing else:

  1. A module-private closed set, RETRYABLE_RESUME_REFUSAL_CODES, naming the codes resumeInternal itself answers for a resume that never ranINVALID_SCREEN_INPUT, INVALID_SIGNAL, RESUME_IN_PROGRESS, STORE_UNAVAILABLE — plus the one-line predicate that reads it. RUN_NOT_FOUND is deliberately absent and the docblock says why: it is the engine's terminal "this pause is gone for good" class (the automation: the run-resume route still answers HTTP 200 wrapping an inner {success:false} — the route #3962's status-code unification left behind #8684 comment sitting 30 lines above), which a transport answers 404 and no retry can fix.
  2. A new arm placed before the terminal-failure arm, answering the child's envelope verbatim but for the parent's durationMs, consuming neither pause and refreshing nothing (the child did not advance, so the parent's surfaced screen is already current).

No log site was added at any level: a refusal is a response, not a degradation, and the child already logged its own warn where it produced the refusal.

Premise checks on origin/main (all verified before the first edit)

#PremiseVerdict
P1resumeInternal's subflow block still has exactly three arms, no refusal armholds — located at engine.ts:4798 by run.correlation.startsWith('subflow:'); the three arms read exactly as the card quotes them
P2The card's measurement reproduces on the current treeholds — written as a failing test first and run before any source edit: 4 failed, 1 passed, every failure expected undefined to be 'INVALID_SCREEN_INPUT' / 'INVALID_SIGNAL'
P3PR #14388 is on origin/main; refuseInvalidScreenInput / ENGINE_BUILT_SIGNAL untouchedholds — the signal-less normalisation to {} is at the public door; neither symbol appears in the diff
P4The refusal codes are the four the ruling namesholds, with one reachability note below — grepped the producers inside resumeInternal: RESUME_IN_PROGRESS, STORE_UNAVAILABLE, RUN_NOT_FOUND (three sites), INVALID_SCREEN_INPUT (via refuseInvalidScreenInput), INVALID_SIGNAL. No other code is produced there. Nothing was invented and packages/spec was not touched
P5packages/spec, content/docs/releases/** and the #14392 log line untouchedholds — the diff is 3 files: engine.ts, one new test file, one changeset

P4 reachability note (reported, not acted on). Two of the four are reachable through delegation today and are pinned end to end: INVALID_SCREEN_INPUT and INVALID_SIGNAL. The other two are in the set because the producer answers them from this method, but neither has a deterministic fixture: RESUME_IN_PROGRESS needs a real race window against a concurrent direct child resume, and a STORE_UNAVAILABLE outage trips the parent's own loadSuspendedRunStrict several frames earlier, so the parent never reaches the delegation block at all. Keeping them in the set is the producer-first rule applied whole; the pins claim only what was measured.

Hypotheses (each falsifiable, each with its evidence)

  • H1 — one new arm before the terminal one, no failSuspendedRun, no pause consumed: HOLDS. That is the whole source change.
  • H2 — is the parent's pause consumed before the delegation block? NO, so nothing needed restoring.forgetSuspendedRun(run, 'resumed') — the one consumption on this path — sits ~90 lines after the delegation block, and the only other consumer is the failSuspendedRun the new arm bypasses. creditChildRun returns immediately when childSummary is absent, which it is on a direct parent resume. Proven behaviourally, not just by reading: hasSuspendedRun(parentRunId) is true after the refusal in every refusal pin.
  • H3 — the corrected retry completes end to end: HOLDS. The child's screen accepts the corrected bag, the child completes, the engine-built signal maps its output into the parent, and the downstream node observes { kind: 'normal' }. Both suspensions are gone afterwards.
  • H4 — the negative control still fails the parent through failSuspendedRun, envelope shape unchanged: HOLDS. A child whose node throws after the screen accepted the bag still answers success: false, codeundefined, error matching subflow run '…' (child_flow) failed: …, with both suspensions consumed. This test is green on both sides of the change — it is the control that stops the arm from passing by never failing anything.

Tests

New file packages/services/service-automation/src/builtin/subflow-child-refusal.test.ts — 5 pins, built on installBuiltinNodes with real subflow and screen nodes, exactly the composition the card measured.

pinasserts
arefused parent resume ⇒ success: false, code: 'INVALID_SCREEN_INPUT', the child's own actionable text, parent and child still suspended, the parent's surfaced screen unchanged, downstream never ran
bcorrected retry on the same parent run id ⇒ completes, child output mapped into the parent, both suspensions gone
cthe signal-less gesture resume(parentRunId) ⇒ the same refusal, both pauses intact, corrected retry still lands (the population the scope note 14379#issuecomment-5504169090 adds)
da second refusal code on the same path — INVALID_SIGNAL from a reserved variable name against a child pause that declares no screen contract ⇒ same shape, then the legitimate submission lands
enegative control — a child that genuinely ran and threw ⇒ parent failed terminally, code undefined, error text unchanged

Every refusal pin asserts the ADR-0112 code (and the error text), never a bare "it failed".

Verdict lines, quoted from the runs, all at 4ae326704:

BEFORE the source edit (the red half, same test file):
Test Files 1 failed (1)
Tests 4 failed | 1 passed (5)
AssertionError: expected undefined to be 'INVALID_SCREEN_INPUT'
Targeted suites (new pins + subflow-node + both screen-resume suites):
Test Files 4 passed (4)
Tests 41 passed (41)
os-verify-lock: VERDICT command-exit 0
Whole package:
Test Files 99 passed (99)
Tests 1171 passed (1171)
os-verify-lock: VERDICT command-exit 0
Downstream consumer `@objectstack/plugin-approvals` (closure built first):
Test Files 35 passed (35)
Tests 652 passed (652)
os-verify-lock: VERDICT command-exit 0

Type check: this package declares no typecheck script and carries a frozen DEBT ledger entry of 3. tsc --noEmit -p tsconfig.json reports exactly those 3 pre-existing TS2341 in src/nested-region-parity.test.ts (a file this diff does not touch) — unchanged. --listFiles confirms both edited files really are in that program (engine.ts and subflow-child-refusal.test.ts both listed), so this is a measurement and not a green over source nothing read.

Ablation (on the committed tree, both legs proven on disk)

Mutation: the new arm's guard neutralised in place, with a greppable sentinel so the on-disk change is provable from two directions.

HEAD_BLOB=111b7185441f3eabb94e62c8390155c7efe2b625
PRE marker=1 sentinel=0
POST marker=0 sentinel=1
MUTATED_BLOB=5b463c5d48b7d523513e8b01963cf3fad9fa9b7e
ABLATION_VITEST_EXIT=1
Test Files 1 failed (1)
Tests 4 failed | 1 passed (5)
RESTORED_BLOB=111b7185441f3eabb94e62c8390155c7efe2b625
RESTORE marker=1 sentinel=0
git diff HEAD -- $TARGET (expect empty): [empty]
git status --porcelain -- $TARGET (expect empty): [empty]
ABLATION_DONE

Predicted direction, observed: pins a–d red, pin e (the negative control) green. Restore verified by blob-hash equality against the HEAD blob and an empty git diff HEAD, not by an exit code; the script carried a trap … EXIT INT TERM with absolute paths throughout, and an earlier attempt that produced a zero-match substitution aborted at the guard rather than reporting a green ablation — it is reported here as an admitted no-op run, not quietly retried.

No rebuild is required for this ablation and none was performed. The pin imports the subject relatively (../engine.js, same package), which vitest resolves to src/engine.ts, and the package's only resolve.alias entry is an unrelated one for @objectstack/platform-objects. That is proven positively rather than asserted: mutating src/engine.ts alone, with no build, flipped the suite red, and restoring it alone flipped it green.

Gates

Derived on the final head from the actual change set, never a hand-written list: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 4ae326704 — 36 commands over the 3 changed paths. 33 exited 0. Three are PREREQUISITE NOT MET, recorded as NOT MEASURED and not as passes, each with the gate's own words:

  • check-test-completeness (exit 3) — "this gate grades a saved turbo run test log, and no log was named … the local reading for this gate is NOT MEASURED. ⛔ It is not a red".
  • check:dual-build-cjs-loads (exit 3) — "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured." (Its own self-test passed: "93 cases pass".)
  • check:type-check-debt (exit 3) — "--re-measure cannot run: 33 workspace dependenc(ies) … have no built type entry point on disk … ⛔ This is NOT a pass and NOT a finding". Its sibling check:type-check-coveragedid run and passed: "OK — 68/78 workspace packages type-checked".

Every exit code above was captured before any pipe (cmd > file 2>&1; EXIT=$?), and each verdict is quoted from the gate's own output rather than read off a bare $?.

Beyond the derived family: check:nul-bytes passed ("scanned 7940 text file(s) … no raw ASCII control bytes"), and a direct control-byte scan over the three changed files returned no hits.

Repo-wide lint was run in full, not narrowedpnpm lint (eslint . --no-inline-config over the whole repo) exited 0.

git merge-tree --write-tree --name-only origin/main HEAD returned a clean tree with no file list, so content/docs/permissions/system-context.mdx is not implicated and no regeneration is owed.

One honest caveat on the derivation: re-running it after a fresh fetch warned "STALE TREE — this answer is derived from a tree at least 7 commit(s) behind origin/main, and 2 file(s) it derives from CHANGED across that range … .github/workflows/lint.ymlscripts/role-word-baseline.json". Both were inspected: the lint.yml change is comment-only (no step added or removed) and role-word-baseline.json moved by one line, so the family for these paths is unchanged. CI runs the real farm regardless.

Clause-②: no

Declared from the actual diff, not from the plan: git diff -U0 origin/main...HEAD | grep export returns exactly one line, a comment in the new test file ("parks on a real screen node and exports what it collected"). No export was added, removed or renamed, and no accept set moved — RETRYABLE_RESUME_REFUSAL_CODES and isRetryableResumeRefusal are module-private. The public resume contract already declares all four codes as answers (packages/spec/src/contracts/automation-service.ts); this change makes the parent's resume return one instead of swallowing it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8

Generated by Claude Code


Generated by Claude Code

…ntract (#14379)
Red half of the reproduction: a parent resume delegated to a child paused on a
screen with a `required` field answers a code-less envelope, fails the parent
and orphans the still-paused child. The negative control (a child that really
ran and threw) is green on both sides.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…l instead of failing the parent (#14379)
The subflow delegation block read every `!childRes.success` as a child that ran
and died. A retryable refusal — the codes `resumeInternal` itself answers for a
resume that never ran — left the child parked where it was, but consumed the
PARENT's pause, recorded a failure, and answered a code-less envelope the
transport maps to `400 FLOW_FAILED`; the corrected retry then answered
`RUN_NOT_FOUND`.
Branch on the child's own `code` (producer-first, per the triage ruling), return
the child's envelope with the code intact and both pauses untouched, and reserve
`failSuspendedRun` for a child that genuinely ran and failed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 7 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx(via INVALID_SCREEN_INPUT (literal, a string literal in RETRYABLE_RESUME_REFUSAL_CODES))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 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 0e68ed25cc45c15ce296c299614b2b51a3296e52packageMentionDocs.

Which tree this was computed on

This run read content/docs from 12ede636a4b77f1c4df9993fade21b1996dd7e3a — the merge of head 4ae326704950de2991b47cf21bd71c5601bc536f into base 0e68ed25cc45c15ce296c299614b2b51a3296e52, 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 12ede636a4b77f1c4df9993fade21b1996dd7e3a && git checkout 12ede636a4b77f1c4df9993fade21b1996dd7e3a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0e68ed25cc45c15ce296c299614b2b51a3296e52 4ae326704950de2991b47cf21bd71c5601bc536f && git checkout -B drift-repro 0e68ed25cc45c15ce296c299614b2b51a3296e52 && git merge --no-ff 4ae326704950de2991b47cf21bd71c5601bc536f
node scripts/docs-audit/affected-docs.mjs --json 0e68ed25cc45c15ce296c299614b2b51a3296e52

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0e68ed25cc45c15ce296c299614b2b51a3296e52 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-salesClaude

Copy link
Copy Markdown
Collaborator

Landing provenance — ready + auto-merge at head 4ae326704


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 5563bfbSep 2, 2026
34 of 35 checks passed
@os-sales
os-sales deleted the claude/issue-14379-subflow-child-refusal-propagation branch September 2, 2026 13:21
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-sales@claude