fix(service-automation): a no-catch try_catch keeps the record of the writes its try region already made - #14224

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-14184-trycatch-returned-failure-steps
Sep 1, 2026
Merged

fix(service-automation): a no-catch try_catch keeps the record of the writes its try region already made#14224
os-steve merged 3 commits into
mainfrom
claude/issue-14184-trycatch-returned-failure-steps

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#14184

The returned-failure half of the engine's childSteps asymmetry. #13803 (PR #14185) closed the throw half — a dying loop brands its thrown error with the body steps it completed and the engine's catch arm folds them into the run log — and deliberately left the if (!result.success) branch alone, because at that moment no executor returned childSteps on a failing result and a fold for zero producers is speculative.

try_catch is the producer that makes it real. It does not throw: it catches the try region's failure and RETURNS it, so it never reached the success-only splice. On that return it withheld its childSteps on purpose — reasoning that was correct while the engine spliced them only after a successful result, and stale the moment the failing branch learned to fold.

Measurement first — the card asked, and it reproduces

The card was explicit that it had not been reproduced. It was, before any fix, on the merge base e62129153, by the test file this PR adds: 11 of 16 pins RED.

  • A no-catchtry_catch whose try region writes two rows then fails reports { selected: 3, acted: 0 } over 2 writes that really landed.
  • The run log keeps zero try-region steps: steps.filter(s => s.regionKind === 'try') is [].

acted: 0 on a failed run reads as "nothing happened, safe to re-run", so the summary was wrong in the one direction that invites double-execution of a non-idempotent region. Same shape as #13803, one construct over.

The 5 pins that were GREEN before are the controls that must not move — see "Failure propagation" below.

The change

Two halves, mirroring #13803:

  • builtin/try-catch-node.ts — the no-catch failing return now carries childSteps: failedAttemptSteps.
  • engine.ts — the if (!result.success) branch folds result.childSteps, in the same position the throw arm and the success path use: right behind the container's own step, ahead of any fault handler's steps.

Both halves are load-bearing. Ablated separately, each one alone reproduces the pre-change measurement exactly — 11 red / 5 green, the same split as the merge base.

Failure propagation is untouched — proved, not asserted

This is the entire basis on which the change can be judged record-only, so it is measured the way PR #14185 measured its own: these assertions were green before the change and green after it.

  • try_catch still returns failure; the run still ends failed.
  • Error text byte-identical: Node 'guard' failed: try_catch 'guard': try region failed — Node 'bang' failed: boom: at least one recipient is required
  • The container's own step is still status: 'failure' with error.code: 'NODE_FAILURE' and the same message.
  • fault-edge routing unchanged, and $error byte-identical: { nodeId: 'guard', message: <the text above>, output: undefined }.
  • The contained (with-catch) path, the all-succeeding path and the failing-catch path are unchanged.

Unlike the shape partial-steps.ts explicitly rejected for loop (swallow the throw and return a failure instead), nothing here converts a throw into a return: this return already reported failure, already produced a NODE_FAILURE step, already set $error and already was routable by a fault edge. Only the record changes.

Reverse control

A try region that fails before writing anything still reports acted: 0 — there 0 is the honest answer. Without this pin the fix would be indistinguishable from copying selected into acted; selected is 3 and acted is 2 in the positive case, so the two also differ there.

Zero-producer / no-double-fold

Once the engine folds on if (!result.success), every failing executor passes through it. Surveyed repo-wide (childSteps, all of ., excluding node_modules/dist): all 9 carrying files live in packages/services/service-automation/, and the only producers are loop (success return), parallel (success return) and try_catch. loop's one failing return (the maxIterations guard) carries no childSteps. So try_catch is the only producer on the failing path, before and after.

Nesting is pinned three ways — no-catch inside no-catch, a dying loop inside a no-catch try region, and a no-catch inside a try_catchwith a catch. Each asserts acted reconciles exactly to the real write count (a step folded twice would push it above) and that new Set(steps).size === steps.length.

Every folded step carries a parentNodeId set by runRegion's tagger, so the ADR-0044 runaway guard, which counts only top-level visits, cannot see them.

Verification

Union run on the final commit 020552e3f.

whatresult
pnpm --filter @objectstack/service-automation test96 files / 1149 tests passed
new pin file, 16 tests11 red before the fix, 16 green after
pnpm lint (eslint . --no-inline-config, whole repo)exit 0
33 of the 35 derived gate familiesexit 0
pnpm --filter @objectstack/service-automation exec tsc --noEmit3 errors, all pre-existing in nested-region-parity.test.ts — equal to the DEBT ledger's recorded errors: 3

Gate families derived live with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (30 path-derived + convention-triggered), exit codes captured before any pipe.

Two families are NOT MEASURED locally (exit 3, which is neither a pass nor a finding) because their prerequisites are CI's to provide, and both say so themselves:

  • check:dual-build-cjs-loadsPREREQUISITE NOT MET, reads built output, 49 packages have no dist/.
  • check:type-check-debt--re-measure refuses without the whole workspace closure built.

For the ratchet, the narrowing is proved rather than assumed: the only ledger entry this diff can move is @objectstack/service-automation, and it was measured directly with its dependency closure built (pnpm --filter '@objectstack/service-automation^...' build green first) at exactly 3 — the recorded number, all three in a file this PR does not touch. tsc --listFiles confirms all three touched files are in that program, so the reading is measured and not a phantom green. No other entry can move either: the package's emitted dist/index.d.ts is byte-identical across the change (d4df83c4216e5b74e0196c73d5b5b9c8b05063bb both with and without the fix, each built from a disk state proved by marker count), so no downstream tsc program can see this diff. The structural half of the coverage gate ran and passed.

Ablation

Each half removed separately, and in both legs the mutation was proved on disk before the run (HEAD blob hash compared, marker count driven to 0) and the restore proved by state after it (git diff HEAD empty and the blob back to its HEAD hash) — never by an editor's exit code. Both legs carried an EXIT INT TERM restore trap with absolute paths.

legmutation on diskresult
remove the engine fold910c8074… to b5e4b3f5…, marker 1 to 011 failed / 5 passed
remove the executor's childStepsc94cba0f… to f5b5cb8c…, marker 1 to 011 failed / 5 passed

Both reproduce the merge-base split exactly, and the 5 controls stay green in both legs. No rebuild is in the loop: the pins reach the subject through relative source imports (../engine.js, ./try-catch-node.js), not through the package's exports, and the mutation of src/*.ts flipping the result is itself the proof that vitest compiles the source rather than dist/.

Out of scope, filed

#14222try_catch whose catch region itself fails still discards the whole record. Same defect class, but not a mechanical mirror: the catch region is run with no partialSteps sink, so closing it needs a new seam plus an ordering decision. Current behaviour is pinned here as an explicit boundary marker (not an endorsement), with a comment pointing at #14222.

Scope

The card offered a second shape — a lint / authoring diagnostic for try_catch with no catch. That is #13681's ruled B-branch, owned there; not touched. #13803 / PR #14185 not reopened. No content/docs/releases/ edit.

Changeset: .changeset/trycatch-returned-failure-step-record.md (@objectstack/service-automation: patch).


Generated by Claude Code

