Skip to content

feat(spec)!: a structured region body refuses a pause-capable node and an 'end' node - #18688

Merged
os-litant merged 7 commits into
mainfrom
claude/issue-15646-region-pause-end-refusal
Sep 18, 2026
Merged

os-litant merged 7 commits into
mainfrom
claude/issue-15646-region-pause-end-refusal

Conversation

@os-litant

@os-litant os-litant commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

Part of #15646

Clause-②: yes

The flow accept set shrinks for five node types inside region bodies — shapes the runtime never honoured. Ruling D clause 4 states it verbatim.

⚠️ The runtime half is NOT in this PR. A region-contained node that durably suspends must fail the run with a named error — only the run can know that — and ruling D assigns it to a separate domain:services card. ⇒ this PR lands with Part of, ⛔ not Fixes; #15646 stays open until the runtime half lands.


⚠️ SEAT BANNER — ruling D superseded the route this body argues for

Everything below the horizontal rule was written when the card was ruled C, and it argues for route A ("Recommendation: A, as implemented"). ⛔ That is no longer what this PR does. It is kept unedited as the record of how the decision was reached — ⛔ deleting it would erase the evidence the later ruling was made on.

What this PR does NOW, per ruling D (batch #153 item 1, comment 5724940095, maintainer 「其他同意」):

CI on 6de9d662f6df: 32 success, 3 skipped, 0 failure, 0 pending.

⛔ Two corrections the domain:spec seat owes on its own record

  1. ⭐ The seat ruled "keep the published name" on a premise that is FALSE. It told the round that renaming FLOW_PAUSE_CAPABLE_NODE_TYPES removes a published export and therefore forces a major. Measured since: main's packages/spec/api-surface/automation.json greps 0 for that name (lit controls FLOW_BUILTIN_NODE_TYPES and FLOW_STRUCTURAL_NODE_TYPES = 1 each; dark control = 0), and the branch greps 1. ⇒ the constant is introduced by this PR and is on no consumer's import path; the gate's 「1 breaking (removed)」 was computed against the branch's own earlier snapshot. The decision stands and costs nothing — a second name would be cost without benefit — but ⛔ the record must not carry 「a removed published export」 as a fact about consumers. The round measured this and told the seat; the seat re-measured and confirms it.
  2. ⛔ THIS CORRECTION WAS ITSELF WRONG, and the seat withdraws it. It claimed --pair 18688 re-measured 「exit 0」 at this head. That reading came from a STALE INSTRUMENT. The shared checkout's check-clause2-carriers.mjs is blob ccd5ad7c9a00 and contains 0 occurrences of rule C8; origin/main's and this head's is blob 3a270ef2eb5f and contains 18 (lit control C1: 50 vs 51, so the reader works). C8 landed on main at 01:41Z via fix(pm): a SECOND Claim: by one seat is NAMED, not ranked as a supersession #18859 and the shared checkout never had it. ⇒ every --pair reading this seat took today was taken with a script that cannot see C8. Re-taken with origin/main's script: feat(spec)!: a structured region body refuses a pause-capable node and an 'end' node #18688 exit 4 on C8 — this seat held two live Claim: comments on service-automation: a PAUSING map inside a contained region leaves its progress state behind — later loop iterations skip items and the exhausted map returns success having run nothing #15646 (5722016855, 5728277407), which the protocol forbids. Repaired as C8 prescribes: Release: (5729634742) then ONE fresh Claim: (5729639847). --pair 18688 now exits 0 — claim.selected 1, claim.rejected 2. The at-tier review caught this; the seat re-measured and confirms it.

Route C, as ruled. Director seat, summon #24, batch #145 item 5 — #15646 (comment) (maintainer 「同意,其他也同意」), with the batch #146 scope addition — #15646 (comment) (maintainer 「146 同意」), which attached #3267's 禁 ruling and absorbed #18112 into this card. One PR, one changeset, two refusals in one rule family.

🛑 Read this first — this PR is NOT ready to land, and the reason is a measured decision, not a bug

packages/spec is green end to end. 5 tests in 3 packages/services/service-automation files now fail, and every one of them fails for the same reason: the fixture can no longer be REGISTERED, because AutomationEngine.registerFlow parses through FlowSchema.parse (engine.ts:3941) and this rule refuses the shape.

⚠️ Corrected by the domain:spec seat after a classification round — the table below replaces one that named 5 tests in 3 files. That earlier count was taken by running three named files; CI runs pnpm --filter @objectstack/service-automation test, the whole package, and a named-file subset cannot see this class of breakage. os-dev.md:56 reserves this body to the PR-open write, so the round named the wording and the seat writes it.

File Failing Card What it pins
src/builtin/contained-failure-rollup.test.ts 7 #16314 the contained-failure rollup fold over loop { subflow } — ⚠️ absent from the earlier table entirely; it predates this branch's base (git merge-base --is-ancestor exit 0), so this is a measurement gap, ⛔ not drift
src/builtin/map-in-loop-iteration-state.test.ts 3 #15616 loop { body: [ map, probe ] } over a non-pausing child: 5 iterations x 2 items ⇒ 10 child runs, failed = 0 either way, a fresh result set per iteration
src/builtin/contained-failure-visibility.test.ts 1 #14456 a parent run's row identity does not leak into a subflow child; the region shape is the vehicle, not the subject
src/end-node-refused-outcome.test.ts 0 (was 1) #15788 ⭐ fixed on this branch — see below

Measured with the package suite: at e10b395cee, 12 failed / 1627 passed (1639) across 4 files. After the fix below, at 87973cab8d1: 11 failed / 1628 passed.

⭐ One of the twelve was never blocked on the open question, and it is repaired here. #15788's region-end case sits in both candidate populations — this body defines route B as the unconditionally pausing types plus end — so no answer to the question below moves it. It is re-homed to the registration refusal: the fixture is unchanged byte for byte, and the case now asserts the ZodError's located path, its message and prescription, and that nothing registered. ⛔ Not a deletion — it fails again the day the shape becomes declarable.

The 11 are mutually exclusive with route A, and that is measured rather than argued. Ablating FLOW_PAUSE_CAPABLE_NODE_TYPES to route B's definition turns all 11 green with nothing else moving; route A on the same four files is 11 red. ⚠️ Method note that is load-bearing: service-automation resolves @objectstack/spec through dist, so the ablation was rebuilt and verified present in 18 built artifacts before anything was read — an unrebuilt ablation would have gone green and proved nothing. Restored afterwards, verified absent from all 216 artifacts, whole-tree porcelain empty.

⭐ The 11 are NOT one cost. 3 of them (#15616) are free: under route A the shape becomes undeclarable, so the defect is unreachable and the regression suite converts to a refusal pin — mechanically, the same conversion performed above for #15788; that file's second describe (a TOP-LEVEL pausing map) is untouched and green, so the durable-pause half keeps its coverage. The other 8 (#16314, #14456) are a genuine re-home onto a top-level delegating node, and loop { subflow } over five rows with one failing is the shape #15617's ruling named, so any re-home must record that the measurement no longer runs on it.

⛔ Not repaired here. The dispatch fences packages/services ("the engine's runtime refusal stays exactly as it is") — ⚠️ and note precisely what that fence claims: it is true of the diff, which touches no packages/services file. Read as a claim about effect it is false, because the parse refusal changes what those suites can register. The changeset carries the same correction, and two of these three are other cards' regression suites: deleting or re-homing #15616's and #15788's coverage is a decision, not a fixture edit. Two of them are also evidence about the rule itself, which is the open question below.

The open question: does the narrowing take a shape that WORKS with it?

The ruling's population is "a node that can durably pause (map / subflow with a pausing child, approval-class nodes)". Measured: map and subflow pause exactly when the child flow they NAME pauses — a different metadata record — so "with a pausing child" is not decidable at parse. Only two spellings are:

There is no third reading available to a parse. Recommendation: A, as implemented — #3267 is ruled 禁 ("structured regions do not support durable pause"), and a shape whose legality lives in a record the author is not editing, revocable by editing that record, is not a contract. Under A the five tests are re-homed (a top-level map, a top-level end) or retired with a statement, in this PR or a follow-up, once the seat says the coverage may move.

Step Zero — the ruling's precondition, answered before any code was written

First step, before writing: prove the nesting is statically decidable at parse/validate time.

Answer: YES for the nesting and for the node vocabulary this rule judges, with two boundaries that are declared rather than discovered. What was measured, on this branch's base 7f7b8557df:

  1. The nesting is decidable, and a refusing layer already exists. collectFlowGraphs (packages/spec/src/automation/control-flow.zod.ts) yields the top-level graph plus every region body, depth first, with a scope label and a path that anchors a Zod issue where the author wrote the node. FlowSchema's superRefine already walks exactly that and refuses on it — the Decision: do a flow's top-level nodes[] and its region bodies (loop / try_catch / parallel) share ONE node-id space, or two? — uniqueness is now enforced inside each, never across #16134 one-node-id-space rule. The PM seat's clue held: there is no refusing layer for this shape, but the walk and the refusal machinery are both live and in the same file.
  2. The pausing vocabulary is statically declared for the built-in set. Derived by reading the shipped defineActionDescriptor literals, not by recall: supportsPause: true appears on screen / wait / subflow / map (packages/services/service-automation/src/builtin/) and approval / approval_revise (packages/plugins/plugin-approvals/src/) — six, the same six the ADR-0044 resumeAuthority default-flip migration entry names in its own prose. They are published here as FLOW_PAUSE_CAPABLE_NODE_TYPES.
  3. end is fully static — FLOW_STRUCTURAL_NODE_TYPES, a node type the engine handles with no executor at all.

What is NOT decidable, and what this rule does about it. Whether a given node will pause is not decidable at parse, in two different ways, and both are stated in the docblock, in the changeset and in the ADR-0087 entry:

  • map / subflow pause exactly when the child flow they NAME pauses (map.config.flowName, an opaque reference to another metadata record). So the rule judges the node TYPE, not the run. That is wider than the runs that actually broke — a region-nested map over a synchronous child parsed green before and is refused now — and it is deliberate: the old shape's legality lived in a record the author is not editing and could be revoked by editing that record. "Legal until somebody adds a wait to the child flow" is not a contract.
  • A plugin-registered pausing type is invisible to a parse. ADR-0018 left the node-type namespace open (FlowNodeSchema.type is a validated string), and a parse has no registry. Pinned as a boundary test so it moves deliberately.
  • MAX_REGION_DEPTH (32). The walk stops there. ⚠️ Unlike Decision: do a flow's top-level nodes[] and its region bodies (loop / try_catch / parallel) share ONE node-id space, or two? — uniqueness is now enforced inside each, never across #16134's duplicate-id rule, there is no second spec refusal behind the ceiling for this rule — analyzeRegion says nothing about pausing nodes — so past depth 32 the engine's run-time refusal is the only one. Measured and pinned at nesting 32 (refused) / 33 (not judged), and stated in the changeset rather than left for an author to find.

What changed

FlowSchema.superRefine gains one walk over collectFlowGraphs, skipping the flow's own graph, that raises a custom issue anchored at [...regionPath, 'nodes', i, 'type'] for:

FLOW_PAUSE_CAPABLE_NODE_TYPES is the new export (api-surface / export-origins regenerated). The two approval entries are the declared constants APPROVAL_NODE_TYPE / APPROVAL_REVISE_NODE_TYPE, so a rename cannot desynchronise them.

⛔ packages/services is untouched — this is authoring-time enforcement only. ⛔ No engine rollback seam (route A, no card filed, per the ruling). ⛔ No runtime detection in map (route B, refused). ⛔ #15617's failed fold is not addressed.

Tests

New file packages/spec/src/automation/flow-region-pause-and-end.test.ts — every case fails without the rule:

  • both refusals × all three region kinds: loop body, try_catch try and catch, parallel branch. A rule covering loop only is route B wearing C's clothes; the try_catch catch arm and the parallel branch arm are the two route B could never see, and each has its own case.
  • all six pause-capable types, table-driven off the exported constant.
  • the card's own reproduction, loop { try_catch { map } }, refused with the chained region path.
  • negative tests, the over-reach guard: every pause-capable type and an end still parse on the top-level graph; every non-pausing type still parses inside a region; a node merely named end or wait in a region still parses (the rule judges type, not id).
  • both declared boundaries pinned: the plugin-contributed pausing type, and the depth-32/33 seam.
  • defineFlow and formatZodError renderings.

Two existing cases pinned the behaviour this rule replaces and were replaced rather than re-spelled, each saying so in its own comment: end-node-outcome.test.ts's region-nested end (its subject — an end-in-region whose config is judged one door later — no longer exists) and flow.test.ts's BPMN waitEventConfig region case (now asserts the earlier refusal and keeps the region-contract half it actually exists to measure). The requireTypeScopedConfig docblock that asserted a nested block-less wait parses green was corrected in the same edit.

Verification

Measured on e10b395cee. Heavy runs go through scripts/pm/os-verify-lock.sh; every exit code below is read from the wrapper's own VERDICT command-exit line or captured into a variable before any pipe — never $? after one.

Command Verdict
pnpm --filter @objectstack/spec build command-exit 0
pnpm --filter @objectstack/spec test (whole package) command-exit 0 — 486 files, 13895 tests, 0 failed
pnpm --filter @objectstack/spec typecheck (tsc --noEmit + check:scripts-typecheck + check:test-typecheck) command-exit 0
pnpm --filter @objectstack/spec check:generated command-exit 0 — all 15 artifacts up to date
pnpm lint (whole repo, eslint . --no-inline-config) exit 0 — run in full, so nothing here is a narrowing
dispatch-gates.mjs --ran reconciliation exit 0 — 85 derived, 81 run, 4 NOT-MEASURED, 0 UNRUN
@objectstack/service-automation — the 3 files whose fixtures feed this rule exit 1 — 5 failed / 24 passed, see the section at the top

Reverse verification (one-shot, restored). The rule's own early-exit was mutated (graph.path.length === 0 → >= 0), and the mutation was proved on disk before anything was read from the run — anchor grep 1 → 0, marker grep 0 → 1, blob 044bbbba → 56a1c350:

  • ablated — flow-region-pause-and-end.test.ts: 20 failed / 7 passed. The 20 are exactly the refusal assertions; the 7 that survive are the over-reach guards and the two boundary pins, which must stay green with the rule absent. That split is itself the reading: a rule that also broke the negative cases would be refusing too much.
  • restored — git checkout HEAD -- the file, blob back to 044bbbba, git diff HEAD clean, git status empty, same file 27/27 passed.

No dist preflight applies: the test imports ./flow.zod by relative source path, so the subject never resolves through packages/spec/dist. A restore trap was armed for the whole window.

The four NOT-MEASURED gates are check:doc-formula-expressions, check:dual-build-cjs-loads, check:lean-entry-closure and check:type-check-debt — each exited 3, PREREQUISITE NOT MET, printing in its own words that nothing was measured. All four read built output across packages this diff does not touch and need a repo-wide build; CI's Build Core and Lint & Repo Gates are where they run. ⛔ Not green, not red — unrun.

Not measured, stated: CI convergence on this PR (the report is filed at the end of local verification); the branch has not been merged forward since 7f7b8557df, so main's newer commits are tested by CI and the queue rather than here.

Review-gate reading, not an action. scripts/pm/check-clause2-carriers.mjs --pair 18688 exits 4 on two rows, both belonging to the claiming seat and ⛔ neither touched here: C1 — card #15646 carries needs:contract-review while this PR does not (the gate is a dual carrier); C2 — no comment on the card's thread is a machine-legible claim comment (none has a first line beginning Claim: carrying the Clause-②: line), so the declaration limb has nothing to read. The declaration itself is at the top of this body and in the changeset.

Acceptance notes

Observations from this card's reading, recorded here and not filed — none is a reproducible defect, a contract violation, or an authoring trap:

🤖 Generated with Claude Code

https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho


Generated by Claude Code

…d an end node

An ADR-0031 region body runs synchronously inside the enclosing run, so it can
neither park that run on a durable pause nor terminate it. The engine already
refused both at run time, silently and after the executor had written its
progress state into the enclosing scope. FlowSchema now refuses the shapes at
parse, naming the node and the region.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 3 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/api-surface-declarations/automation.txt, packages/spec/api-surface/automation.json, packages/spec/export-origins/automation.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/flows.mdx (via FlowSchema (symbol, a top-level const))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via FlowSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-4.mdx (via FlowSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/api-surface-declarations/automation.txt, packages/spec/api-surface/automation.json, packages/spec/export-origins/automation.json) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 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 — 136 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 2767af8e8354511f9c82ce402b147ad12c512819 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 63832c83c862ea414b73672c6005d41ed26e6857 — the merge of head 6de9d662f6df5e38be9303647845704a357e6b50 into base 2767af8e8354511f9c82ce402b147ad12c512819, 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 63832c83c862ea414b73672c6005d41ed26e6857 && git checkout 63832c83c862ea414b73672c6005d41ed26e6857
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2767af8e8354511f9c82ce402b147ad12c512819 6de9d662f6df5e38be9303647845704a357e6b50 && git checkout -B drift-repro 2767af8e8354511f9c82ce402b147ad12c512819 && git merge --no-ff 6de9d662f6df5e38be9303647845704a357e6b50

node scripts/docs-audit/affected-docs.mjs --json 2767af8e8354511f9c82ce402b147ad12c512819

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

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 17, 2026
…arse refusal

`registerFlow` parses through `FlowSchema` (`canonicalizeStoredFlow`), so the
region-nested refusing `end` this case registered can no longer be registered
at all — the refusal it asserted at the region boundary is now met one door
earlier, at load. The fixture is unchanged and the case still fails the day the
shape becomes declarable again; what it no longer covers (`runRegion`'s
`isRefusalSignal` arm, now reachable only past `MAX_REGION_DEPTH`) is stated in
the docblock rather than left to be discovered.

The changeset's scope line said `packages/services` is untouched and the
run-time refusal stays exactly as it was. Measured: registration and the
ADR-0087 stored-row rehydration seam both parse, so a stored row carrying a
refused shape stops loading, and the `end` arm's run-time refusal has no other
caller. Corrected in place.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
… types

Ruling batch #153 item 1, letter D: inside `loop` / `parallel` branch /
`try_catch` bodies at any depth the refused population is `screen`, `wait`,
`approval`, `approval_revise` and `end`. `map` and `subflow` are not refused
by type — they pause exactly when the child flow `config.flowName` names
pauses, a record this parse does not hold, so a type-keyed refusal would also
refuse `loop { map(synchronous child) }`, a shape that runs correctly.

`FLOW_PAUSE_CAPABLE_NODE_TYPES` (unreleased, added on this branch) becomes
`FLOW_UNCONDITIONAL_PAUSE_NODE_TYPES` so the exported name states the
population the rule keys on rather than a capability list two of whose
members it does not judge.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
Seat decision on the conflict the rename surfaced: ruling D orders a `minor`
for `@objectstack/spec`, and `check:api-surface` grades a removed export
breaking, so the rename and the ruling cannot both stand. The ruling asks for
a change to the refused POPULATION, not to the export's name.

`FLOW_PAUSE_CAPABLE_NODE_TYPES` keeps its identifier and its place in
`api-surface/automation.json`; only its contents narrow to the four types that
pause unconditionally. The docblock now leads with "read the contents, not the
name" and states why the name is kept, so the mismatch is declared rather than
discovered.

`src/migrations/registry.ts` is regenerated from the edited ADR-0087 entry.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
The declaration-text gate (check:api-surface-declarations) and its shards
landed on main after this branch point, so the gate could not be run here
at all. Merging brings it in; the shard regeneration is judged separately.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
`check:api-surface-declarations` landed on main after this branch point; the
merge brings it in. Its delta on this branch is 0 removed / 1 added /
2 reshaped, and all three are non-narrowing:

  + FLOW_PAUSE_CAPABLE_NODE_TYPES — introduced by this PR; it is on neither
    main's api-surface nor main's declaration shard, so "added" is accurate.
  ~ ApprovalDecision, ApprovalNodeConfigSchema — property ORDER inside their
    `z.ZodEnum<{...}>` type literals, same members, same literal values.
    Object type members are order-insensitive in TypeScript, so old and new
    are mutually assignable; proved with a two-direction assignability probe
    plus a `@ts-expect-error` negative control, tsc exit 0.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

Contract review — PR #18688

Served-tier: CONTRACT_REVIEW_TIER
Implemented-by: claude/issue-15646-region-pause-end-refusal (mode:subagent)
Reviewed-by: session_01LvwGppdonww4zGLWZo5rho (adopting seat; isolated contract-review subagent, no session of its own)

6de9d662f6df5e38be9303647845704a357e6b50

Reviewed against ruling D (comment 5724940095, maintainer 「其他同意」) and the code at the head above — ⛔ not against the route-A text below the PR body's SEAT BANNER. Every number below is my own instrument: a detached worktree at the head under <scratchpad>/pr-18688/wt (shallow clone deepened; main fetched into an owned ref: tip 43f47668, PR base = merge-base 2767af8e, 1 commit behind tip); exit codes captured before any pipe; heavy runs through scripts/pm/os-verify-lock.sh (VERDICT lines read, never $?). Nothing from the round's reports was adopted.

① Derived judgments — the accept set and the public surface, each named and judged

  1. Accept set narrows, to exactly ruling D's population — CORRECT. FlowSchema.superRefine walks collectFlowGraphs, which enumerates every declared region slot (FLOW_REGION_SLOTS: loop.body, parallel.branches[], try_catch.try, try_catch.catch) recursively to MAX_REGION_DEPTH (32), skips the flow's own graph (graph.path.length === 0), and refuses type === 'end' on its own branch and FLOW_PAUSE_CAPABLE_NODE_TYPES.includes(type) with the constant reading ['screen', 'wait', APPROVAL_NODE_TYPE, APPROVAL_REVISE_NODE_TYPE] (approval.zod.ts:342/371 = 'approval' / 'approval_revise'). Issue anchored at [...regionPath, 'nodes', i, 'type'], message names node and region scope. Matches clause 1 letter for letter.
  2. map / subflow not refused by type — CORRECT and pinned. Four pins: the constant equals the four; excludes subflow/map; a map and a subflow in a loop body parse green; the card's own reproduction loop { try_catch { map } } parses green. Ablation (mine): adding 'map' to the constant (1 replacement, verified on disk) turns exactly those 4 red — 4 failed | 26 passed — and nothing else moves; restored blob 12360e7a… = HEAD blob; porcelain 0.
  3. The rule is load-bearing and does not over-reach. Disabling it (=== 0 → >= 0, anchored on the rule's own comment, 1 replacement): 18 failed | 11 passed — every refusal assertion red (all 4 types × loop, try, catch, parallel branch, chained path, end × 4 kinds, depth-32 seam), every over-reach guard and boundary pin green. Baseline and restored: 29 passed (29).
  4. Boundaries declared, not discovered: plugin-registered pausing types (ADR-0018 open namespace) and nesting > 32 are not refused; both pinned, both stated in the changeset and the ledger entry.
  5. Public surface: +1 export, 0 removed. FLOW_PAUSE_CAPABLE_NODE_TYPES (const): readonly string[] on @objectstack/spec/automation; api-surface/, export-origins/, api-surface-declarations/ regenerated. check:generated exit 0: 「✓ All 16 generated artifacts are up to date」, with check:api-surface, check:api-surface-declarations, check:migration-registry, check:spec-changes, check:upgrade-guide each ✓. main has not moved these artifact directories since the PR base (diff empty), so no regen conflict is pending.
  6. The 2 "reshaped" declarations are member-ORDER only — proven, and the round's Arm A probe judged. ApprovalDecision (reject/approve → approve/reject) and ApprovalNodeConfigSchema.onEmptyApprovers (fail/fallback → fallback/fail). My probe (tsc 6.0.3, --strict, against the real built dist/automation/index.d.mts, zod resolved from the worktree): OLD↔NEW mutually assignable; real dist value ↔ main's declaration both directions; output level 'approve'/'reject' ok. Negative controls at the DECLARATION level all errored: NARROW → OLD, OLD → WIDE, 'maybe' on the output type, OLD_OEA → OEA_WIDE; a fires-control (one directive removed) exits non-zero naming the line. Exit 0. One measured subtlety the round's write-up does not carry: WIDE → OLD compiles (TypeScript's variance reading of ZodEnum<T> — property-level options/_zod.output do reject 'maybe', the generic reference does not), so a one-direction "new assigned to old" probe would be blind to widening. The round's probe ran both directions, and that identity is discriminating in both (narrowing fails one way, widening the other), so it establishes what it claims; its own 'maybe' control is an output-level control, weaker than it sounds, and does not test the declaration instrument — the two-direction identity does.
  7. Load-seam consequence, declared: registerFlow and the ADR-0087 stored-row rehydration seam parse through FlowSchema, so a stored row carrying a refused shape stops loading until rewritten — a D3 structured TODO (hoisting a node out of a region is a graph rewrite; not losslessly convertible). Stated in the changeset and in the entry's acceptanceCriteria.
  8. No accepted ADR is contradicted. ADR-0031 already lists 「Durable pause inside a parallel branch or loop iteration」 under Non-goals / deferred; the rule is that non-goal's authoring-time enforcement under [P2] engine ADR: durable pause inside structured regions (unlock topology-level parallel approvals / waits / subflows) #3267's 禁.

Claims 1–8 from the brief — verified with my own runs

  • build exit 0 (.build-input-hash / -dts stamped); check:generated exit 0, 16/16; typecheck exit 0 (tsc --noEmit + check:scripts-typecheck + check:test-typecheck, 54 files / 259 errors / 144 pinned signatures held).
  • spec test: Test Files 490 passed (490) · Tests 14238 passed (14238), exit 0 (local project; 521 test files on disk under src+scripts, local = that superset minus REPO_TESTS).
  • service-automation, whole package: Test Files 138 passed (138) · Tests 1652 passed (1652), exit 0. Whole-package control: 138 *.test.ts on disk (excluding node_modules/dist) = 138 ran. ⚠️ My first attempt, with only spec built, was 135 failed | 3 passed at import (Failed to resolve entry for package "@objectstack/core") — an environment reading, corrected by building '@objectstack/service-automation^...' with spec excluded; recorded so that number is never read as a PR reading.
  • ⭐ False-green control reproduced: from the package dir, require.resolve('@objectstack/spec/automation') → packages/spec/dist/automation/index.js; the artifact exports FLOW_PAUSE_CAPABLE_NODE_TYPES = ["screen","wait","approval","approval_revise"], FLOW_STRUCTURAL_NODE_TYPES = ["start","end"] (the not-constant control), FLOW_BUILTIN_NODE_TYPES.length = 20; exports['./automation'] maps import → index.mjs (the condition vitest takes) and require → index.js; both artifacts carry the rule text may not sit inside a structured region 2× (dark control 0), mtime 11:38:35Z = this session's build; vitest.config.ts aliases only platform-objects and types. Independently, the re-homed service-automation: honour outcome: 'refused' on the flow end node — a terminal refused run status (distinct from failed) with the interpolated message persisted on the run (lane 2 of the #14945 ruling 2′) #15788 case asserting a ZodError from registerFlow can only pass on a dist that carries the end refusal.
  • The files ruling D protects, by name (verbose run, 4 files / 45 passed): map-in-loop-iteration-state 4 ✓ (service-automation: a map node inside a loop body runs its collection ONCE — iterations 2..n do nothing, report success, and the run completes green #15616's three + the durable-pause half), contained-failure-rollup 16 ✓, contained-failure-visibility 13 ✓, end-node-refused-outcome 12 ✓ incl. 「a refusing end inside a loop body is refused at REGISTRATION, before any run」.
  • Paths per commit: a6822be, e10b395, 247845c, 48f2065, 6de9d66 touch only .changeset/ and packages/spec/**; 87973cab8d1 (06:19Z, the earlier round) is the sole commit touching packages/services/service-automation/src/end-node-refused-outcome.test.ts; the merge 1b793d0 brings 408 main paths and nothing of its own. Claim 7 is confirmed as stated (zero services paths in this round's commits) — the cumulative diff nevertheless carries one services path; see flag 1.
  • No test deleted, skipped or quarantined: case counts unchanged in every modified test file (end-node-outcome 21→21, flow.test 127→127, end-node-refused-outcome 12→12), new file +29; added lines grep for .skip(|.todo(|.only(|xit( = 0, with a repo-wide control that hits (1 file).
  • Gates: check-changeset-no-major --base main --event <REST PR payload> exit 0 — no major; LEVEL AXIS ✓ 「declares clause-② yes… carrier needs:contract-review IS on this PR… direction arm: none declared」. check-adr-0087-registration exit 0 — [BREAKING] registered structured-region-body-pause-and-end-refused.
  • CI at this head: 0 failure; the seven required contexts all success; 5 skipped (Auto Label and Check PR Size on the 11:31 re-run, Build Docs, Console Pin Gate, Packed-tarball smoke). PR body first line Part of #15646, draft, needs:contract-review on. Runtime sibling card exists: service-automation: a durable suspension inside a structured region (loop / parallel branch / try_catch) must fail the run with a named error, not leave progress state and report success — runtime half of #15646 ruling D #18881 (open, pm:queue, domain:services, p1), so Part of does not strand the card.

⭐ The seat's correction — verified independently: the seat is right.

FLOW_PAUSE_CAPABLE_NODE_TYPES greps 0 on main tip 43f47668 and on the PR base 2767af8e across api-surface/automation.json, api-surface-declarations/automation.txt and src/; lit controls FLOW_BUILTIN_NODE_TYPES and FLOW_STRUCTURAL_NODE_TYPES hit on all three refs; dark control 0. Beyond main: the published npm @objectstack/spec@17.4.0 tarball greps 0 for the name (controls: 6 files each). The gate's 「1 breaking (removed)」 was computed at commit 247845c, where src carried the renamed FLOW_UNCONDITIONAL_PAUSE_NODE_TYPES (4 hits) while the checked-in api-surface/automation.json still carried the old name (1 hit) — the branch's own earlier snapshot, not anything a consumer can import. Keeping the name costs nothing; renaming would have cost nothing either. Residue: the shipped docblock still argues the keep from the false premise (「the identifier is in api-surface/automation.json, and check:api-surface grades a removed export breaking — trading a whole-stack major for a better name…」). Post-merge that warning becomes true for its readers, so this is a record inaccuracy, not a contract one (flag 4).

② Clause ② and semver

  • Clause-②: yes, no arm — on the PR body, the changeset and the governing claim, matching ruling D clause 4 verbatim. AGENTS.md:1067-1068 makes the arm optional (「plus at most one arm」) and yes takes ≥ minor ✓. The narrowing IS breaking; during the launch window the bump level is deliberately not the carrier — check-changeset-no-major's own header: 「During the launch window we ship breaking changes as minor… The mandatory information carriers for breaking-ness in the meantime are the BREAKING banner… and the ADR-0087 migration-ledger disposition」. Both carriers are present and both gates are green. The round's reading is correct.
  • Semver: '@objectstack/spec': minor — correctly declared under that convention; the changeset carries the FROM → TO table and the one-line fix (「move the node onto the top-level graph and route the region's exit to it」); ledger entry 18.structured-region-body-pause-and-end-refused.ts present, registry.ts regenerated. No authorable KEY is removed, so no tombstone is owed; the refusal message itself carries the prescription.

③ Boundary flags — each with why it does not block

  1. One packages/services path is in the PR diff (end-node-refused-outcome.test.ts, +57/−35) while ruling D clause 1 says 「packages/services untouched; the 5 tests and service-automation: a map node inside a loop body runs its collection ONCE — iterations 2..n do nothing, report success, and the run completes green #15616's suite stand」 — and service-automation: honour outcome: 'refused' on the flow end node — a terminal refused run status (distinct from failed) with the interpolated message persisted on the run (lane 2 of the #14945 ruling 2′) #15788's region-end case WAS one of those 5 (first report: 「1 being service-automation: honour outcome: 'refused' on the flow end node — a terminal refused run status (distinct from failed) with the interpolated message persisted on the run (lane 2 of the #14945 ruling 2′) #15788's run-time refusing-end test」). Not blocking because the ruling's own clause 1 orders end refused at parse and registerFlow parses, so the old run-time assertion is unreachable — the ruling's premise 「B breaks nothing」 was false for this one case; the fixture is byte-identical, 12→12 cases, the assertion is strengthened (region path, message, nothing registered) and fails again the day the shape becomes declarable; no engine source moves; service-automation: a map node inside a loop body runs its collection ONCE — iterations 2..n do nothing, report success, and the run completes green #15616's suite and the other three files are untouched and green. It is a declared deviation from the ruling's letter and the seat's hand-off comment must say so — the PR body's 「untouched by this round — zero paths」 is true of the round, not of the PR diff.
  2. check-clause2-carriers --pair 18688 at this head: exit 4, row C8 — two LIVE Claim: comments by os-litant (5722016855, 5728277407), the second written under an un-retracted first. PR body correction ✨ Set up Copilot instructions #2 reports 「re-measured at this head: exit 0」; that reading came from a stale instrument: the shared checkout's script (blob ccd5ad7c9a00, claude/laughing-dirac-5cgty2 @ 55523fdee7a) has no C8, while C8 landed on main at 01:41Z (e8ba892a1b8, fix(pm): a SECOND Claim: by one seat is NAMED, not ranked as a supersession #18859), is an ancestor of the PR base, and the head's own script (blob 3a270ef2eb5f, identical on base, tip and head) carries it. Not a contract finding, but it IS the pre-arm predicate (--pair must read 0 before the gate label is cleared), so the seat repairs it before arming — Clause-②-correction: <claim id> or Release: then ONE fresh Claim: — ⛔ never a label written from the checker.
  3. Declared non-reach: plugin-registered pausing types and nesting > 32 are not refused, and the card's own reproduction stays declarable by design — closed by service-automation: a durable suspension inside a structured region (loop / parallel branch / try_catch) must fail the run with a named error, not leave progress state and report success — runtime half of #15646 ruling D #18881's runtime half, not here. Not blocking: ruling D clause 2 assigns it, and the card stays open under Part of.
  4. Prose residue: the docblock premise above, and the refusal message's hard-coded (screen / wait / approval / approval_revise) duplicating the constant. Pinned by tests; prose only; not blocking.
  5. Stored rows carrying a refused shape stop loading (D3 TODO). Declared in the changeset and the entry's acceptanceCriteria; the ruling calls these 「shapes the runtime never honoured」. Not blocking.

NOT MEASURED

  • pnpm lint not re-run locally — CI Lint & Repo Gates success is the reading.
  • spec's cross-package repo vitest project (only local, as the package script runs it); check:react-declaration-parity (needs MANIFEST; runs in lint.yml, green there).
  • The pinned objectui sibling: whether any Studio fixture authors end/wait/approval inside a region (Console Pin Gate skipped by paths filter; nothing is removed, so the AGENTS.md sibling-import rule does not apply).
  • Whether [P2] engine ADR: durable pause inside structured regions (unlock topology-level parallel approvals / waits / subflows) #3267's 禁 owes an ADR-0031/ADR-0039 status amendment — outside this PR.
  • CI job logs were not re-read; check-run conclusions were read from the API at this head.
  • In-repo docs/skills/examples authoring the refused shape: heuristic scan only (967 files; 2 candidates, both false positives — top-level end nodes); the real controls are the Dogfood gates and check:skill-examples, green in CI.
  • The round's dispatch-gates --ran reconciliation was not reproduced.

Scratch (all of it under /tmp/claude-0/-home-user/d31c56ec-952d-5a16-945f-6e430b9a6d34/scratchpad/pr-18688/): build.log, check-generated.log, typecheck.log, spec-test.log, svc-test.log, svc-protected.log, abl-{baseline,mutA,mutB,restored}.log, probe.log, probe-fires.log, no-major-event.log, adr0087.log, pair.log, event.json. The worktree and the owned refs are removed; the shared checkout was never edited (porcelain 0 throughout).

PASS — 6de9d662f6df5e38be9303647845704a357e6b50 · Clause-②: yes (no arm) · @objectstack/spec minor, BREAKING banner + ADR-0087 registered carried · flags 1–5 non-blocking; flag 2 (--pair exit 4, C8) is the seat's repair before arming, and flag 1 is a deviation from ruling D's letter the hand-off comment must state.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

✅ 达档合约复核 PASS —— 落地 head 6de9d662f6df5e38be9303647845704a357e6b50

domain:spec 席位,session_01LvwGppdonww4zGLWZo5rho,2026-09-18T11:59Z。记录原文见紧接本评论之前的那一条(5729652447),⛔ 本席逐字采纳、未编辑一字。拆两条的原因同前:记录正文带它自己读到的裸时间戳,与本评论的 {{NOW}} 同处一体会被 post-stamped 判 MIXED 并拒写。

档位核验(本席自取)

复核轮抄本逐条消息的 harness 盖章 message.model:claude-fable-5-1 158 / 158,off-tier 0。

它真的去消融了,两个方向

消融 读数
把 'map' 加进常量 恰好 4 条红(map/subflow 的排除钉),其余一条不动;还原后 blob 与 HEAD 相同,porcelain 0
把规则本身停掉(=== 0 → >= 0) 18 红 / 11 绿 —— 红的是全部拒绝断言,绿的恰是过度拒绝护栏与边界钉

⇒ 规则可证伪,且不过度拒绝。基线与还原都是 29 passed (29)。

⭐ 它还复现了本席点名的假绿对照:从 service-automation 目录 require.resolve('@objectstack/spec/automation') 落在 dist/ 上,产物读出 ["screen","wait","approval","approval_revise"],而未动的兄弟常量 FLOW_STRUCTURAL_NODE_TYPES 仍读 ["start","end"](证明这个读数不是恒定值),mtime 是本次构建。⇒ 这条绿不是陈旧 dist 给的假绿。

⛔ 本席被逮到两条,两条都实测复验、当场认下

① 本席用了一个过期的仪器,而且这正是本席今天警告过别人两次的坑。

本席多次报 --pair 18688 exit 0,还据此在正文里写「不 actionable」。实测:

脚本 blob 含 C8
共享检出(本席一直在跑的那个) ccd5ad7c9a00 0
origin/main 与本 PR head 3a270ef2eb5f 18

亮对照 C1 两边 50 / 51 ⇒ 读法没问题,差的是脚本。C8 规则 01:41Z 随 #18859 落在 main 上,本席的共享检出停在自己的开发分支上,从来没拿到过。

⇒ 本席今天所有 --pair 读数都是无 C8 的脚本取的。 已用 main 的脚本全部重取:#18688 = exit 4、#18638 = 0、#18890 = 0。⇒ 真有问题的只有本卡,但纪律问题是系统性的。

C8 说的是:本席在本卡上持有两条存活的 Claim:,而协议禁止第二条 Claim:。已按 C8 点名的修法修:Release:(5729634742)+ 唯一一条新 Claim:(5729639847)⇒ --pair 18688 现在 exit 0,claim.selected 1、claim.rejected 2。

② 「packages/services 零路径」只对本轮成立,对 PR 累计 diff 不成立 —— 而且这是一条对裁决字面的偏离。

裁决 D 第 1 条写「packages/services untouched;那 5 条测试与 #15616 的套件照旧」,而 #15788 那条 region-end 用例正是那 5 条之一。⇒ 裁决那句「B 什么都不破坏」对这一条不成立:第 1 条自己要求 end 在解析期被拒,而 registerFlow 走解析 ⇒ 旧的运行期断言按构造不可达。

处理:夹具逐字未动,用例数 12 → 12,断言加强(区域路径、报文、且断言什么都没注册)。⛔ 没删、没跳过、没隔离,⛔ 引擎源码未动。复核测全了并判不挡 —— 但它是偏离,写在明处,⛔ 不埋。

分级

Clause-②: yes(无臂),@objectstack/spec minor。收窄确实是 breaking,但发布窗口期版本号不是承载体 —— 闸门自己的表头这么写,而两个强制承载体(changeset 的 BREAKING 横幅、ADR-0087 处置)都在,两道闸都绿。

运行期兄弟卡 #18881 确实存在且开着 ⇒ Part of 不会把本卡晾在半空。


Generated by Claude Code

@os-litant
os-litant marked this pull request as ready for review September 18, 2026 12:00
@os-litant
os-litant added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit 7843663 Sep 18, 2026
54 checks passed
@os-litant
os-litant deleted the claude/issue-15646-region-pause-end-refusal branch September 18, 2026 12:36
os-litant pushed a commit that referenced this pull request Sep 18, 2026
…ed on the origin/main merge

The merge of origin/main (b146102) routed four paths to the os-regen
driver, which defers rather than text-merging: the three
api-surface-declarations shards automation.txt, data.txt and ui.txt, and the
generated reference page content/docs/references/ui/component.mdx. On every one
of them the driver kept this branch's side and silently dropped main's, so
step 2 of the sanctioned sequence restored main's side into the worktree and
this commit re-derives all four from the merged tree.

What each side contributed, now both present:

  ui.txt / component.mdx    main's element:text.variant widening to the
                            published nine (#19019), plus this branch's
                            evaluated-slot narrowing
  data.txt                  main's transport no-transactions declaration
                            (#18890) and the $orderby dual declaration
                            (#19018), plus this branch's `source: string`
                            narrowing on ConditionalValidationSchema and
                            CrossFieldValidationSchema
  automation.txt            main's structured-region pause/end refusal
                            (#18688), plus this branch's narrowing

The two MIXED, deliberately unrouted paths were hand-resolved by git's ordinary
three-way merge and verified rather than eyeballed: registry.ts's hand-written
remainder (generated regions stripped with the merge script's own awk) is
byte-identical across base, both sides and the merge, and its line count is
exactly additive (17142 + 121 + 74 = 17337), with both sides' migration entries
present by id. component.zod.ts is additive too (3750 + 4 + 45 = 3799) and its
single .superRefine() is untouched.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…fuses stops its parent, in `subflow` and in `map` alike (objectstack-ai#18706)

Part of objectstack-ai#18110
Part of objectstack-ai#18555

⛔ Deliberately `Part of`, not `Fixes`, for both: the ruling on objectstack-ai#18110
(director batch objectstack-ai#145 item 4, letter **A**, maintainer 「同意,其他也同意」) states
that the lane seat closes both cards at landing, after verifying the two
arms independently.

## The defect

A child flow that ends on an `end` node declaring `outcome: 'refused'`
rolled up to its parent as an **ordinary success**.

`subflow-node.ts` branched only on `child.status === 'paused'` and
`!child.success`. A refused child is neither — `finishRefusedRun`
answers `{ success: true, status: 'refused' }`, because *a refusal is a
successful evaluation that says no* — so it fell straight through the
ordinary success exit. The parent walked the node's out-edges, recorded
`completed`, and fired its **own** `successMessage` over the child's
refusal.

`map-node.ts` had the line-for-line identical branch set and the
identical missing arm (objectstack-ai#18555): a refusing row's output went into
`state.results` and every row after it was processed anyway.

This is fail-open in the direction nobody notices. A refusing `end` is
most often a gate — an approval, an eligibility check, a precondition —
and a gate whose "no" lets the run through finishes green.

## The change — one channel, two call sites

| | |
|---|---|
| **New on `NodeExecutionResult`** | `refuse?: boolean` and
`refusalMessage?: string`, beside the `suspend?: boolean` that already
exists for the pause half of the same unwinding protocol. |
| **`executeNode`** | throws `new FlowRefusalSignal(node.id,
result.refusalMessage)` **at the position the suspend signal is thrown**
— after the success step is pushed, after the `childSteps` fold, after
output write-back. |
| **`subflow-node.ts` / `map-node.ts`** | both gain the `refused` arm
that sets it. |
| **Region-boundary diagnostic** | generalised to name whichever node
carried the refusal. **Text only.** |

The throw **position** is the design, not a convenience: it is what
keeps the child's `selected` / `acted` / `unmeasuredEffect` rollup
(objectstack-ai#4354) in the run log and therefore in the run summary. A refusing
child really can have written rows before it said no, and an unwind that
began any earlier would drop exactly those counts. This is the property
option B was rejected for losing, and it is pinned in both test files.

⛔ A refusal is still **not** a failure: it does not consume retry
budget, is not routable by a `fault` edge, and is not counted in
`nodes[].failures`.

### What this change deliberately does not do

- **`packages/spec` untouched**, no new status value (`refused` has been
a published member of `TERMINAL_RUN_STATUSES` since objectstack-ai#15788), no
authorable edge semantics.
- **Region semantics untouched.** objectstack-ai#18112's option B is not implemented
and no container is taught to rethrow. Only the diagnostic's wording
changed, per objectstack-ai#18112's ruling, which assigns the runtime text to this PR
and the authoring-time half to objectstack-ai#15646 / PR objectstack-ai#18688.
- **Option B** (an unexported internal seam) and **option C** (defer)
are not taken.

## `Clause-②: yes (widening)`

`NodeExecutionResult` is barrel-exported from this package's single
entry point (`src/index.ts:9`), so two new optional members are a
widening of the published executor contract. Changeset:
`@objectstack/service-automation` **minor**. Contract review is owed on
the review, per the ruling. Additive for third parties: an executor that
never sets `refuse` behaves exactly as before.

## Premise re-verification (every position re-taken, ⛔ none inherited)

`engine.ts` moved the same day the ruling was written (`99fcb4a`,
2026-09-17T11:57:43Z), so every line number handed to me was stale.
Re-taken with `git show origin/main:PATH` at `1bc22b3`:

| reading | ruling / prior report said | re-taken | verdict |
|---|---|---|---|
| `NodeExecutionResult` | `:332` | `:332` | unchanged |
| `TERMINAL_RUN_STATUSES` | `:1365` | `:1365` | unchanged — `refused`
already published |
| the single `new FlowRefusalSignal` | `:9319` | **`:9352`** | moved,
shape intact, still the only site, still `node.type === 'end'` |
| the suspend throw | `:9611` | **`:9648`** | moved; still after the
success step, the `childSteps` fold and output write-back — the ruling's
prescribed position **exists as described** |
| the region boundary | `:9956` | **`:9991`** | moved |
| `NodeExecutionResult` barrel-exported | `src/index.ts:9` | confirmed |
clause ② `yes` holds |
| `map-node.ts` branch set | `:191` / `:207`, `refused` 0 hits |
confirmed, control `child` = 31 hits | identical hole |

`FlowRefusalSignal` is still not exported and this change does not
export it — callers still see `status: 'refused'`.

## Verification

- **Both arms pinned separately**, so deleting either one fails a test
by itself:
  - `src/builtin/subflow-refused-rollup.test.ts` — 6 tests
  - `src/builtin/map-refused-rollup.test.ts` — 7 tests
- **Ablation, both legs**, each proven on disk (HEAD blob hash vs
mutated blob hash, anchor counts before and after) and each restored
from `HEAD` with `git diff HEAD` empty:
- ablating **only** the `subflow` arm ⇒ 5 failed in
`subflow-refused-rollup.test.ts`, `map-refused-rollup.test.ts`
**entirely green**;
- ablating **only** the `map` arm ⇒ 6 failed in
`map-refused-rollup.test.ts`, `subflow-refused-rollup.test.ts`
**entirely green**.
- The ablation also corrected the pins: both `objectstack-ai#4354 rollup` legs were
originally green under ablation, because the totals are equally true of
the unfixed engine, which rolled the same metrics up and then carried
on. They now assert `status === 'refused'` in the same test.
- **Controls**, mandatory and green on both sides: a **non**-refused
child still completes the parent, fires the parent's `successMessage`,
walks its out-edges, and rolls up identical totals — on both the
`subflow` and the `map` path.
- `pnpm --filter @objectstack/service-automation test` — **138 files /
1652 tests passed**, re-run after merging `origin/main`.
- `pnpm --filter @objectstack/service-automation typecheck` — exit 0,
test layer included (0 files / 0 errors in the debt ledger).
- **Gate roster derived from merge-base, not from a predicted path
list**: `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` (no paths). 60 families derived,
re-derived after the merge with **zero delta**. Reconciled with `--ran`:
**60 accounted for, 58 run green, 2 NOT MEASURED**.
- `pnpm check:dual-build-cjs-loads` and `pnpm check:type-check-debt`
both exited **3 — `PREREQUISITE NOT MET`**, each printing "this is NOT a
pass: nothing was measured". Both require a whole-repo build; that is
CI's run, not this PR's. ⛔ Not recorded as a failed measurement.
- `node scripts/check-plugin-teardown-shape.mjs --self-test` first
exited 3 because its positive control is pinned to a commit outside this
shallow checkout; after `git fetch --deepen` it exits 0 with 48 cases.
- `pnpm lint` (`eslint . --no-inline-config`) — **full repo, exit 0**. ⛔
No narrowing claimed; the whole scan ran.

## Acceptance notes — found in passing, ⛔ not fixed here

1. **A child that PAUSES and then refuses on resume still rolls up as a
success, and worse, may strand its parent.** This PR closes the
synchronous path only. On the delegation path `resumeInternal` reads
`childRes.status === 'paused'`, then failure, then treats everything
else as "child completed" — a refused child (`success: true`) falls
through there exactly as it used to here. And on the up-bubble path the
refusal arm returns `finishRefusedRun` from the `catch` **before**
`bubbleToParent` is reached on the completion path, so a child that
refuses after a pause never wakes its parent at all. Read from source, ⛔
not driven. This is a separate mechanism (resume / bubble machinery),
outside the ruling's prescription, and it is reported for the seat to
file rather than ridden in. Dedupe words: `paused child refused
bubbleToParent` · `resumeInternal childRes status refused` · `subflow
delegation refused child` · `map re-entry mapItemDone refused` · `parent
stranded refused child`.
2. **Noted, not filed** — the definition of the `refused` run status
("the flow reached an `end` node declaring `outcome: 'refused'`") now
under-describes its producers. It is written in
`packages/services/service-automation/src/sys-automation-run.object.ts:178`
and, identically, in `packages/spec` (`src/automation/execution.zod.ts`,
`src/contracts/automation-service.ts`), with a pin test and a generated
reference page downstream. Correcting it needs a `packages/spec` edit,
which this card's ruling puts out of bounds (clause 5: that routes to
the `domain:spec` seat). Successor: the `domain:spec` seat already
working this boundary on objectstack-ai#15646 / PR objectstack-ai#18688.
3. Three hard-wired "an `end` node" attributions inside the files this
PR already edits were generalised in place, because this change is what
makes them false: the `FlowRefusalSignal` docblock, `finishRefusedRun`'s
header, and its durability `error` log line. Comments and one log
string; no gate reads them.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01QGMBhvUoyD8t5zY8xHQhnP)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…asure — narrow `DashboardWidgetSchema.values` for the metric/kpi/gauge/solid-gauge/bullet family (objectui#8894 ruling D) (objectstack-ai#18720)

Fixes objectstack-ai#17779
Clause-②: yes (narrowing)

Executes maintainer ruling **D** on objectui#8894 (decision batch objectstack-ai#119
item 4, 2026-09-12 「同意」) under the standing rule 「协议不正确的应该先修改协议。」 —
judge the protocol wrong: a metric-family widget takes exactly one
measure. The direction was not re-opened here.

## What changed

`DashboardWidgetSchema.values` was `z.array(z.string()).min(1)` with
**no upper bound on any widget type**, so a `metric` tile could declare
three measures; the dataset query selected and computed all three and
the tile rendered `values[0]`. The other two were queried and dropped on
the floor (objectui#7293 defect 1). objectui#8887's sub-caption made the
tile honest about dropping them; it did not make the document legal.

- `checkDashboardWidgetMetricMeasureArity` — a new exported object-level
check, chained onto the same door by identifier, refusing more than one
measure on `metric` / `kpi` / `gauge` / `solid-gauge` / `bullet` and on
a widget that declares no `type` (it defaults to `metric`, and the
message says so rather than claiming the author wrote it). One `custom`
issue at `values`, naming the widget's `id`, the count, and the authored
`type`, and prescribing one measure per tile — "make N tiles for N
measures" — plus the visuals that DO render several numbers.
- **Exactly one is a conjunction**: the field's own `.min(1)` still owns
the empty array (`too_small`, unchanged, and the new check deliberately
adds no second issue there); the new check owns the upper bound.
- `.changeset/17779-...` — `minor`, **BREAKING** banner, ADR-0087
disposition `registered
dashboard-widget-metric-family-multi-measure-refused`.
-
`packages/spec/src/migrations/entries/semantic/18.dashboard-widget-metric-family-multi-measure-refused.ts`
— one new entry file, plus the `gen:migration-registry` lap. No other
file in that directory was touched and nothing was hand-edited inside
the generated regions of `registry.ts`.
- The `values` doc string now states the arity rule it enforces, so the
generated reference page stops saying only "at least one".

## The three questions the dispatch asked, answered by measurement

### 1. `superRefine`, not a per-type union arm — because a union
destroys every other diagnostic on this door

Eight widget bodies through `z.union([metricArm, otherArm])` versus one
more `.superRefine` on the strict object, measured on this tree:

| body | union arms | the spelling shipped |
|---|---|---|
| `bogusProp` on a widget | `(root) invalid_union: Invalid input` | the
strict-object refusal, naming the key + the history sentence |
| `categoryField` / `valueField` | `(root) invalid_union: Invalid input`
| the `WIDGET_GUIDANCE_SETS` ADR-0021 prescription |
| `titel` | `(root) invalid_union: Invalid input` | "Did you mean
`titel` → `title`?" |
| `type: 'ziggurat'` | `(root) invalid_union: Invalid input` |
`invalid_value` at `type`, listing all twenty |
| `metric` + 3 measures | `too_big` at `values` | the curated `custom`
refusal at `values` |

Four of eight bodies lose their whole diagnostic to one bare `Invalid
input`. That is not a new observation on this file: the `compareTo`
docblock already records it for the same reason (objectstack-ai#5014 — "a union
collapses into one bare `Invalid input` on the wire … A plain strict
object's errors reach the author"), and `view-union-diagnostics.test.ts`
is the entire apparatus objectui needed **because** `ViewMetadataSchema`
is a union. A second union here would commission that apparatus again to
buy a refusal the object-level form gives for free. Second datum,
measured: zod 4.4.3 throws `Cannot overwrite keys on object schemas
containing refinements. Use .safeExtend() instead` on a plain
`.extend()` that redeclares a key, so the arms cannot even be built from
the existing door without `.safeExtend()` or a duplicated declaration.

### 2. `major` does collide with `check-changeset-no-major` — so the
changeset is `minor`

The guard is **armed**: there is no `.changeset/pre.json`, so the RC
exemption does not apply, and the only other route is the `allow-major`
PR label whose own error text says "a whole-stack major release is
genuinely intended" — false for this PR. Its header states the
convention: every publishable package is in the Changesets `fixed`
group, so one `major` promotes all ~70 packages; during the launch
window a breaking change ships `minor` and **breaking-ness is carried by
the BREAKING banner plus the ADR-0087 disposition, not by the bump
level**. `pr-automation.yml`'s "WHICH LEVEL" prose says the same in the
place the author reads it. So the card's "major changeset" is satisfied
as `minor` + `**BREAKING**` + `registered ...`, and
`check-adr-0087-registration --base origin/main` reads the changeset
back as `[BREAKING+bang+clause-②-narrowing] registered
dashboard-widget-metric-family-multi-measure-refused`.

### 3. The migration entry's acceptance criteria, re-derived from what
the code refuses

Not a restatement of the card. Two things the card's wording implies
that the machinery does **not** do, both measured and both written into
the entry:

- **The TODO cannot name your dropped measures.** `applyMetaMigrations`
maps `step.semantic` straight onto the result (`chain.ts`) with no
per-document interpolation and no filtering by whether the stack even
carries the shape, and `SemanticMigration` has only static string
fields. `os migrate meta` therefore prints the entry's prose, not a
list. The **refusal** is what names them, per widget, on the re-parse —
so the entry tells the author to drive the fix off `os build`, not off
the migrate output.
- **Splitting into N tiles is not attempted**, as the card says — and
the entry states why in the registry's own terms: N tiles need N ids and
N boxes on a 12-column grid, which is a layout fact about a dashboard
the registry has never seen.

The rest of `acceptanceCriteria` is the measured accept/refuse matrix:
which door refuses (publish, not objectui's `.shape`-mirror editor), the
empty-array carve-out, the aborting `invalid_value` on an unknown
`type`, the un-reachable "does this measure exist in the dataset", and
the fact that `.omit()` / `.pick()` / `.partial()` already threw before
this change.

## Controls

**LIT** — a legal single-measure metric tile parses **identically before
and after**, and the non-metric families are untouched. Sixteen bodies
through `DashboardWidgetSchema.safeParse`, before and after the change:

| body | before | after |
|---|---|---|
| `metric` + 1 measure | ACCEPT, `values: ["amount_sum"]` | ACCEPT,
`values: ["amount_sum"]` |
| `metric` / `kpi` / `gauge` / `solid-gauge` / `bullet` + 2–3 measures |
ACCEPT (all five) | REFUSE `values:custom` (all five) |
| no `type` + 3 measures | ACCEPT, `type: "metric"` | REFUSE
`values:custom` |
| `bar` / `line` / `table` / `pivot` / `funnel` + 3 measures | ACCEPT |
ACCEPT (unchanged) |
| `metric` + `values: []` | REFUSE `values:too_small` | REFUSE
`values:too_small` (one issue, not two) |
| `type: 'ziggurat'` + 3 measures | REFUSE `type:invalid_value` | REFUSE
`type:invalid_value` (alone) |
| `metric` + 3 measures + `bogusProp` | REFUSE `unrecognized_keys` |
REFUSE `unrecognized_keys` |

The whole taxonomy is covered by a pin that asserts the metric family
plus the fifteen others **is** `ChartTypeSchema.options`, so a new chart
type cannot land uncovered by either list.

**DARK** — things that must read **0**, with paths and counts:

- `.min(1)` **array** keys in `packages/spec/src/ui/dashboard.zod.ts`
other than `values`: **0**. The file has exactly two `.min(1)` code
sites at the branch point — `values` (line 706) and `dashboard.columns`
(line 1151, `z.number().int().min(1).max(24)`, a number bound, not an
array). The latter is byte-identical after the change; every other new
`.min(1)` occurrence in the file is inside a docblock.
- `ReportSchema.values` (`packages/spec/src/ui/report.zod.ts`, lines 237
and 314) is a separate declaration, `optional()`, with no `.min(1)` and
no arity check, and its `type` enum (`tabular` / `summary` / `matrix` /
`joined`) contains **0** metric-family members. Untouched, and not the
same defect.
- Fleet census over every tracked `.ts` / `.tsx` / `.json` / `.mdx` /
`.md` / `.yaml` **at the branch point** `72dd95fa5a`: **187**
brace-local literals carrying a `values: [...]`, **39** of them on a
metric-family `type` (both lit controls), and **0** of those carrying
more than one measure. Nothing in the monorepo moves. On this branch the
same scan reads 205 / 49 / **7**, and all seven are the fixtures this PR
added.
- `check:authorable-surface` is green with no regeneration: **0**
authorable keys move. `check:api-surface` reports `0 breaking
(removed/narrowed), 1 added` — the new exported check.

## Verification

Red before green, with the mutation proved on disk and the restore
hash-verified:

```
HEAD blob     : 30c6d78
worktree blob : 30c6d78   (at HEAD before the mutation)
anchor occurrences BEFORE: 1   AFTER: 0   injected line: 1
mutated blob  : 90548649227c3971f16b7dc85b02e1bab8155f96   (differs -> the edit really landed)
RED   vitest exit=1   17 failed | 205 passed (222)
restored blob : 30c6d78   git diff HEAD on the path: empty
GREEN vitest exit=0   222 passed (222)
```

The mutation removed only the
`.superRefine(checkDashboardWidgetMetricMeasureArity)` attachment,
leaving the function declared — so the 17 reds are the door's behaviour,
not a compile failure. The script carried a `trap ... EXIT INT TERM`
restore against an absolute `git rev-parse --show-toplevel` path,
restored with `git checkout HEAD -- path` (never a bare `git checkout
--`), and proved the restore by blob hash **and** an empty `git diff
HEAD`.

- `pnpm --filter @objectstack/spec test` — **486 files / 13933 tests
passed**, exit 0.
- `pnpm --filter @objectstack/spec typecheck` — exit 0
(`check:scripts-typecheck` and `check:test-typecheck` included; the
test-layer ledger held at 54 files / 259 errors / 144 pinned signatures,
shrink-only).
- `pnpm --filter @objectstack/spec check:generated` — **all 15 generated
artifacts up to date**, exit 0, after regenerating exactly the three it
proved stale (`api-surface/`, `export-origins/`,
`content/docs/references/**`).
- Changeset gates: `check-adr-0087-registration --base origin/main` exit
0 (+ `--self-test`, 384 assertions), `check-changeset-no-major --base
origin/main` exit 0, `check-empty-changeset --base origin/main` exit 0.
- `pnpm check:nul-bytes` exit 0 (8812 text files, no raw control bytes),
plus `check:widget-option-census`, `check:liveness`,
`check:exported-any`, `check:dual-source-exports`,
`check:entry-nameability`, `check:empty-state`,
`check:cross-package-test-inputs`, `check:test-source-alias`,
`check:type-check-coverage`, `check:merge-driver`,
`check:pm-widening-tells`, `check:spec-docblock-symbol-anchors`,
`check:dts-closure`, `check:published-files`, `check:spec-parsed-alias`,
`check:page-declaration-shape`, `check:corpus-claim-drift`,
`check:skill-examples`, `check:docs-transcript-drift`,
`check:doc-formula-expressions`, `check:variant-docs`, `check:llms-txt`,
`check:yaml-examples`, `check:objectui-pin-citations`, and the ten doc
gates the regenerated `.mdx` newly derives — every one exit 0.
- **Repo-wide lint, not a narrowing**: `node --stack-size=4000
node_modules/eslint/bin/eslint.js . --no-inline-config --format json` at
`ea17ab8491`, 81s — **6822 files linted, 0 errors, 0 warnings**, exit 0.

## Migration-entry adjacency — checked, not assumed

`packages/spec/src/migrations/entries/` is one file per entry and the
entries README records the measured objectstack-ai#8344 table: two in-flight
registrations merge clean **unless** their ids are adjacent in sort
order or both are the first entry of a new major. Enumerated the `18.*`
semantic directory and every open PR's file list on 2026-09-17:

- In-flight ADDED semantic registrations:
`ui-list-view-groupbyfield-padded-refused` (objectstack-ai#18695),
`structured-region-body-pause-and-end-refused` (objectstack-ai#18688),
`evaluated-expression-slots-source-required` (objectstack-ai#18638),
`manifest-id-reverse-domain-required` (objectstack-ai#18319). (objectstack-ai#18420 modifies an
existing entry, which is not an insertion.)
- This entry's immediate neighbours in the sorted set are
`dashboard-header-modal-target-page-only` and
`dashboard-widget-stage-order-non-funnel-refused` — **both already
landed on `main`**, neither in flight — and it is not the first entry of
major 18. Neither ejection row applies. The seat's expectation about
objectstack-ai#18695 held, and was verified rather than assumed.

The two projections the README names came back **byte-identical, and
that is correct rather than a skipped step**: `build-spec-changes.ts`
and `build-upgrade-guide.ts` both loop `for (major =
MIGRATION_SUPPORT_FLOOR + 1; major <= PROTOCOL_MAJOR; major++)`, and
`PROTOCOL_MAJOR` is **17** while this entry registers under **18**. Both
were regenerated anyway and `check:spec-changes` / `check:upgrade-guide`
are green.

## Acceptance notes

Noted, not filed — neither is a reproducible defect, a contract
violation, or a metadata-authoring trap:

- **zod 4.4.3 refuses `.extend()` that overwrites a key on a refined
object** ("Use `.safeExtend()` instead"), measured here while probing
the union spelling. It is a trap for the next author who mirrors or
re-arms this door — recorded in the new check's docblock and in the
migration entry, which is where that author looks. Successor: whoever
lands objectui#8894's half, which must re-attach this export onto a
`.shape` mirror.
- **An ADR-0087 semantic entry cannot name per-document values.**
`applyMetaMigrations` emits `step.semantic` unconditionally and
`SemanticMigration` carries only static strings, so a card instruction
of the form "emit a structured TODO naming X" is unsatisfiable as
literally written — the refusal message is the only per-document
channel. Recorded in this entry's `acceptanceCriteria`. Successor: the
next card that writes that instruction.

## Downstream, not in this PR

Card item 3 (objectui's contract twins gain the refusal pin; the runtime
warning becomes the door refusal) is the objectui half and objectui#8894
is `pm:blocked` on this card. Nothing in `../objectui` was touched.
Until that package imports and chains
`checkDashboardWidgetMetricMeasureArity`, its `.shape`-mirror editor
keeps accepting three measures on a `metric` and the author meets this
refusal at publish — stated in the check's docblock and in the migration
entry rather than left implied.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_

---

> ⏱️ **席位代改正文(dev 只写一次,⛔ 不 PATCH 正文;事后要改的由本席代写)。** 两处:
>
> - **`applyMigrationChain` → `applyMetaMigrations`(2 处)** ——
前者在树上**不存在**;
> 真函数是 `packages/spec/src/migrations/chain.ts:68`,CLI 调它,根 api-surface
导出它。
>   同一处错名也写进了 ADR-0087 语义条目、并经 `gen:migration-registry` 复制进
>   `registry.ts:6765` —— 那段文本会被 `os migrate meta` 在协议 18 打印出来,
>   所以读者照着 grep 会一无所获。已随 `3b15ca1254` 修正(条目 + 重生成,⛔ 未手改 registry.ts)。
>   ⭐ 这一条由**达档隔离契约复核**判出(记录见下方 PASS/FAIL 评论),⛔ 不是本席自己看出来的。
> - **`Clause-②: no (narrowing)` → `yes (widening)`** —— 该行**只定路由**,⛔
非终审:
>   章程原文「只定是否必过席内契约复核的保守方向」,机械地板「新导出符号…恒 `yes`」。
>   本 diff 在 `api-surface/ui.json` 上**净增一个导出符号**
> (`checkDashboardWidgetMetricMeasureArity`,+1 / 移除 0,本席对着 merge-base
`72dd95fa5a` 实测),
>   ⇒ 地板落在 `yes`。认领侧早已是 `yes (widening)`,正文与 changeset 两个载体**落后于它**;
>   changeset 已随 `881db1280d` 对齐,并在行内写明两条轴(接受集**收窄**、公开面**扩大**),
>   免得 CHANGELOG 读成「本改动放宽了行为」。
>
> ⚠️ **破坏性未受影响**:`check-adr-0087-registration` 仍读作 breaking,
> 经 `**BREAKING**` 横幅与摘要里的 `!`;它失去的 `clause-②-narrowing` 信号从来不是唯一载体
> (实测 `[BREAKING+bang]`,exit 0)。
>
> ⏱️ **再正一次(席位):`yes (widening)` → `yes (narrowing)`。** 上一版本席以为「收窄行为 +
扩大公开面」在这套两态词表里没有正确拼法,于是取了 `widening`
并写了一段话解释「它不是那个意思」。**那个前提是错的**:`readClause2Line` 认 `yes
(narrowing)`,而`check-adr-0087-registration` 的自测逐字命名了这个形状 ——「the
`narrowing` arm beside a `yes` value — a diff that **widens AND
narrows**」。⇒ 值仍是 `yes`(机械地板:新导出符号),但**臂**改回
`narrowing`,`clause-②-narrowing` 信号随之回到 ADR-0087 门禁(实测
`[BREAKING+bang+clause-②-narrowing]`,exit 0)。⭐ 这一条由第二次达档复核在 ③
里作为**边界旗标**提出,⛔ 不是 FAIL;本席自己验过词表才动手。⚠️ 顺带一提 `no (widening)` 读作
**malformed** —— 臂不是自由的:`no` 只配 `narrowing`,`yes` 两者皆可。

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…e the 27 signature hashes (objectstack-ai#18971)

Fixes objectstack-ai#16045

Clause-②: yes (widening)

Ruled at `5560224701` (director batch objectstack-ai#60, 2026-09-06, maintainer
verbatim 「同意」), re-affirmed by triage at `5724532096`: option A, a
readable declaration-text snapshot, ⛔ not a hash. The card body's three
mutually exclusive routes predate that ruling and were not re-litigated
here.

`@objectstack/spec` pinned its public surface on one axis.
`api-surface/` records each export as `name (kind)`, and a signature
change, a renamed interface field and a dropped union member move
**none** of those rows. The only shape pin was
`api-surface-signatures.json`: 27 rows, and reference-level even there.
This adds `api-surface-declarations/`, the declaration text the packed
build actually emits for every export of every published entry point,
and retires the 27 hashes it subsumes.

## The counts, re-derived on this head before the first generation

The ruling asks for this by name; the card's own numbers were
self-declared unverified and 12 days old.

| Number | Card | This head (`b33898f5d`) | Unit, and what would make it
something else |
|---|---|---|---|
| entry points | 17 | **17** | type entry points in the `exports` map —
those whose `require.types` ends in `.d.ts`. Adding or removing one such
subpath. |
| `exports` map entries | (not stated) | **19** | every key in the map.
The extra two are `./openapi.json` and `./package.json` — asset subpaths
with no declaration at all, filtered out by the same `.d.ts` test
`build-api-surface.ts` has always applied. ⇒ premise 1 resolved: **17 is
right and the map did not grow**; 19 counts two things that were never
entry points. |
| pinned rows | 5309 | **5336** | `name (kind)` rows summed over the 17
`api-surface/` shards. +27 since the card. Ratio unmoved: 27/5336 =
0.51%, so the headline 99.5% stands. |
| distinct exported names | (not stated) | **5200** | (entry, name)
pairs. The gap to 5336 is dual-declared names, which are two rows by
design. |
| signature hashes | 27 | **27** | top-level keys of
`api-surface-signatures.json`. Bright control: the first value really is
a `sha256:` string, so this counts signature entries and not empty
objects. |

Premise 3 also holds: all 17 packed `.d.ts` files exist and resolve
through the map (3,215,437 bytes for the root entry down to 13,081 for
`./integration`). No entry point lacks a packed declaration, so the gap
the dispatch reserved for itself did not open.

## What the artefact costs — premise 4, which nobody had costed

| | |
|---|---|
| shards | 17, one per entry point |
| declaration blocks | 5336 |
| bytes | **12,661,943 (12.08 MiB)** |
| lines | **237,706** |
| gzipped | **1,071,825 (1.02 MiB)** — against this package's ~17.57 MiB
compressed `dist`, so about **+5.8%** of tarball |
| largest shard | `system.txt`, 3,592,701 bytes / 73,283 lines |
| median declaration | **81 bytes** |
| skew | the 20 largest declarations hold **~65%** of all bytes; four
exceed 20,000 lines each (`EnvironmentArtifactSchema` 21,868,
`ObjectStackDefinitionSchema` and `ObjectStackSchema` 21,851,
`ChangeSetSchema` 20,395) |

Stated plainly, as the dispatch asks, and ⛔ not as a veto: the packed
`.d.ts` is a tsup dts rollup, so a Zod schema's declaration is its
**fully expanded** structural type. That expansion is exactly what makes
an inner field rename visible — and it is also why a single schema can
produce a 21,000-line diff. The ruling's stated reason for choosing text
over a hash is that the contract-review seat reads the diff; that
reasoning holds per declaration and is worth a second look at the top
twenty. One reading, for whoever wants it: 31% of declarations hold
97.7% of the bytes, so nothing cheap is available by trimming the tail.

## Both instruments, measured on one tree at one commit

The card's thesis is that the old pin cannot fail on a shape change. Not
argued — ablated, with the mutation proven on disk by blob hash and the
mutation proven to have reached `dist/` before any verdict was read.

**A. the source-level control — a renamed interface field, the card's
own class.** `JobRunOutcome.reason?` renamed to `degradationReason?` in
`packages/spec/src/contracts/job-service.ts` (blob `363443e2` to
`d4b1520c`), spec rebuilt, `ablation-dist-preflight` exit 0 confirming
the marker reached the built artefact:

```
check:api-surface              exit=0    "public API surface unchanged"      [BLIND]
check:api-surface-declarations exit=1    "~ JobRunOutcome (interface)"       [SEES IT]
```

Restore leg: blob back to `363443e2`, rebuilt, `ablation-dist-preflight
--absent` exit 0 (marker gone from all 214 built files), `git diff HEAD`
clean, gate back to exit 0.

**B. the gate can fail on its own artefact.** One field renamed inside
`qa.txt` by hand (blob `3f5efb04` to `5b86fec2`, injected occurrences 1,
deleted text 0): exit **1**, attributed to `TestSuiteSchema (const)`,
failure text naming the regenerate command. Restored to the HEAD blob,
`git diff HEAD` empty: exit **0**.

## The retirement, and the coverage proof the ruling demands

All **27** signature names resolve to a declaration block in
`api-surface-declarations/root.txt`, **0 missing** — enumerated from
`defineAction` through `defineWebhook`, each as `(function)`.

One honest qualification, because the subsumption is not uniform. For
those 27 factory declarations the text is `declare function
defineAction(config: z.input of ActionSchema): ActionParsed;` — a type
**reference**, exactly as blind to an inner-key narrowing as
`typeToString` was. What is gained is not sharper text on the 27; it is
the **5309 other declarations**, including `ActionSchema` itself, whose
own expanded block is where such a narrowing shows up. So the retirement
is a strict superset of pinned declarations, not an equal trade. Nothing
published read the retired file — it was never in this package's
`files[]`.

## Where it lands, and why there

- Generator: `packages/spec/scripts/build-api-surface-declarations.ts`,
beside the eight sibling artefact generators, reading the same input
through the same `collectEntries` logic. The ruling says "one generator
script under `scripts/`"; this reads that as the directory the whole
family lives in, because the artefact reads the **built dist** and only
the lane that builds spec can run its gate.
- Artefact: `packages/spec/api-surface-declarations/ENTRY.txt`, a
sibling **directory** of `api-surface/`. Not inside it: `listShardNames`
throws on any file in that directory that is not a `NAME.json` shard, so
`api-surface/` is closed by construction. No existing
`api-surface/*.json` is regenerated by this PR (`check:api-surface`
green throughout), which keeps it clear of PR objectstack-ai#18688 and PR objectstack-ai#18319.
- Gate: `check:api-surface-declarations`, a step in lint.yml's `Type
Check · consumer gates` lane after the two build steps, with
`check:api-surface` and the other dist-reading gates. **No new required
context** — a step in an existing lane. Registered in the
`check:generated` ledger, in `REGEN_ARTIFACTS`, and in `.gitattributes`
as `merge=os-regen`.
- Sharded per entry point from day one, for the reason its neighbour is:
the merge queue rebuilds server-side where no custom driver runs, so two
PRs sharing one generated file evict the second. Pit 1 from `5715457322`
is answered by the layout rather than by an assumption — and
`check:merge-driver`, which reconciles `.gitattributes` against
`REGEN_ARTIFACTS` in both directions, is green over the swap.
- Published, with the reason the gate demands. `check:published-files`
refuses a `files[]` entry that carries none; the registered line says
what a consumer does with it — read two published tarballs and see
*which declared shape* moved between releases, the question
`api-surface` cannot answer. If 1.02 MiB of tarball is judged too much,
one line of `files[]` removes it without touching anything else.

Three registries had to learn about the new gate, each because it
discovered the gate on its own rather than because a list named it:

- `check:published-files` — demanded the reason above.
- `scripts/pm/dispatch-gates.mjs` — its live manifest edge gave the new
gate a population before anything listed it, which is the eighth member
of a class whose seventh was recorded the same way. Declared as
`CLASS_EIGHTH`, with a case asserting the edge really reaches it.
- `scripts/pm/check-widening-tells.mjs` — `PUBLISHED_SURFACES` is
derived from `REGEN_ARTIFACTS`, so retiring the signatures row dropped
it off that surface and reddened two self-test cases. Both are
retargeted to state the retirement as a counterfactual (the surface
follows the table, not a literal); ⛔ the new artefact is **not** added
to that surface, because the ruling assigns "is a snapshot diff a
Clause-② signal" to the skills seat by name and out of this card's
scope. Both directions are now pinned, so the boundary is declared
rather than forgotten. 483 cases pass, up from 481.

## Verification

- **Gate families**: derived with `node scripts/pm/dispatch-gates.mjs
--repo objectstack-ai/objectstack --commands` from the merge base, 120
commands, every exit code redirected to a file and read back. **All 120
green.** Four returned exit **3** PREREQUISITE NOT MET on first pass
(`check:doc-formula-expressions`, `check:dual-build-cjs-loads`,
`check:lean-entry-closure`, `check:type-check-debt`); each names a
build, each was built and re-run green, and none is recorded as a
finding. Reconciled with `--ran`.
- **Tests**: `@objectstack/spec` local project **488 files / 14,182
tests passed**; the tooling suites that name the edited scripts, both
projects, **10 files / 220 tests passed** (`sharded-artifacts`,
`check-generated-ledger`, `dist-freshness`, `dist-freshness-adoption`,
`api-surface-dual-kind-rows.pin`, `build-schemas-check-mode`,
`def-key-collisions`, `root-index`, `export-list`,
`docs-import-surface`). `pnpm --filter @objectstack/spec typecheck`
green.
- **eslint, the union rather than a narrowing**: `eslint .
--no-inline-config --format json` at `b33898f5d` examined **6856
files**, **0 errors, 0 warnings**, exit 0. The population is eslint's
own config resolution and the count is read from its JSON output;
type-aware linting is not enabled in `eslint.config.mjs` (no
`parserOptions.project`, no typed rules), so this diff cannot move an
untouched file's verdict either way.
- **Control bytes**: `check:nul-bytes` green over 8906 files, plus a
direct scan of all 31 changed paths for the wider control-byte class —
no matches.
- `scripts/check-single-claim-paths.mjs` in the diffstat is **not
mine**: it arrived with the one-commit `origin/main` merge (`16cb493d5`)
this PR carries.

## Acceptance notes

- `.claude/skills/spec-property-retirement/SKILL.md` line 124 lists
`api-surface-signatures` as an instance of a retirement shape, and that
row goes stale with this landing. ⛔ Left untouched on purpose:
`.claude/**` is a governed surface, so editing it would make this whole
PR maintainer-landed for a one-word prose nit. Noted, not filed.
- `packages/spec/scripts/build-schemas.ts` line 830 carries the same
stale mention. Left untouched because PR objectstack-ai#18952 holds that file; noted,
not filed, with the later lander as the natural carrier.
- Three files in this diff are held by open PRs and were edited anyway
because the retirement forces it, not by choice:
`scripts/pm/check-widening-tells.mjs` (PR objectstack-ai#18948),
`scripts/pm/dispatch-gates.mjs` (PR objectstack-ai#18903) and
`.github/workflows/lint.yml` (PRs objectstack-ai#18946, objectstack-ai#18889, objectstack-ai#18414). All are
hand-written files where a text conflict is visible rather than silent,
and all three of my hunks are small and far from theirs. Whoever lands
second resolves.
- The top-20 skew above is a reading, not a finding: no gate is wrong
and nothing is unenforced. It is recorded here because the ruling's own
justification for text over hash is per-declaration readability, and at
21,000 lines a declaration that argument thins out.

## 维护者速读(草稿)

**改了什么。** `@objectstack/spec` 从今天起为它的**每一个**公开导出留一份"形状快照" —— 不是哈希,而是打包后
`.d.ts` 里那段声明原文,按入口点分成 17 个文件签入仓库,并配一道 CI
闸门:重新生成后对不上就红,失败信息里直接给出重新生成的命令。同时退休了旧的 27 条签名哈希文件。

**为什么改。** 原来的 pin 只记"某个名字还在不在",5336 行里只有 27
行能看出"形状变没变"。也就是说:把一个接口字段改名、砍掉一个联合成员、改一个函数签名 —— 这些都是会让客户升级后编译失败的破坏性改动 ——
全部一路绿灯。本次 PR 里有实测:改了 `JobRunOutcome` 的一个字段名之后,旧闸门 `check:api-surface`
退出码 **0**(看不见),新闸门退出码 **1**(点名了那个 interface)。路线是 2026-09-06 决策批次 objectstack-ai#60
里您逐字「同意」的那一条。

**风险与代价(含回滚)。** 代价是体积:12.08 MiB 文本、23.7 万行,压缩后 1.02 MiB,相当于 npm 包增长约
5.8%。更值得注意的是分布极不均匀 —— 最大的 4 个 schema 各自超过 2 万行声明文本,一旦它们变动,复核席位面对的是一份 2
万行的 diff;而裁决选"文本不选哈希"的理由恰恰是"diff 可读"。这一点我按实测如实报告,未自行改动路线。回滚成本很低:从
`files[]` 去掉一行即可停止随包发布;整道闸门回滚就是撤销本 PR,不留任何数据迁移。

**席位意见。**

**你要做的。** 只有一件事需要您判断:12 MiB / 23.7 万行这个量级,以及最大 4 个 schema 的 diff
可读性,是否仍符合当初选 A 方案时的预期。若认为需要收窄,那是裁决层面的一次增补,不是本 PR 的返工。其余部分已按裁决落地并自证。

---
_Generated by [Claude
Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ion fails the run with a named refusal (objectstack-ai#19140)

Fixes objectstack-ai#18881

Fixes objectstack-ai#15646

Clause-②: no — this is a refusal being added, pulling runtime behaviour
back to objectstack-ai#3267's declared 禁. The flow accept set does not widen, and no
export leaves this package: `FlowRegionSuspensionRefusalError` lives in
a new internal module that `src/index.ts` does not re-export, exactly as
`guard-refusal.ts` and `partial-steps.ts` do.

## The defect, measured before anything was written

A node contained in an ADR-0031 structured region body — a `loop` body,
a `parallel` branch, a `try_catch` try or catch region, **at any depth**
— that durably suspends now FAILS the run with a named refusal carrying
the region node, the suspending node and the sub-flow. `runRegion`
already converted such a suspension, but into a plain `Error`, which is
indistinguishable from a node that simply failed.

The card's own reproduction, `loop { try_catch { map(pausing child) }
}`, on `origin/main` at `c70581bc` with only the new test file added:

```
 × the card's reproduction: `loop { try_catch { map(pausing) } }` fails, and the catch handler never sees it
   AssertionError: expected true to be false     // result.success
 × the error NAMES the region node, the suspending node and the sub-flow
   AssertionError: the given combination of arguments (undefined and string) is invalid
 × counts the refusal ONCE: `summary.failed = 1`, on the region node
   AssertionError: expected +0 to be 1
 × `loop { map(pausing) }` — the region node is the `loop`
   Expected: "sweep"
   Received: "durable pause inside a structured region (node 'per_cell') is not supported"

 Tests  6 failed | 2 passed (8)
```

Read that fourth line: even where the run DID fail, the sentence named
neither the region nor the sub-flow. The `try_catch` case is worse — the
enclosing container read the plain `Error` as "the try region failed",
ran its catch handler, and the run finished `completed` with
`summary.failed = 0`. The `map`'s progress state (`nodeId.$mapState`) is
written into the ENCLOSING scope, so the residue a contained refusal
leaves is read back as progress by the next entry to the same node:
iteration 2 saw `started === collection.length`, ran nothing, and
reported success again.

## What changed

- **`src/region-suspension-refusal.ts`** (new, internal):
`FlowRegionSuspensionRefusalError` carries `regionNodeId`, `regionKind`,
`suspendedNodeId` and `subFlowName` as FIELDS as well as in its message,
so a reader never parses the sentence. Branded as a objectstack-ai#3863 guard refusal,
so a `fault` edge on the enclosing container cannot route it either —
the one-edge switch that would otherwise re-open the same silence. The
predicate is duck-typed on a registered symbol, matching
`isGuardRefusal` / `isSuspendSignal`, because this package ships ESM and
CJS from one source and a cross-realm `instanceof` answering `false`
would mean a container swallowing the refusal again.
- **`runRegion`** raises it in place of the plain `Error`, resolving the
sub-flow from the suspending node's own `config.flowName`. A refusal
raised at an INNER boundary is re-thrown untouched, so the region named
is the one the author nested the pause in and not the frame the unwind
passes through.
- **`try_catch` re-throws it** from both the try-attempt arm and the
catch-region arm, and spends NO retry attempt on it: re-entering the
region would re-enter the pausing node, and the metadata is what is
wrong. **`parallel` re-throws it** rather than folding it into its
returned — and therefore `fault`-routable — branch failure. `loop`
already re-threw unchanged. The attempt's steps ride out on objectstack-ai#13803's
channel, so rows the region really did write stay in the run log and in
the objectstack-ai#4354 totals.
- **One refusal is one failure.** The region node's own frame records
the `EXECUTION_ERROR` step and publishes `{$error}`, exactly as any
thrown node failure does; every enclosing container records nothing, so
`summary.failed` counts the fault and not the nesting depth.

⛔ **No parse-time rule is added here.** objectstack-ai#18688 landed that half. ⛔ **No
new `error.code`**: the closed `ERROR_CODE_LEDGER` (ADR-0112) lives in
`packages/spec`, outside this card's declared file surface; the refusal
is named by its type and its fields.

## The closing keyword on objectstack-ai#15646, and the round trip it took to get
there

⭐ This section previously explained why the body said `Part of objectstack-ai#15646`.
It now says `Fixes`, and the reasoning is kept rather than deleted
because the round trip is the record.

**Ruling D's execution clause names the mechanism**, verbatim: 「The spec
half lands with `Part of objectstack-ai#15646`; the runtime card's PR closes this card
with `Fixes objectstack-ai#15646` once both are on `main`.」 Both halves exist:
`78436637` (PR objectstack-ai#18688) is an ancestor of `origin/main` and of this
branch's base, and the parse-time refusal does **not** reach the card's
reproduction — `map` and `subflow` are deliberately not judged by type
there — so the runtime arm is live and every fixture in the new suite
registers and runs.

**What blocked the literal mechanism was a shipped gate.**
`check:closing-target-claim` refuses a PR closing a card whose thread
carries no live `Claim:` naming that PR's head branch. objectstack-ai#15646 carried
three claims, all naming `claude/issue-15646-region-pause-end-refusal`,
and its newest protocol event was a `Release:` (`5730095126`) — no live
claim, none naming this branch. ⛔ A dispatched executor may not post a
claim of its own to clear that, so the delivering dev shipped `Part of`
and reported the conclusion as falsified. **That was the correct call at
its authority level.**

**The gate's own remedy 1 is a PM act**, and the PM seat performed it:
the `domain:spec` seat had already released this card's runtime half to
`domain:services` in writing (「由 services 车道重新认领」), so the services seat
claimed it on this branch (`issuecomment-5737225992`) and assigned
itself. The claim is simply true — this card's remaining half really is
in flight here.

**Re-measured after the claim**, with the gate's own prescribed
invocation: `check:closing-target-claim` **exits 0**, reporting 「PR
objectstack-ai#19140 closes objectstack-ai#18881, objectstack-ai#15646, and each carries a `Claim:` whose
`Branch:` line names
`claude/issue-18881-region-durable-suspension-refusal`」.

⇒ the ruling is executed **literally**, via the gate's prescribed route.
⛔ Not a re-adjudication of the ruling, and ⛔ not an evasion of the gate
— the gate exists to stop a second seat duplicating work on an unclaimed
card, and a truthful claim serves that purpose.

## Tests

All figures below were taken at `bad6404b0`, the final commit on this
branch.

**The new suite** —
`packages/services/service-automation/src/region-durable-suspension-refusal.test.ts`,
8 tests, every refusal case paired with a synchronous control:

```
pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 \
  src/region-durable-suspension-refusal.test.ts
 Test Files  1 passed (1)
      Tests  8 passed (8)
```

**The whole package**, which is what CI runs and the only scope that can
see this class of breakage:

```
pnpm --filter @objectstack/service-automation test
 Test Files  139 passed (139)
      Tests  1660 passed (1660)

pnpm --filter @objectstack/service-automation typecheck
check:test-typecheck: OK — 0 file(s) / 0 error(s) / 0 pinned signature(s)
```

⭐ **1652 + 8 = 1660.** PR objectstack-ai#18688's body measured this package at **138
files / 1652 tests, all passing** on the tree this branch is cut from.
This branch adds exactly one file and eight tests and lands at 139 /
1660, so nothing was lost, re-homed, skipped or quarantined. objectstack-ai#15616's
five tests, objectstack-ai#15788's runtime `end` test and objectstack-ai#16314's rollup suite are
**untouched in the diff** and green in that run.

**Reverse validation — two ablations, each mutated on disk through
`scripts/ablation-replace.mjs` (anchor must hit, blob hash must change)
and restored against `HEAD`.** The package's own tests import their
subject through relative specifiers (`./engine.js`), so the mutation is
live from source with no `dist` on the path — which each ablation
demonstrates by going red.

1. **The `try_catch` re-throw arm**, the one that decides whether this
card's defect exists:

```
ablation-replace: anchor "if (isRegionSuspensionRefusal(err)) {" x1 -> x0
ablation-replace: blob   c4ed5b3 -> c3b44a8af269
  × the card's reproduction: `loop { try_catch { map(pausing) } }` fails …
  × the error NAMES the region node, the suspending node and the sub-flow
  × counts the refusal ONCE: `summary.failed = 1`, on the region node
 Tests  3 failed | 5 passed (8)
ablation-replace: ok restored: blob == HEAD (c4ed5b3) and `git diff HEAD` is empty
```

The three `loop` / `parallel` cases and both controls stay GREEN under
that mutation, which is the second reading it buys: the `try_catch` arm
is precisely what closes the contained case, and the other two region
kinds are closed by a different arm.

2. **The one-refusal-one-failure rule**, because `summary.failed = 1` is
the assertion most at risk of being vacuously true:

```
ablation-replace: anchor "isRegionSuspensionRefusal(execErr) && execErr.regionNodeId !== node.id;" x1 -> x0
  × counts the refusal ONCE: `summary.failed = 1`, on the region node
    AssertionError: expected 2 to be 1
 Tests  1 failed | 7 passed (8)
ablation-replace: ok restored: blob == HEAD (53fee28) and `git diff HEAD` is empty
```

`2` is exactly the nesting-depth reading — the `try_catch` and the
`loop` each recording the same event — that the suppression prevents.

**Gates.** Derived with `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack`, no paths passed, reconciled with
`--ran`:

```
Run reconciliation — 59 derived, 59 run, 0 NOT-MEASURED, 0 UNRUN.
✓ dispatch-gates --ran: 59 derived famil(ies) accounted for — 59 run,
  0 NOT-MEASURED (a DERIVED zero — all 59 recorded an exit code and none of them is 3)
```

Two of the 59 first answered `PREREQUISITE NOT MET` (exit 3, ⛔ not a
finding) because they read built output the whole tree has to supply —
`check:dual-build-cjs-loads` and `check:type-check-debt`. Both were
re-run after `pnpm exec turbo run build --filter='./packages/*'
--filter='./packages/*/*'` (72/72 tasks) and both exit 0.
`check:plugin-teardown-shape --self-test` first answered exit 3 on a
pinned fixture commit this shallow checkout could not reach; after `git
fetch --unshallow` it passes its 48 cases.

**Lint — a declared narrowing, and a measured one.** `pnpm lint` is
`eslint . --no-inline-config`: a whole-repo scan whose broadest block is
`files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` minus `NEVER_LINTED`,
read from `eslint.config.mjs` itself. Run instead over this diff's five
lintable paths, counted from the linter's own `--format json` output:
**5 files, 0 errors, 0 warnings**. The narrowing excludes nothing, and
that is a property of the config rather than an assumption:
`eslint.config.mjs` **never enables type-aware linting for ANY file** —
no `parserOptions.project`, no typed `@typescript-eslint` rules — stated
verbatim at `eslint.config.mjs:326` with its own positive-control
measurement, so every rule is per-file and syntactic and no edit here
can move the verdict on a file it does not contain.

⚠️ `dispatch-gates` reports its derivation as taken from a tree behind
`origin/main`, with `scripts/check-release-spec-changes.mjs` and
`scripts/ts-parse.mjs` changed across that range. Neither declares a
`packages/services` population — `check:spec-changes` is
spec-release-scoped and `ts-parse.mjs` is a parser library that declares
no family — so the derived list is unchanged at 59. CI derives it again
on the merge base.

## Acceptance notes

Noted while reading, ⛔ not filed and ⛔ not fixed here — none is a
reproducible defect, a contract violation or a metadata-authoring trap:

- `runRegion`'s `isRefusalSignal` arm (objectstack-ai#15788) still converts a
region-contained REFUSAL into a plain `Error`, so an enclosing
`try_catch` can still contain that one. It is not reachable today:
objectstack-ai#18688 refuses an `end` inside a region body at parse, and the other
producers are a `subflow` / `map` whose CHILD run refused — a different
card's surface (objectstack-ai#18112's option B is explicitly not implemented).
Carrier if it ever becomes reachable: objectstack-ai#18112. ⛔ Deliberately left alone;
touching it would move accept/reject behaviour outside this card's
ruling.
- The resumed and up-bubble legs of a region-contained pause are
objectstack-ai#18714's and are held serial behind this card, so nothing here touches
`resumeInternal` or `creditChildRun`.


---
_Generated by [Claude Code](https://claude.ai/code)_

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants