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 0claim.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 staticFLOW_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 0486 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 085 derived, 81 run, 4 NOT-MEASURED, 0 UNRUN
@objectstack/service-automation — the 3 files whose fixtures feed this rule exit 15 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 044bbbba56a1c350:

  • ablatedflow-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.
  • restoredgit 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 2767af8e8354511f9c82ce402b147ad12c512819packageMentionDocs.

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/approveapprove/reject) and ApprovalNodeConfigSchema.onEmptyApprovers (fail/fallbackfallback/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 importindex.mjs (the condition vitest takes) and requireindex.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>
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