…cards its try-region step record
Measured on origin/main e621291: 11 of 16 pins RED. The run log keeps zero
try-region steps and the #4354 summary reports acted: 0 over 2 writes that
really landed. The 5 GREEN pins are the controls that must not move: failure
text, NODE_FAILURE step code, $error contents, the honest-zero reverse
control, and the contained (with-catch) path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…ep record (#14184)
Attach childSteps to try_catch's no-catch failing return, and fold
result.childSteps in the engine's if (!result.success) branch — the
returned-failure half of #13803's throw-path fold.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2packageMentionDocs.

Which tree this was computed on

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

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

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 15:47
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 7d3b1b7Sep 1, 2026
34 checks passed
@os-steve
os-steve deleted the claude/issue-14184-trycatch-returned-failure-steps branch September 1, 2026 16:13
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

try_catch with no catch region discards its try-region step record on the returned-failure path (the other half of the engine's childSteps asymmetry)

2 participants

@os-steve@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): a no-catch try_catch keeps the record of the writes its try region already made - #14224

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-14184-trycatch-returned-failure-steps
Sep 1, 2026
Merged

fix(service-automation): a no-catch try_catch keeps the record of the writes its try region already made#14224
os-steve merged 3 commits into
mainfrom
claude/issue-14184-trycatch-returned-failure-steps

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#14184

The returned-failure half of the engine's childSteps asymmetry. #13803 (PR #14185) closed the throw half — a dying loop brands its thrown error with the body steps it completed and the engine's catch arm folds them into the run log — and deliberately left the if (!result.success) branch alone, because at that moment no executor returned childSteps on a failing result and a fold for zero producers is speculative.

try_catch is the producer that makes it real. It does not throw: it catches the try region's failure and RETURNS it, so it never reached the success-only splice. On that return it withheld its childSteps on purpose — reasoning that was correct while the engine spliced them only after a successful result, and stale the moment the failing branch learned to fold.

Measurement first — the card asked, and it reproduces

The card was explicit that it had not been reproduced. It was, before any fix, on the merge base e62129153, by the test file this PR adds: 11 of 16 pins RED.

  • A no-catchtry_catch whose try region writes two rows then fails reports { selected: 3, acted: 0 } over 2 writes that really landed.
  • The run log keeps zero try-region steps: steps.filter(s => s.regionKind === 'try') is [].

acted: 0 on a failed run reads as "nothing happened, safe to re-run", so the summary was wrong in the one direction that invites double-execution of a non-idempotent region. Same shape as #13803, one construct over.

The 5 pins that were GREEN before are the controls that must not move — see "Failure propagation" below.

The change

Two halves, mirroring #13803:

  • builtin/try-catch-node.ts — the no-catch failing return now carries childSteps: failedAttemptSteps.
  • engine.ts — the if (!result.success) branch folds result.childSteps, in the same position the throw arm and the success path use: right behind the container's own step, ahead of any fault handler's steps.

Both halves are load-bearing. Ablated separately, each one alone reproduces the pre-change measurement exactly — 11 red / 5 green, the same split as the merge base.

Failure propagation is untouched — proved, not asserted

This is the entire basis on which the change can be judged record-only, so it is measured the way PR #14185 measured its own: these assertions were green before the change and green after it.

  • try_catch still returns failure; the run still ends failed.
  • Error text byte-identical: Node 'guard' failed: try_catch 'guard': try region failed — Node 'bang' failed: boom: at least one recipient is required
  • The container's own step is still status: 'failure' with error.code: 'NODE_FAILURE' and the same message.
  • fault-edge routing unchanged, and $error byte-identical: { nodeId: 'guard', message: <the text above>, output: undefined }.
  • The contained (with-catch) path, the all-succeeding path and the failing-catch path are unchanged.

Unlike the shape partial-steps.ts explicitly rejected for loop (swallow the throw and return a failure instead), nothing here converts a throw into a return: this return already reported failure, already produced a NODE_FAILURE step, already set $error and already was routable by a fault edge. Only the record changes.

Reverse control

A try region that fails before writing anything still reports acted: 0 — there 0 is the honest answer. Without this pin the fix would be indistinguishable from copying selected into acted; selected is 3 and acted is 2 in the positive case, so the two also differ there.

Zero-producer / no-double-fold

Once the engine folds on if (!result.success), every failing executor passes through it. Surveyed repo-wide (childSteps, all of ., excluding node_modules/dist): all 9 carrying files live in packages/services/service-automation/, and the only producers are loop (success return), parallel (success return) and try_catch. loop's one failing return (the maxIterations guard) carries no childSteps. So try_catch is the only producer on the failing path, before and after.

Nesting is pinned three ways — no-catch inside no-catch, a dying loop inside a no-catch try region, and a no-catch inside a try_catchwith a catch. Each asserts acted reconciles exactly to the real write count (a step folded twice would push it above) and that new Set(steps).size === steps.length.

Every folded step carries a parentNodeId set by runRegion's tagger, so the ADR-0044 runaway guard, which counts only top-level visits, cannot see them.

Verification

Union run on the final commit 020552e3f.

whatresult
pnpm --filter @objectstack/service-automation test96 files / 1149 tests passed
new pin file, 16 tests11 red before the fix, 16 green after
pnpm lint (eslint . --no-inline-config, whole repo)exit 0
33 of the 35 derived gate familiesexit 0
pnpm --filter @objectstack/service-automation exec tsc --noEmit3 errors, all pre-existing in nested-region-parity.test.ts — equal to the DEBT ledger's recorded errors: 3

Gate families derived live with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (30 path-derived + convention-triggered), exit codes captured before any pipe.

Two families are NOT MEASURED locally (exit 3, which is neither a pass nor a finding) because their prerequisites are CI's to provide, and both say so themselves:

  • check:dual-build-cjs-loadsPREREQUISITE NOT MET, reads built output, 49 packages have no dist/.
  • check:type-check-debt--re-measure refuses without the whole workspace closure built.

For the ratchet, the narrowing is proved rather than assumed: the only ledger entry this diff can move is @objectstack/service-automation, and it was measured directly with its dependency closure built (pnpm --filter '@objectstack/service-automation^...' build green first) at exactly 3 — the recorded number, all three in a file this PR does not touch. tsc --listFiles confirms all three touched files are in that program, so the reading is measured and not a phantom green. No other entry can move either: the package's emitted dist/index.d.ts is byte-identical across the change (d4df83c4216e5b74e0196c73d5b5b9c8b05063bb both with and without the fix, each built from a disk state proved by marker count), so no downstream tsc program can see this diff. The structural half of the coverage gate ran and passed.

Ablation

Each half removed separately, and in both legs the mutation was proved on disk before the run (HEAD blob hash compared, marker count driven to 0) and the restore proved by state after it (git diff HEAD empty and the blob back to its HEAD hash) — never by an editor's exit code. Both legs carried an EXIT INT TERM restore trap with absolute paths.

legmutation on diskresult
remove the engine fold910c8074… to b5e4b3f5…, marker 1 to 011 failed / 5 passed
remove the executor's childStepsc94cba0f… to f5b5cb8c…, marker 1 to 011 failed / 5 passed

Both reproduce the merge-base split exactly, and the 5 controls stay green in both legs. No rebuild is in the loop: the pins reach the subject through relative source imports (../engine.js, ./try-catch-node.js), not through the package's exports, and the mutation of src/*.ts flipping the result is itself the proof that vitest compiles the source rather than dist/.

Out of scope, filed

#14222try_catch whose catch region itself fails still discards the whole record. Same defect class, but not a mechanical mirror: the catch region is run with no partialSteps sink, so closing it needs a new seam plus an ordering decision. Current behaviour is pinned here as an explicit boundary marker (not an endorsement), with a comment pointing at #14222.

Scope

The card offered a second shape — a lint / authoring diagnostic for try_catch with no catch. That is #13681's ruled B-branch, owned there; not touched. #13803 / PR #14185 not reopened. No content/docs/releases/ edit.

Changeset: .changeset/trycatch-returned-failure-step-record.md (@objectstack/service-automation: patch).


Generated by Claude Code

…cards its try-region step record
Measured on origin/main e621291: 11 of 16 pins RED. The run log keeps zero
try-region steps and the #4354 summary reports acted: 0 over 2 writes that
really landed. The 5 GREEN pins are the controls that must not move: failure
text, NODE_FAILURE step code, $error contents, the honest-zero reverse
control, and the contained (with-catch) path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…ep record (#14184)
Attach childSteps to try_catch's no-catch failing return, and fold
result.childSteps in the engine's if (!result.success) branch — the
returned-failure half of #13803's throw-path fold.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2packageMentionDocs.

Which tree this was computed on

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

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

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 15:47
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 7d3b1b7Sep 1, 2026
34 checks passed
@os-steve
os-steve deleted the claude/issue-14184-trycatch-returned-failure-steps branch September 1, 2026 16:13
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

try_catch with no catch region discards its try-region step record on the returned-failure path (the other half of the engine's childSteps asymmetry)

2 participants

@os-steve@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): a no-catch try_catch keeps the record of the writes its try region already made - #14224

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-14184-trycatch-returned-failure-steps
Sep 1, 2026
Merged

fix(service-automation): a no-catch try_catch keeps the record of the writes its try region already made#14224
os-steve merged 3 commits into
mainfrom
claude/issue-14184-trycatch-returned-failure-steps

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#14184

The returned-failure half of the engine's childSteps asymmetry. #13803 (PR #14185) closed the throw half — a dying loop brands its thrown error with the body steps it completed and the engine's catch arm folds them into the run log — and deliberately left the if (!result.success) branch alone, because at that moment no executor returned childSteps on a failing result and a fold for zero producers is speculative.

try_catch is the producer that makes it real. It does not throw: it catches the try region's failure and RETURNS it, so it never reached the success-only splice. On that return it withheld its childSteps on purpose — reasoning that was correct while the engine spliced them only after a successful result, and stale the moment the failing branch learned to fold.

Measurement first — the card asked, and it reproduces

The card was explicit that it had not been reproduced. It was, before any fix, on the merge base e62129153, by the test file this PR adds: 11 of 16 pins RED.

  • A no-catchtry_catch whose try region writes two rows then fails reports { selected: 3, acted: 0 } over 2 writes that really landed.
  • The run log keeps zero try-region steps: steps.filter(s => s.regionKind === 'try') is [].

acted: 0 on a failed run reads as "nothing happened, safe to re-run", so the summary was wrong in the one direction that invites double-execution of a non-idempotent region. Same shape as #13803, one construct over.

The 5 pins that were GREEN before are the controls that must not move — see "Failure propagation" below.

The change

Two halves, mirroring #13803:

  • builtin/try-catch-node.ts — the no-catch failing return now carries childSteps: failedAttemptSteps.
  • engine.ts — the if (!result.success) branch folds result.childSteps, in the same position the throw arm and the success path use: right behind the container's own step, ahead of any fault handler's steps.

Both halves are load-bearing. Ablated separately, each one alone reproduces the pre-change measurement exactly — 11 red / 5 green, the same split as the merge base.

Failure propagation is untouched — proved, not asserted

This is the entire basis on which the change can be judged record-only, so it is measured the way PR #14185 measured its own: these assertions were green before the change and green after it.

  • try_catch still returns failure; the run still ends failed.
  • Error text byte-identical: Node 'guard' failed: try_catch 'guard': try region failed — Node 'bang' failed: boom: at least one recipient is required
  • The container's own step is still status: 'failure' with error.code: 'NODE_FAILURE' and the same message.
  • fault-edge routing unchanged, and $error byte-identical: { nodeId: 'guard', message: <the text above>, output: undefined }.
  • The contained (with-catch) path, the all-succeeding path and the failing-catch path are unchanged.

Unlike the shape partial-steps.ts explicitly rejected for loop (swallow the throw and return a failure instead), nothing here converts a throw into a return: this return already reported failure, already produced a NODE_FAILURE step, already set $error and already was routable by a fault edge. Only the record changes.

Reverse control

A try region that fails before writing anything still reports acted: 0 — there 0 is the honest answer. Without this pin the fix would be indistinguishable from copying selected into acted; selected is 3 and acted is 2 in the positive case, so the two also differ there.

Zero-producer / no-double-fold

Once the engine folds on if (!result.success), every failing executor passes through it. Surveyed repo-wide (childSteps, all of ., excluding node_modules/dist): all 9 carrying files live in packages/services/service-automation/, and the only producers are loop (success return), parallel (success return) and try_catch. loop's one failing return (the maxIterations guard) carries no childSteps. So try_catch is the only producer on the failing path, before and after.

Nesting is pinned three ways — no-catch inside no-catch, a dying loop inside a no-catch try region, and a no-catch inside a try_catchwith a catch. Each asserts acted reconciles exactly to the real write count (a step folded twice would push it above) and that new Set(steps).size === steps.length.

Every folded step carries a parentNodeId set by runRegion's tagger, so the ADR-0044 runaway guard, which counts only top-level visits, cannot see them.

Verification

Union run on the final commit 020552e3f.

whatresult
pnpm --filter @objectstack/service-automation test96 files / 1149 tests passed
new pin file, 16 tests11 red before the fix, 16 green after
pnpm lint (eslint . --no-inline-config, whole repo)exit 0
33 of the 35 derived gate familiesexit 0
pnpm --filter @objectstack/service-automation exec tsc --noEmit3 errors, all pre-existing in nested-region-parity.test.ts — equal to the DEBT ledger's recorded errors: 3

Gate families derived live with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (30 path-derived + convention-triggered), exit codes captured before any pipe.

Two families are NOT MEASURED locally (exit 3, which is neither a pass nor a finding) because their prerequisites are CI's to provide, and both say so themselves:

  • check:dual-build-cjs-loadsPREREQUISITE NOT MET, reads built output, 49 packages have no dist/.
  • check:type-check-debt--re-measure refuses without the whole workspace closure built.

For the ratchet, the narrowing is proved rather than assumed: the only ledger entry this diff can move is @objectstack/service-automation, and it was measured directly with its dependency closure built (pnpm --filter '@objectstack/service-automation^...' build green first) at exactly 3 — the recorded number, all three in a file this PR does not touch. tsc --listFiles confirms all three touched files are in that program, so the reading is measured and not a phantom green. No other entry can move either: the package's emitted dist/index.d.ts is byte-identical across the change (d4df83c4216e5b74e0196c73d5b5b9c8b05063bb both with and without the fix, each built from a disk state proved by marker count), so no downstream tsc program can see this diff. The structural half of the coverage gate ran and passed.

Ablation

Each half removed separately, and in both legs the mutation was proved on disk before the run (HEAD blob hash compared, marker count driven to 0) and the restore proved by state after it (git diff HEAD empty and the blob back to its HEAD hash) — never by an editor's exit code. Both legs carried an EXIT INT TERM restore trap with absolute paths.

legmutation on diskresult
remove the engine fold910c8074… to b5e4b3f5…, marker 1 to 011 failed / 5 passed
remove the executor's childStepsc94cba0f… to f5b5cb8c…, marker 1 to 011 failed / 5 passed

Both reproduce the merge-base split exactly, and the 5 controls stay green in both legs. No rebuild is in the loop: the pins reach the subject through relative source imports (../engine.js, ./try-catch-node.js), not through the package's exports, and the mutation of src/*.ts flipping the result is itself the proof that vitest compiles the source rather than dist/.

Out of scope, filed

#14222try_catch whose catch region itself fails still discards the whole record. Same defect class, but not a mechanical mirror: the catch region is run with no partialSteps sink, so closing it needs a new seam plus an ordering decision. Current behaviour is pinned here as an explicit boundary marker (not an endorsement), with a comment pointing at #14222.

Scope

The card offered a second shape — a lint / authoring diagnostic for try_catch with no catch. That is #13681's ruled B-branch, owned there; not touched. #13803 / PR #14185 not reopened. No content/docs/releases/ edit.

Changeset: .changeset/trycatch-returned-failure-step-record.md (@objectstack/service-automation: patch).


Generated by Claude Code

…cards its try-region step record
Measured on origin/main e621291: 11 of 16 pins RED. The run log keeps zero
try-region steps and the #4354 summary reports acted: 0 over 2 writes that
really landed. The 5 GREEN pins are the controls that must not move: failure
text, NODE_FAILURE step code, $error contents, the honest-zero reverse
control, and the contained (with-catch) path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…ep record (#14184)
Attach childSteps to try_catch's no-catch failing return, and fold
result.childSteps in the engine's if (!result.success) branch — the
returned-failure half of #13803's throw-path fold.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2packageMentionDocs.

Which tree this was computed on

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

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

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 15:47
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 7d3b1b7Sep 1, 2026
34 checks passed
@os-steve
os-steve deleted the claude/issue-14184-trycatch-returned-failure-steps branch September 1, 2026 16:13
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

try_catch with no catch region discards its try-region step record on the returned-failure path (the other half of the engine's childSteps asymmetry)

2 participants

@os-steve@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): a no-catch try_catch keeps the record of the writes its try region already made - #14224

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-14184-trycatch-returned-failure-steps
Sep 1, 2026
Merged

fix(service-automation): a no-catch try_catch keeps the record of the writes its try region already made#14224
os-steve merged 3 commits into
mainfrom
claude/issue-14184-trycatch-returned-failure-steps

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#14184

The returned-failure half of the engine's childSteps asymmetry. #13803 (PR #14185) closed the throw half — a dying loop brands its thrown error with the body steps it completed and the engine's catch arm folds them into the run log — and deliberately left the if (!result.success) branch alone, because at that moment no executor returned childSteps on a failing result and a fold for zero producers is speculative.

try_catch is the producer that makes it real. It does not throw: it catches the try region's failure and RETURNS it, so it never reached the success-only splice. On that return it withheld its childSteps on purpose — reasoning that was correct while the engine spliced them only after a successful result, and stale the moment the failing branch learned to fold.

Measurement first — the card asked, and it reproduces

The card was explicit that it had not been reproduced. It was, before any fix, on the merge base e62129153, by the test file this PR adds: 11 of 16 pins RED.

  • A no-catchtry_catch whose try region writes two rows then fails reports { selected: 3, acted: 0 } over 2 writes that really landed.
  • The run log keeps zero try-region steps: steps.filter(s => s.regionKind === 'try') is [].

acted: 0 on a failed run reads as "nothing happened, safe to re-run", so the summary was wrong in the one direction that invites double-execution of a non-idempotent region. Same shape as #13803, one construct over.

The 5 pins that were GREEN before are the controls that must not move — see "Failure propagation" below.

The change

Two halves, mirroring #13803:

  • builtin/try-catch-node.ts — the no-catch failing return now carries childSteps: failedAttemptSteps.
  • engine.ts — the if (!result.success) branch folds result.childSteps, in the same position the throw arm and the success path use: right behind the container's own step, ahead of any fault handler's steps.

Both halves are load-bearing. Ablated separately, each one alone reproduces the pre-change measurement exactly — 11 red / 5 green, the same split as the merge base.

Failure propagation is untouched — proved, not asserted

This is the entire basis on which the change can be judged record-only, so it is measured the way PR #14185 measured its own: these assertions were green before the change and green after it.

  • try_catch still returns failure; the run still ends failed.
  • Error text byte-identical: Node 'guard' failed: try_catch 'guard': try region failed — Node 'bang' failed: boom: at least one recipient is required
  • The container's own step is still status: 'failure' with error.code: 'NODE_FAILURE' and the same message.
  • fault-edge routing unchanged, and $error byte-identical: { nodeId: 'guard', message: <the text above>, output: undefined }.
  • The contained (with-catch) path, the all-succeeding path and the failing-catch path are unchanged.

Unlike the shape partial-steps.ts explicitly rejected for loop (swallow the throw and return a failure instead), nothing here converts a throw into a return: this return already reported failure, already produced a NODE_FAILURE step, already set $error and already was routable by a fault edge. Only the record changes.

Reverse control

A try region that fails before writing anything still reports acted: 0 — there 0 is the honest answer. Without this pin the fix would be indistinguishable from copying selected into acted; selected is 3 and acted is 2 in the positive case, so the two also differ there.

Zero-producer / no-double-fold

Once the engine folds on if (!result.success), every failing executor passes through it. Surveyed repo-wide (childSteps, all of ., excluding node_modules/dist): all 9 carrying files live in packages/services/service-automation/, and the only producers are loop (success return), parallel (success return) and try_catch. loop's one failing return (the maxIterations guard) carries no childSteps. So try_catch is the only producer on the failing path, before and after.

Nesting is pinned three ways — no-catch inside no-catch, a dying loop inside a no-catch try region, and a no-catch inside a try_catchwith a catch. Each asserts acted reconciles exactly to the real write count (a step folded twice would push it above) and that new Set(steps).size === steps.length.

Every folded step carries a parentNodeId set by runRegion's tagger, so the ADR-0044 runaway guard, which counts only top-level visits, cannot see them.

Verification

Union run on the final commit 020552e3f.

whatresult
pnpm --filter @objectstack/service-automation test96 files / 1149 tests passed
new pin file, 16 tests11 red before the fix, 16 green after
pnpm lint (eslint . --no-inline-config, whole repo)exit 0
33 of the 35 derived gate familiesexit 0
pnpm --filter @objectstack/service-automation exec tsc --noEmit3 errors, all pre-existing in nested-region-parity.test.ts — equal to the DEBT ledger's recorded errors: 3

Gate families derived live with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (30 path-derived + convention-triggered), exit codes captured before any pipe.

Two families are NOT MEASURED locally (exit 3, which is neither a pass nor a finding) because their prerequisites are CI's to provide, and both say so themselves:

  • check:dual-build-cjs-loadsPREREQUISITE NOT MET, reads built output, 49 packages have no dist/.
  • check:type-check-debt--re-measure refuses without the whole workspace closure built.

For the ratchet, the narrowing is proved rather than assumed: the only ledger entry this diff can move is @objectstack/service-automation, and it was measured directly with its dependency closure built (pnpm --filter '@objectstack/service-automation^...' build green first) at exactly 3 — the recorded number, all three in a file this PR does not touch. tsc --listFiles confirms all three touched files are in that program, so the reading is measured and not a phantom green. No other entry can move either: the package's emitted dist/index.d.ts is byte-identical across the change (d4df83c4216e5b74e0196c73d5b5b9c8b05063bb both with and without the fix, each built from a disk state proved by marker count), so no downstream tsc program can see this diff. The structural half of the coverage gate ran and passed.

Ablation

Each half removed separately, and in both legs the mutation was proved on disk before the run (HEAD blob hash compared, marker count driven to 0) and the restore proved by state after it (git diff HEAD empty and the blob back to its HEAD hash) — never by an editor's exit code. Both legs carried an EXIT INT TERM restore trap with absolute paths.

legmutation on diskresult
remove the engine fold910c8074… to b5e4b3f5…, marker 1 to 011 failed / 5 passed
remove the executor's childStepsc94cba0f… to f5b5cb8c…, marker 1 to 011 failed / 5 passed

Both reproduce the merge-base split exactly, and the 5 controls stay green in both legs. No rebuild is in the loop: the pins reach the subject through relative source imports (../engine.js, ./try-catch-node.js), not through the package's exports, and the mutation of src/*.ts flipping the result is itself the proof that vitest compiles the source rather than dist/.

Out of scope, filed

#14222try_catch whose catch region itself fails still discards the whole record. Same defect class, but not a mechanical mirror: the catch region is run with no partialSteps sink, so closing it needs a new seam plus an ordering decision. Current behaviour is pinned here as an explicit boundary marker (not an endorsement), with a comment pointing at #14222.

Scope

The card offered a second shape — a lint / authoring diagnostic for try_catch with no catch. That is #13681's ruled B-branch, owned there; not touched. #13803 / PR #14185 not reopened. No content/docs/releases/ edit.

Changeset: .changeset/trycatch-returned-failure-step-record.md (@objectstack/service-automation: patch).


Generated by Claude Code

…cards its try-region step record
Measured on origin/main e621291: 11 of 16 pins RED. The run log keeps zero
try-region steps and the #4354 summary reports acted: 0 over 2 writes that
really landed. The 5 GREEN pins are the controls that must not move: failure
text, NODE_FAILURE step code, $error contents, the honest-zero reverse
control, and the contained (with-catch) path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…ep record (#14184)
Attach childSteps to try_catch's no-catch failing return, and fold
result.childSteps in the engine's if (!result.success) branch — the
returned-failure half of #13803's throw-path fold.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2packageMentionDocs.

Which tree this was computed on

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

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

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 15:47
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 7d3b1b7Sep 1, 2026
34 checks passed
@os-steve
os-steve deleted the claude/issue-14184-trycatch-returned-failure-steps branch September 1, 2026 16:13
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

try_catch with no catch region discards its try-region step record on the returned-failure path (the other half of the engine's childSteps asymmetry)

2 participants

@os-steve@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): a no-catch try_catch keeps the record of the writes its try region already made - #14224

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-14184-trycatch-returned-failure-steps
Sep 1, 2026
Merged

fix(service-automation): a no-catch try_catch keeps the record of the writes its try region already made#14224
os-steve merged 3 commits into
mainfrom
claude/issue-14184-trycatch-returned-failure-steps

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#14184

The returned-failure half of the engine's childSteps asymmetry. #13803 (PR #14185) closed the throw half — a dying loop brands its thrown error with the body steps it completed and the engine's catch arm folds them into the run log — and deliberately left the if (!result.success) branch alone, because at that moment no executor returned childSteps on a failing result and a fold for zero producers is speculative.

try_catch is the producer that makes it real. It does not throw: it catches the try region's failure and RETURNS it, so it never reached the success-only splice. On that return it withheld its childSteps on purpose — reasoning that was correct while the engine spliced them only after a successful result, and stale the moment the failing branch learned to fold.

Measurement first — the card asked, and it reproduces

The card was explicit that it had not been reproduced. It was, before any fix, on the merge base e62129153, by the test file this PR adds: 11 of 16 pins RED.

  • A no-catchtry_catch whose try region writes two rows then fails reports { selected: 3, acted: 0 } over 2 writes that really landed.
  • The run log keeps zero try-region steps: steps.filter(s => s.regionKind === 'try') is [].

acted: 0 on a failed run reads as "nothing happened, safe to re-run", so the summary was wrong in the one direction that invites double-execution of a non-idempotent region. Same shape as #13803, one construct over.

The 5 pins that were GREEN before are the controls that must not move — see "Failure propagation" below.

The change

Two halves, mirroring #13803:

  • builtin/try-catch-node.ts — the no-catch failing return now carries childSteps: failedAttemptSteps.
  • engine.ts — the if (!result.success) branch folds result.childSteps, in the same position the throw arm and the success path use: right behind the container's own step, ahead of any fault handler's steps.

Both halves are load-bearing. Ablated separately, each one alone reproduces the pre-change measurement exactly — 11 red / 5 green, the same split as the merge base.

Failure propagation is untouched — proved, not asserted

This is the entire basis on which the change can be judged record-only, so it is measured the way PR #14185 measured its own: these assertions were green before the change and green after it.

  • try_catch still returns failure; the run still ends failed.
  • Error text byte-identical: Node 'guard' failed: try_catch 'guard': try region failed — Node 'bang' failed: boom: at least one recipient is required
  • The container's own step is still status: 'failure' with error.code: 'NODE_FAILURE' and the same message.
  • fault-edge routing unchanged, and $error byte-identical: { nodeId: 'guard', message: <the text above>, output: undefined }.
  • The contained (with-catch) path, the all-succeeding path and the failing-catch path are unchanged.

Unlike the shape partial-steps.ts explicitly rejected for loop (swallow the throw and return a failure instead), nothing here converts a throw into a return: this return already reported failure, already produced a NODE_FAILURE step, already set $error and already was routable by a fault edge. Only the record changes.

Reverse control

A try region that fails before writing anything still reports acted: 0 — there 0 is the honest answer. Without this pin the fix would be indistinguishable from copying selected into acted; selected is 3 and acted is 2 in the positive case, so the two also differ there.

Zero-producer / no-double-fold

Once the engine folds on if (!result.success), every failing executor passes through it. Surveyed repo-wide (childSteps, all of ., excluding node_modules/dist): all 9 carrying files live in packages/services/service-automation/, and the only producers are loop (success return), parallel (success return) and try_catch. loop's one failing return (the maxIterations guard) carries no childSteps. So try_catch is the only producer on the failing path, before and after.

Nesting is pinned three ways — no-catch inside no-catch, a dying loop inside a no-catch try region, and a no-catch inside a try_catchwith a catch. Each asserts acted reconciles exactly to the real write count (a step folded twice would push it above) and that new Set(steps).size === steps.length.

Every folded step carries a parentNodeId set by runRegion's tagger, so the ADR-0044 runaway guard, which counts only top-level visits, cannot see them.

Verification

Union run on the final commit 020552e3f.

whatresult
pnpm --filter @objectstack/service-automation test96 files / 1149 tests passed
new pin file, 16 tests11 red before the fix, 16 green after
pnpm lint (eslint . --no-inline-config, whole repo)exit 0
33 of the 35 derived gate familiesexit 0
pnpm --filter @objectstack/service-automation exec tsc --noEmit3 errors, all pre-existing in nested-region-parity.test.ts — equal to the DEBT ledger's recorded errors: 3

Gate families derived live with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (30 path-derived + convention-triggered), exit codes captured before any pipe.

Two families are NOT MEASURED locally (exit 3, which is neither a pass nor a finding) because their prerequisites are CI's to provide, and both say so themselves:

  • check:dual-build-cjs-loadsPREREQUISITE NOT MET, reads built output, 49 packages have no dist/.
  • check:type-check-debt--re-measure refuses without the whole workspace closure built.

For the ratchet, the narrowing is proved rather than assumed: the only ledger entry this diff can move is @objectstack/service-automation, and it was measured directly with its dependency closure built (pnpm --filter '@objectstack/service-automation^...' build green first) at exactly 3 — the recorded number, all three in a file this PR does not touch. tsc --listFiles confirms all three touched files are in that program, so the reading is measured and not a phantom green. No other entry can move either: the package's emitted dist/index.d.ts is byte-identical across the change (d4df83c4216e5b74e0196c73d5b5b9c8b05063bb both with and without the fix, each built from a disk state proved by marker count), so no downstream tsc program can see this diff. The structural half of the coverage gate ran and passed.

Ablation

Each half removed separately, and in both legs the mutation was proved on disk before the run (HEAD blob hash compared, marker count driven to 0) and the restore proved by state after it (git diff HEAD empty and the blob back to its HEAD hash) — never by an editor's exit code. Both legs carried an EXIT INT TERM restore trap with absolute paths.

legmutation on diskresult
remove the engine fold910c8074… to b5e4b3f5…, marker 1 to 011 failed / 5 passed
remove the executor's childStepsc94cba0f… to f5b5cb8c…, marker 1 to 011 failed / 5 passed

Both reproduce the merge-base split exactly, and the 5 controls stay green in both legs. No rebuild is in the loop: the pins reach the subject through relative source imports (../engine.js, ./try-catch-node.js), not through the package's exports, and the mutation of src/*.ts flipping the result is itself the proof that vitest compiles the source rather than dist/.

Out of scope, filed

#14222try_catch whose catch region itself fails still discards the whole record. Same defect class, but not a mechanical mirror: the catch region is run with no partialSteps sink, so closing it needs a new seam plus an ordering decision. Current behaviour is pinned here as an explicit boundary marker (not an endorsement), with a comment pointing at #14222.

Scope

The card offered a second shape — a lint / authoring diagnostic for try_catch with no catch. That is #13681's ruled B-branch, owned there; not touched. #13803 / PR #14185 not reopened. No content/docs/releases/ edit.

Changeset: .changeset/trycatch-returned-failure-step-record.md (@objectstack/service-automation: patch).


Generated by Claude Code

…cards its try-region step record
Measured on origin/main e621291: 11 of 16 pins RED. The run log keeps zero
try-region steps and the #4354 summary reports acted: 0 over 2 writes that
really landed. The 5 GREEN pins are the controls that must not move: failure
text, NODE_FAILURE step code, $error contents, the honest-zero reverse
control, and the contained (with-catch) path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…ep record (#14184)
Attach childSteps to try_catch's no-catch failing return, and fold
result.childSteps in the engine's if (!result.success) branch — the
returned-failure half of #13803's throw-path fold.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2packageMentionDocs.

Which tree this was computed on

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

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

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 15:47
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 7d3b1b7Sep 1, 2026
34 checks passed
@os-steve
os-steve deleted the claude/issue-14184-trycatch-returned-failure-steps branch September 1, 2026 16:13
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

try_catch with no catch region discards its try-region step record on the returned-failure path (the other half of the engine's childSteps asymmetry)

2 participants

@os-steve@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): a no-catch try_catch keeps the record of the writes its try region already made - #14224

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-14184-trycatch-returned-failure-steps
Sep 1, 2026
Merged

fix(service-automation): a no-catch try_catch keeps the record of the writes its try region already made#14224
os-steve merged 3 commits into
mainfrom
claude/issue-14184-trycatch-returned-failure-steps

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#14184

The returned-failure half of the engine's childSteps asymmetry. #13803 (PR #14185) closed the throw half — a dying loop brands its thrown error with the body steps it completed and the engine's catch arm folds them into the run log — and deliberately left the if (!result.success) branch alone, because at that moment no executor returned childSteps on a failing result and a fold for zero producers is speculative.

try_catch is the producer that makes it real. It does not throw: it catches the try region's failure and RETURNS it, so it never reached the success-only splice. On that return it withheld its childSteps on purpose — reasoning that was correct while the engine spliced them only after a successful result, and stale the moment the failing branch learned to fold.

Measurement first — the card asked, and it reproduces

The card was explicit that it had not been reproduced. It was, before any fix, on the merge base e62129153, by the test file this PR adds: 11 of 16 pins RED.

  • A no-catchtry_catch whose try region writes two rows then fails reports { selected: 3, acted: 0 } over 2 writes that really landed.
  • The run log keeps zero try-region steps: steps.filter(s => s.regionKind === 'try') is [].

acted: 0 on a failed run reads as "nothing happened, safe to re-run", so the summary was wrong in the one direction that invites double-execution of a non-idempotent region. Same shape as #13803, one construct over.

The 5 pins that were GREEN before are the controls that must not move — see "Failure propagation" below.

The change

Two halves, mirroring #13803:

  • builtin/try-catch-node.ts — the no-catch failing return now carries childSteps: failedAttemptSteps.
  • engine.ts — the if (!result.success) branch folds result.childSteps, in the same position the throw arm and the success path use: right behind the container's own step, ahead of any fault handler's steps.

Both halves are load-bearing. Ablated separately, each one alone reproduces the pre-change measurement exactly — 11 red / 5 green, the same split as the merge base.

Failure propagation is untouched — proved, not asserted

This is the entire basis on which the change can be judged record-only, so it is measured the way PR #14185 measured its own: these assertions were green before the change and green after it.

  • try_catch still returns failure; the run still ends failed.
  • Error text byte-identical: Node 'guard' failed: try_catch 'guard': try region failed — Node 'bang' failed: boom: at least one recipient is required
  • The container's own step is still status: 'failure' with error.code: 'NODE_FAILURE' and the same message.
  • fault-edge routing unchanged, and $error byte-identical: { nodeId: 'guard', message: <the text above>, output: undefined }.
  • The contained (with-catch) path, the all-succeeding path and the failing-catch path are unchanged.

Unlike the shape partial-steps.ts explicitly rejected for loop (swallow the throw and return a failure instead), nothing here converts a throw into a return: this return already reported failure, already produced a NODE_FAILURE step, already set $error and already was routable by a fault edge. Only the record changes.

Reverse control

A try region that fails before writing anything still reports acted: 0 — there 0 is the honest answer. Without this pin the fix would be indistinguishable from copying selected into acted; selected is 3 and acted is 2 in the positive case, so the two also differ there.

Zero-producer / no-double-fold

Once the engine folds on if (!result.success), every failing executor passes through it. Surveyed repo-wide (childSteps, all of ., excluding node_modules/dist): all 9 carrying files live in packages/services/service-automation/, and the only producers are loop (success return), parallel (success return) and try_catch. loop's one failing return (the maxIterations guard) carries no childSteps. So try_catch is the only producer on the failing path, before and after.

Nesting is pinned three ways — no-catch inside no-catch, a dying loop inside a no-catch try region, and a no-catch inside a try_catchwith a catch. Each asserts acted reconciles exactly to the real write count (a step folded twice would push it above) and that new Set(steps).size === steps.length.

Every folded step carries a parentNodeId set by runRegion's tagger, so the ADR-0044 runaway guard, which counts only top-level visits, cannot see them.

Verification

Union run on the final commit 020552e3f.

whatresult
pnpm --filter @objectstack/service-automation test96 files / 1149 tests passed
new pin file, 16 tests11 red before the fix, 16 green after
pnpm lint (eslint . --no-inline-config, whole repo)exit 0
33 of the 35 derived gate familiesexit 0
pnpm --filter @objectstack/service-automation exec tsc --noEmit3 errors, all pre-existing in nested-region-parity.test.ts — equal to the DEBT ledger's recorded errors: 3

Gate families derived live with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (30 path-derived + convention-triggered), exit codes captured before any pipe.

Two families are NOT MEASURED locally (exit 3, which is neither a pass nor a finding) because their prerequisites are CI's to provide, and both say so themselves:

  • check:dual-build-cjs-loadsPREREQUISITE NOT MET, reads built output, 49 packages have no dist/.
  • check:type-check-debt--re-measure refuses without the whole workspace closure built.

For the ratchet, the narrowing is proved rather than assumed: the only ledger entry this diff can move is @objectstack/service-automation, and it was measured directly with its dependency closure built (pnpm --filter '@objectstack/service-automation^...' build green first) at exactly 3 — the recorded number, all three in a file this PR does not touch. tsc --listFiles confirms all three touched files are in that program, so the reading is measured and not a phantom green. No other entry can move either: the package's emitted dist/index.d.ts is byte-identical across the change (d4df83c4216e5b74e0196c73d5b5b9c8b05063bb both with and without the fix, each built from a disk state proved by marker count), so no downstream tsc program can see this diff. The structural half of the coverage gate ran and passed.

Ablation

Each half removed separately, and in both legs the mutation was proved on disk before the run (HEAD blob hash compared, marker count driven to 0) and the restore proved by state after it (git diff HEAD empty and the blob back to its HEAD hash) — never by an editor's exit code. Both legs carried an EXIT INT TERM restore trap with absolute paths.

legmutation on diskresult
remove the engine fold910c8074… to b5e4b3f5…, marker 1 to 011 failed / 5 passed
remove the executor's childStepsc94cba0f… to f5b5cb8c…, marker 1 to 011 failed / 5 passed

Both reproduce the merge-base split exactly, and the 5 controls stay green in both legs. No rebuild is in the loop: the pins reach the subject through relative source imports (../engine.js, ./try-catch-node.js), not through the package's exports, and the mutation of src/*.ts flipping the result is itself the proof that vitest compiles the source rather than dist/.

Out of scope, filed

#14222try_catch whose catch region itself fails still discards the whole record. Same defect class, but not a mechanical mirror: the catch region is run with no partialSteps sink, so closing it needs a new seam plus an ordering decision. Current behaviour is pinned here as an explicit boundary marker (not an endorsement), with a comment pointing at #14222.

Scope

The card offered a second shape — a lint / authoring diagnostic for try_catch with no catch. That is #13681's ruled B-branch, owned there; not touched. #13803 / PR #14185 not reopened. No content/docs/releases/ edit.

Changeset: .changeset/trycatch-returned-failure-step-record.md (@objectstack/service-automation: patch).


Generated by Claude Code

…cards its try-region step record
Measured on origin/main e621291: 11 of 16 pins RED. The run log keeps zero
try-region steps and the #4354 summary reports acted: 0 over 2 writes that
really landed. The 5 GREEN pins are the controls that must not move: failure
text, NODE_FAILURE step code, $error contents, the honest-zero reverse
control, and the contained (with-catch) path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…ep record (#14184)
Attach childSteps to try_catch's no-catch failing return, and fold
result.childSteps in the engine's if (!result.success) branch — the
returned-failure half of #13803's throw-path fold.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2packageMentionDocs.

Which tree this was computed on

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

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

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 15:47
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 7d3b1b7Sep 1, 2026
34 checks passed
@os-steve
os-steve deleted the claude/issue-14184-trycatch-returned-failure-steps branch September 1, 2026 16:13
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

try_catch with no catch region discards its try-region step record on the returned-failure path (the other half of the engine's childSteps asymmetry)

2 participants

@os-steve@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): a no-catch try_catch keeps the record of the writes its try region already made - #14224

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-14184-trycatch-returned-failure-steps
Sep 1, 2026
Merged

fix(service-automation): a no-catch try_catch keeps the record of the writes its try region already made#14224
os-steve merged 3 commits into
mainfrom
claude/issue-14184-trycatch-returned-failure-steps

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#14184

The returned-failure half of the engine's childSteps asymmetry. #13803 (PR #14185) closed the throw half — a dying loop brands its thrown error with the body steps it completed and the engine's catch arm folds them into the run log — and deliberately left the if (!result.success) branch alone, because at that moment no executor returned childSteps on a failing result and a fold for zero producers is speculative.

try_catch is the producer that makes it real. It does not throw: it catches the try region's failure and RETURNS it, so it never reached the success-only splice. On that return it withheld its childSteps on purpose — reasoning that was correct while the engine spliced them only after a successful result, and stale the moment the failing branch learned to fold.

Measurement first — the card asked, and it reproduces

The card was explicit that it had not been reproduced. It was, before any fix, on the merge base e62129153, by the test file this PR adds: 11 of 16 pins RED.

  • A no-catchtry_catch whose try region writes two rows then fails reports { selected: 3, acted: 0 } over 2 writes that really landed.
  • The run log keeps zero try-region steps: steps.filter(s => s.regionKind === 'try') is [].

acted: 0 on a failed run reads as "nothing happened, safe to re-run", so the summary was wrong in the one direction that invites double-execution of a non-idempotent region. Same shape as #13803, one construct over.

The 5 pins that were GREEN before are the controls that must not move — see "Failure propagation" below.

The change

Two halves, mirroring #13803:

  • builtin/try-catch-node.ts — the no-catch failing return now carries childSteps: failedAttemptSteps.
  • engine.ts — the if (!result.success) branch folds result.childSteps, in the same position the throw arm and the success path use: right behind the container's own step, ahead of any fault handler's steps.

Both halves are load-bearing. Ablated separately, each one alone reproduces the pre-change measurement exactly — 11 red / 5 green, the same split as the merge base.

Failure propagation is untouched — proved, not asserted

This is the entire basis on which the change can be judged record-only, so it is measured the way PR #14185 measured its own: these assertions were green before the change and green after it.

  • try_catch still returns failure; the run still ends failed.
  • Error text byte-identical: Node 'guard' failed: try_catch 'guard': try region failed — Node 'bang' failed: boom: at least one recipient is required
  • The container's own step is still status: 'failure' with error.code: 'NODE_FAILURE' and the same message.
  • fault-edge routing unchanged, and $error byte-identical: { nodeId: 'guard', message: <the text above>, output: undefined }.
  • The contained (with-catch) path, the all-succeeding path and the failing-catch path are unchanged.

Unlike the shape partial-steps.ts explicitly rejected for loop (swallow the throw and return a failure instead), nothing here converts a throw into a return: this return already reported failure, already produced a NODE_FAILURE step, already set $error and already was routable by a fault edge. Only the record changes.

Reverse control

A try region that fails before writing anything still reports acted: 0 — there 0 is the honest answer. Without this pin the fix would be indistinguishable from copying selected into acted; selected is 3 and acted is 2 in the positive case, so the two also differ there.

Zero-producer / no-double-fold

Once the engine folds on if (!result.success), every failing executor passes through it. Surveyed repo-wide (childSteps, all of ., excluding node_modules/dist): all 9 carrying files live in packages/services/service-automation/, and the only producers are loop (success return), parallel (success return) and try_catch. loop's one failing return (the maxIterations guard) carries no childSteps. So try_catch is the only producer on the failing path, before and after.

Nesting is pinned three ways — no-catch inside no-catch, a dying loop inside a no-catch try region, and a no-catch inside a try_catchwith a catch. Each asserts acted reconciles exactly to the real write count (a step folded twice would push it above) and that new Set(steps).size === steps.length.

Every folded step carries a parentNodeId set by runRegion's tagger, so the ADR-0044 runaway guard, which counts only top-level visits, cannot see them.

Verification

Union run on the final commit 020552e3f.

whatresult
pnpm --filter @objectstack/service-automation test96 files / 1149 tests passed
new pin file, 16 tests11 red before the fix, 16 green after
pnpm lint (eslint . --no-inline-config, whole repo)exit 0
33 of the 35 derived gate familiesexit 0
pnpm --filter @objectstack/service-automation exec tsc --noEmit3 errors, all pre-existing in nested-region-parity.test.ts — equal to the DEBT ledger's recorded errors: 3

Gate families derived live with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (30 path-derived + convention-triggered), exit codes captured before any pipe.

Two families are NOT MEASURED locally (exit 3, which is neither a pass nor a finding) because their prerequisites are CI's to provide, and both say so themselves:

  • check:dual-build-cjs-loadsPREREQUISITE NOT MET, reads built output, 49 packages have no dist/.
  • check:type-check-debt--re-measure refuses without the whole workspace closure built.

For the ratchet, the narrowing is proved rather than assumed: the only ledger entry this diff can move is @objectstack/service-automation, and it was measured directly with its dependency closure built (pnpm --filter '@objectstack/service-automation^...' build green first) at exactly 3 — the recorded number, all three in a file this PR does not touch. tsc --listFiles confirms all three touched files are in that program, so the reading is measured and not a phantom green. No other entry can move either: the package's emitted dist/index.d.ts is byte-identical across the change (d4df83c4216e5b74e0196c73d5b5b9c8b05063bb both with and without the fix, each built from a disk state proved by marker count), so no downstream tsc program can see this diff. The structural half of the coverage gate ran and passed.

Ablation

Each half removed separately, and in both legs the mutation was proved on disk before the run (HEAD blob hash compared, marker count driven to 0) and the restore proved by state after it (git diff HEAD empty and the blob back to its HEAD hash) — never by an editor's exit code. Both legs carried an EXIT INT TERM restore trap with absolute paths.

legmutation on diskresult
remove the engine fold910c8074… to b5e4b3f5…, marker 1 to 011 failed / 5 passed
remove the executor's childStepsc94cba0f… to f5b5cb8c…, marker 1 to 011 failed / 5 passed

Both reproduce the merge-base split exactly, and the 5 controls stay green in both legs. No rebuild is in the loop: the pins reach the subject through relative source imports (../engine.js, ./try-catch-node.js), not through the package's exports, and the mutation of src/*.ts flipping the result is itself the proof that vitest compiles the source rather than dist/.

Out of scope, filed

#14222try_catch whose catch region itself fails still discards the whole record. Same defect class, but not a mechanical mirror: the catch region is run with no partialSteps sink, so closing it needs a new seam plus an ordering decision. Current behaviour is pinned here as an explicit boundary marker (not an endorsement), with a comment pointing at #14222.

Scope

The card offered a second shape — a lint / authoring diagnostic for try_catch with no catch. That is #13681's ruled B-branch, owned there; not touched. #13803 / PR #14185 not reopened. No content/docs/releases/ edit.

Changeset: .changeset/trycatch-returned-failure-step-record.md (@objectstack/service-automation: patch).


Generated by Claude Code

…cards its try-region step record
Measured on origin/main e621291: 11 of 16 pins RED. The run log keeps zero
try-region steps and the #4354 summary reports acted: 0 over 2 writes that
really landed. The 5 GREEN pins are the controls that must not move: failure
text, NODE_FAILURE step code, $error contents, the honest-zero reverse
control, and the contained (with-catch) path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…ep record (#14184)
Attach childSteps to try_catch's no-catch failing return, and fold
result.childSteps in the engine's if (!result.success) branch — the
returned-failure half of #13803's throw-path fold.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2packageMentionDocs.

Which tree this was computed on

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

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

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 15:47
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 7d3b1b7Sep 1, 2026
34 checks passed
@os-steve
os-steve deleted the claude/issue-14184-trycatch-returned-failure-steps branch September 1, 2026 16:13
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

try_catch with no catch region discards its try-region step record on the returned-failure path (the other half of the engine's childSteps asymmetry)

2 participants

@os-steve@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): a no-catch try_catch keeps the record of the writes its try region already made - #14224

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-14184-trycatch-returned-failure-steps
Sep 1, 2026
Merged

fix(service-automation): a no-catch try_catch keeps the record of the writes its try region already made#14224
os-steve merged 3 commits into
mainfrom
claude/issue-14184-trycatch-returned-failure-steps

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#14184

The returned-failure half of the engine's childSteps asymmetry. #13803 (PR #14185) closed the throw half — a dying loop brands its thrown error with the body steps it completed and the engine's catch arm folds them into the run log — and deliberately left the if (!result.success) branch alone, because at that moment no executor returned childSteps on a failing result and a fold for zero producers is speculative.

try_catch is the producer that makes it real. It does not throw: it catches the try region's failure and RETURNS it, so it never reached the success-only splice. On that return it withheld its childSteps on purpose — reasoning that was correct while the engine spliced them only after a successful result, and stale the moment the failing branch learned to fold.

Measurement first — the card asked, and it reproduces

The card was explicit that it had not been reproduced. It was, before any fix, on the merge base e62129153, by the test file this PR adds: 11 of 16 pins RED.

  • A no-catchtry_catch whose try region writes two rows then fails reports { selected: 3, acted: 0 } over 2 writes that really landed.
  • The run log keeps zero try-region steps: steps.filter(s => s.regionKind === 'try') is [].

acted: 0 on a failed run reads as "nothing happened, safe to re-run", so the summary was wrong in the one direction that invites double-execution of a non-idempotent region. Same shape as #13803, one construct over.

The 5 pins that were GREEN before are the controls that must not move — see "Failure propagation" below.

The change

Two halves, mirroring #13803:

  • builtin/try-catch-node.ts — the no-catch failing return now carries childSteps: failedAttemptSteps.
  • engine.ts — the if (!result.success) branch folds result.childSteps, in the same position the throw arm and the success path use: right behind the container's own step, ahead of any fault handler's steps.

Both halves are load-bearing. Ablated separately, each one alone reproduces the pre-change measurement exactly — 11 red / 5 green, the same split as the merge base.

Failure propagation is untouched — proved, not asserted

This is the entire basis on which the change can be judged record-only, so it is measured the way PR #14185 measured its own: these assertions were green before the change and green after it.

  • try_catch still returns failure; the run still ends failed.
  • Error text byte-identical: Node 'guard' failed: try_catch 'guard': try region failed — Node 'bang' failed: boom: at least one recipient is required
  • The container's own step is still status: 'failure' with error.code: 'NODE_FAILURE' and the same message.
  • fault-edge routing unchanged, and $error byte-identical: { nodeId: 'guard', message: <the text above>, output: undefined }.
  • The contained (with-catch) path, the all-succeeding path and the failing-catch path are unchanged.

Unlike the shape partial-steps.ts explicitly rejected for loop (swallow the throw and return a failure instead), nothing here converts a throw into a return: this return already reported failure, already produced a NODE_FAILURE step, already set $error and already was routable by a fault edge. Only the record changes.

Reverse control

A try region that fails before writing anything still reports acted: 0 — there 0 is the honest answer. Without this pin the fix would be indistinguishable from copying selected into acted; selected is 3 and acted is 2 in the positive case, so the two also differ there.

Zero-producer / no-double-fold

Once the engine folds on if (!result.success), every failing executor passes through it. Surveyed repo-wide (childSteps, all of ., excluding node_modules/dist): all 9 carrying files live in packages/services/service-automation/, and the only producers are loop (success return), parallel (success return) and try_catch. loop's one failing return (the maxIterations guard) carries no childSteps. So try_catch is the only producer on the failing path, before and after.

Nesting is pinned three ways — no-catch inside no-catch, a dying loop inside a no-catch try region, and a no-catch inside a try_catchwith a catch. Each asserts acted reconciles exactly to the real write count (a step folded twice would push it above) and that new Set(steps).size === steps.length.

Every folded step carries a parentNodeId set by runRegion's tagger, so the ADR-0044 runaway guard, which counts only top-level visits, cannot see them.

Verification

Union run on the final commit 020552e3f.

whatresult
pnpm --filter @objectstack/service-automation test96 files / 1149 tests passed
new pin file, 16 tests11 red before the fix, 16 green after
pnpm lint (eslint . --no-inline-config, whole repo)exit 0
33 of the 35 derived gate familiesexit 0
pnpm --filter @objectstack/service-automation exec tsc --noEmit3 errors, all pre-existing in nested-region-parity.test.ts — equal to the DEBT ledger's recorded errors: 3

Gate families derived live with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (30 path-derived + convention-triggered), exit codes captured before any pipe.

Two families are NOT MEASURED locally (exit 3, which is neither a pass nor a finding) because their prerequisites are CI's to provide, and both say so themselves:

  • check:dual-build-cjs-loadsPREREQUISITE NOT MET, reads built output, 49 packages have no dist/.
  • check:type-check-debt--re-measure refuses without the whole workspace closure built.

For the ratchet, the narrowing is proved rather than assumed: the only ledger entry this diff can move is @objectstack/service-automation, and it was measured directly with its dependency closure built (pnpm --filter '@objectstack/service-automation^...' build green first) at exactly 3 — the recorded number, all three in a file this PR does not touch. tsc --listFiles confirms all three touched files are in that program, so the reading is measured and not a phantom green. No other entry can move either: the package's emitted dist/index.d.ts is byte-identical across the change (d4df83c4216e5b74e0196c73d5b5b9c8b05063bb both with and without the fix, each built from a disk state proved by marker count), so no downstream tsc program can see this diff. The structural half of the coverage gate ran and passed.

Ablation

Each half removed separately, and in both legs the mutation was proved on disk before the run (HEAD blob hash compared, marker count driven to 0) and the restore proved by state after it (git diff HEAD empty and the blob back to its HEAD hash) — never by an editor's exit code. Both legs carried an EXIT INT TERM restore trap with absolute paths.

legmutation on diskresult
remove the engine fold910c8074… to b5e4b3f5…, marker 1 to 011 failed / 5 passed
remove the executor's childStepsc94cba0f… to f5b5cb8c…, marker 1 to 011 failed / 5 passed

Both reproduce the merge-base split exactly, and the 5 controls stay green in both legs. No rebuild is in the loop: the pins reach the subject through relative source imports (../engine.js, ./try-catch-node.js), not through the package's exports, and the mutation of src/*.ts flipping the result is itself the proof that vitest compiles the source rather than dist/.

Out of scope, filed

#14222try_catch whose catch region itself fails still discards the whole record. Same defect class, but not a mechanical mirror: the catch region is run with no partialSteps sink, so closing it needs a new seam plus an ordering decision. Current behaviour is pinned here as an explicit boundary marker (not an endorsement), with a comment pointing at #14222.

Scope

The card offered a second shape — a lint / authoring diagnostic for try_catch with no catch. That is #13681's ruled B-branch, owned there; not touched. #13803 / PR #14185 not reopened. No content/docs/releases/ edit.

Changeset: .changeset/trycatch-returned-failure-step-record.md (@objectstack/service-automation: patch).


Generated by Claude Code

…cards its try-region step record
Measured on origin/main e621291: 11 of 16 pins RED. The run log keeps zero
try-region steps and the #4354 summary reports acted: 0 over 2 writes that
really landed. The 5 GREEN pins are the controls that must not move: failure
text, NODE_FAILURE step code, $error contents, the honest-zero reverse
control, and the contained (with-catch) path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…ep record (#14184)
Attach childSteps to try_catch's no-catch failing return, and fold
result.childSteps in the engine's if (!result.success) branch — the
returned-failure half of #13803's throw-path fold.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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 33dea61f579288c6f1b6b7e9a4789b932ddfa3e2packageMentionDocs.

Which tree this was computed on

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

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

@os-steve
os-steve marked this pull request as ready for review September 1, 2026 15:47
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 7d3b1b7Sep 1, 2026
34 checks passed
@os-steve
os-steve deleted the claude/issue-14184-trycatch-returned-failure-steps branch September 1, 2026 16:13
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

try_catch with no catch region discards its try-region step record on the returned-failure path (the other half of the engine's childSteps asymmetry)

2 participants

@os-steve@claude