Skip to content

fix(pm): a SECOND Claim: by one seat is NAMED, not ranked as a supersession - #18859

Merged
os-justin merged 5 commits into
mainfrom
claude/issue-18828-second-claim-same-seat-refused
Sep 18, 2026
Merged

os-justin merged 5 commits into
mainfrom
claude/issue-18828-second-claim-same-seat-refused

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #18828

Clause-②: no

The claim protocol forbids a second Claim: — the rule is this file's own, in the #17366 docblock at scripts/pm/check-clause2-carriers.mjs:378 — and until this PR nothing READ it. A thread that carried the forbidden second line was RANKED, not refused: the governing-claim selector took the newest live claim that parses a branch and printed the loser as rejected: 1 … a SUPERSEDED claim, clean and green at exit 0, in the register reserved for a transition the protocol DESIGNED. A writer-side prohibition with no enforcing reader. claimRepeats and the C8 row are that reader.

⚠️ This is a RESUMED delivery. Three commits were already on this branch from a run whose container was killed in its final-gates phase. Nothing it wrote was rewritten and nothing was redone — every item of the dispatch contract was re-verified against the tree, and all three readings were re-taken at the current tip and are dated below. The verified/fixed ledger is the first section.

What was VERIFIED and what was FIXED

Every contract item was found already correct and is left byte-unchanged. No code fix was needed; the only commit this run adds is a merge of origin/main (the derivation was answering about a stale tree — see Gates).

contract item finding
the reading is a VERDICT at EXIT_PAIR_ADVERSE (4), never a NOTE at 0 verified — C8 is pushed in pairRows (:4430); pairNotes does not carry it, pinned
claimCarrierSelection stays a pure function of the rows it is handed verified — byte-identical to origin/main (sha256 of the whole function 40f59ba86082c358 at both revs)
CLAIM_COMMENT_MARKER unwidened verified — it is imported from check-half-states.mjs and read through markerMatches; that file is not in this diff at all
CLAIM_SELECTION_RULE and the governing-claim choice unchanged verified — byte-identical across revs; every hunk but two is a pure insertion
SUPERSEDED / RETRACTED wordings byte-unchanged where they still apply verified — no hunk touches them; the fixture control below prints the SUPERSEDED sentence identically at both revs
every pin (a)–(i) present, each with a non-vacuity control verified — 52 t(...) cases in the battery block; the ablation shows all nine directions carried
battery registered in the roster with its case count verified — :792, pinned at 52, and the block declares exactly 52
SELF_TEST_BATTERY_FLOOR raised by exactly one verified — 31 → 32 (:806)
the live census extended for this shape, population named verified — the five measured instances replayed from their real rows, with #18559 as the sanctioned-shape control

rowAuthor, laterOnThread and claimRetractions are byte-identical across the two revs as well.

The before-reading — the fixture control through the exported reader

The same two rows (one author, two claims each parsing a branch, no retraction between) through the same exported claimCarrierSelection / pairInputRecord / pairRows, at origin/main 88aa326deb and at this branch:

origin/main 88aa326deb this branch
claims / live / pool / rejected 2 / 2 / 1 / 1 2 / 2 / 1 / 1 — unmoved
governing claim 7100000002 7100000002 — unmoved
rejected[0].reason a SUPERSEDED claim — it is not the newest LIVE claim that parses a branch … byte-identical
claimRepeats exported no — the reader does not exist at that rev yes
claim.repeat in the record absent 1 author(s) holding more than one LIVE claim comment …
pairRows codes (none) C8
verdict exit 0 — nothing refused exit 4 (EXIT_PAIR_ADVERSE)

That is the defect and the repair in one table: the selector does not move, and the thread stops reading green.

The live count — the five cards and the control, re-taken 2026-09-18T00:44:39Z

Read per card through the gate's own markerMatches / CLAIM_COMMENT_MARKER and claimRetractions, not by eye. ⚠️ Those threads may have left this state since.

card Claim: comments (author) Clause-②-correction: Release: the OLD reading C8 today card state open delivering PR
#18540 2 — os-support-ai (5719079496, 5720020876) 0 0 SUPERSEDED, exit 0 named, exit 4 closed none
#18677 2 — os-support-ai (5720104138, 5720190458) 0 0 SUPERSEDED, exit 0 named, exit 4 closed none
#18748 2 — os-support-ai (5720212595, 5720888122) 0 0 SUPERSEDED, exit 0 named, exit 4 closed none
#18651 2 — os-support-ai (5721424769, 5721997887) 0 0 SUPERSEDED, exit 0 named, exit 4 closed none
#18778 2 — os-support-ai (5721425530, 5722028692) 0 0 SUPERSEDED, exit 0 named, exit 4 closed none
#18559 (control) 1 — os-support-ai (5721425131) 1 (5721779100) 0 (nothing rejected) silent closed none

The card's table reproduces exactly. All six cards are now closed, and the open-PR column is empty for every one of them: the only open PR cross-referenced from any of these threads is #18857, whose body's closing keyword names #18780 instead — prDeliversCard answers false for all six and true for #18780 (the control leg), so it is not paired with any of them. ⛔ The repair of the five is os-support-ai's; this PR only names them, and posts nothing on those cards.

The escalation probe — the triage's p1 condition, MEASURED

The triage marked this p2 because all five instances were one seat self-superseding, and named the escalation condition it had not run: a second Claim: from a DIFFERENT session on one card, which would be a silent ownership transfer printed green. I ran it.

Population, read 2026-09-18T00:47:37Z → 00:48:42Z: every open card on both boards this gate reads — 529 open cards in objectstack-ai/objectstack, 413 in objectstack-ai/objectui (942 total), of which 880 carry at least one comment and were read; 163 carry at least one claim comment. 9 comment lists sit at the 100-comment cap and are UNJUDGED past it, exactly as #18683 prescribes. 0 parse failures.

The answer is not 0 — it is 12. Twelve open cards carry LIVE Claim: comments from two or more DIFFERENT authors with no retraction between them:

repo card authors holding live claims
objectstack #13503 claude[bot] + baozhoutao
objectstack #14026 hotlong + claude[bot]
objectstack #15811 os-bill + os-litant
objectstack #17852 os-warren + os-litant
objectui #4730 yinlianghui + os-sales
objectui #7070 os-warren + claude[bot]
objectui #7696 os-justin + os-tesla
objectui #7804 os-tesla + os-sam + os-justin
objectui #7848 claude[bot] + baozhoutao
objectui #7924 os-warren + os-sales
objectui #8115 claude[bot] + yinlianghui
objectui #9370 os-tesla + os-justin

⚠️ This PR is deliberately SILENT on all twelve — and that silence is pinned, per direction (b). Refusing an ownership transfer between sessions is not this card's to do: it is a different state, it would need its own remedy sentence, and a row that answered both would make one sentence out of two states. This is reported here and in the dispatch report so the seat can file it; ⛔ it is not folded in.

Who the new exit 4 meets before it lands

The seat needs this before landing, so I swept it rather than assuming. Two facts:

  1. No CI job turns red. check:pm-clause2-carriers — the only wiring, .github/workflows/lint.yml:1140 — runs --self-test and nothing else. No workflow runs --pair or a sweep, so landing this changes no required context. The exit 4 appears only when a seat runs --pair or a sweep by hand.
  2. On the objectstack board: nobody. Of 32 open PRs in objectstack, none delivers a card that would newly earn a C8. Twenty open cards across both boards would earn the row (report-only, listed in the dispatch report), but only one is reachable through an open PR, and it is in the sibling repo: objectstack-ai/objectui#9584 (open, not draft; its closing keyword names objectui#9499), which delivers a card carrying two live claims by os-try-charles (5663366106 on 2026-09-14, 5690579598 on 2026-09-16). That pair answers exit 4 at its next --pair. DEFAULT_SWEEP_REPO is objectstack-ai/objectstack, so objectui is only ever read when passed explicitly.

⛔ Nothing was posted on #9499, #9584 or any of the twelve.

The reading, and WHERE it is computed

claimRepeats (:1898) is a sibling pure reader beside claimRetractions, built on it — the same map decides membership here and for governance, so the pool and this row cannot describe two different retractions. It names every author holding more than one LIVE claim comment, orders them by the file's one recency rule (laterOnThread, to ORDER the record, never to pick a winner), and resolves no state, no row and no exit code. c8SecondClaimSameSeat (:4380) renders the verdict; pairRows (:4430) pushes it as row C8; pairInputRecord adds claim.repeat (:5876, declared in INPUT_RECORD_PAIR_FIELDS at :5641) as the READING — one derivation feeding both, so the record and the verdict cannot disagree about how many claims a seat holds or which they are.

MEMBERSHIP first, and that is what makes the state repairable. The state is read over LIVE claims only. A re-claim after a Release: is the protocol working and reads exactly as it did before. And a seat that already wrote a second claim has an act that clears the row: Release: what it holds, then one fresh Claim:. A rule written over the writing moment instead ("no retraction strictly BETWEEN the two lines") would have been unrepairable by construction — nothing un-writes a comment — so the row would have been a permanent red with a remedy nobody could execute.

The four axes

  • 实际业务需求 — measured, not assumed. Five live instances on the objectstack board at filing, re-confirmed today, every one of them read green before this row; plus 20 open cards across both boards that carry the state now. The first signal in five occurrences came from a dev reading a docblock, not from any instrument. This is a real shape occurring repeatedly, not a speculative surface.
  • 项目长远合理性 — contract-first, and no workaround. The rule already existed in writing at :378; this adds the reader that enforces it, in the same file, over the same thread, through the same membership derivation governance uses. No new exit code was minted, no second selector, no second reader of the marker. The prohibition and its reader now live one screen apart.
  • 防 AI 写代码犯错 — this is the axis that decides the exit. A second Claim: re-enters the pool as the newest claim and becomes what every downstream reader is handed — the property the correction key was deliberately designed NOT to have. Rendering that as a NOTE at exit 0 is precisely the tolerant-consumer shape this repo refuses: an adverse fact printed green is how a batch of identical mistakes stays invisible. Declaring the prohibition and not enforcing it is the "声明而未兑现" gap; the repair is to enforce it loudly, at EXIT_PAIR_ADVERSE. The row also ⛔ never prescribes WHICH repair — choosing between a correction and a release would be choosing whether the card is being re-taken, which is the seat's judgement, so it names both and writes nothing.
  • 创业阶段不扩散需求 — the surface added is one file, one pure reader, one row, one record field. It refuses exactly one shape the protocol already forbade in writing and re-blocks no legal workflow: a card claimed once reads as it always did, a re-claim after a Release: reads as it always did, a Clause-②-correction: is not a claim and never was. The cross-seat question, which is a genuine second capability, is explicitly NOT taken here.

The pins — per direction, each with a non-vacuity control

direction reading
(a) same author, two claims, no retraction named, exit 4; the row carries both ids, the author and both repairs
(b) DIFFERENT authors supersession as today, exit 0 — ⛔ not this state
(c) same author after a Release: or the id-naming retraction RETRACTED as today, exit 0; the ⛔ NOT superseded wording byte-unchanged
(d) a Clause-②-correction: as the later row silent; the #17366 exit still reads the declaration off it
(e) a DECORATED second claim (bold, backticked) counted through markerMatches exactly as a bare one; the raw constant refuses both, so the counting is the sibling's ONE reading
(f) a second claim whose Branch: parses to zero branches still named — the prohibition is on the WRITING, not the parse
(g) three claims by one seat ONE refusal naming all three, not two
(h) an unattributable row (rowAuthor null) fail closed, as claimRetractions does — and null never groups with null
(i) a later same-author comment that QUOTES or DISCUSSES the word silent — the marker is read at line start

Direction (i) has a control in the wild on this very card: the triage comment 5722477144 contains the word Claim: mid-line, and markerMatches refuses it — card #18828 reads one claim comment, so --pair on this PR is silent.

Roster line (:792): '#18828: a SECOND \Claim:` by ONE seat — the writer-side prohibition, finally READ': 52— and the battery block declares exactly 52t(...)cases. **Floor** (:806):SELF_TEST_BATTERY_FLOOR` 31 → 32, raised by exactly one.

The ablation

Run from the committed fix, twice, each leg proving its mutation landed on disk before the reading was taken and proving its restore by an empty git diff HEAD and by blob hash — never by an editing command's exit code. HEAD blob 3a270ef2eb5f33780e04e4732714f8e88d74a017.

leg mutation mutated blob result
baseline none 3a270ef2eb… 941 cases pass, exit 0
A — the repeat detection neutered (a group is never reported) 72115d2796ead200f93aa855c8ba5820a83f5f8f 23 of 941 failed, exit 1
B — the SAME-AUTHOR check removed (the author no longer decides the grouping) 40cfadd4f5740f34210675ceb998fb2977823769 3 of 941 failed, exit 1

Both legs restored: git diff HEAD empty, blob back to 3a270ef2eb….

Total case count is 941 in all three runs — the rest of the self-test is byte-identical in its case count, and in both legs 0 of the failures fall outside the #18828 battery.

Leg A is the interesting one, because it shows the per-direction controls doing their job. Five of the nine directions assert SILENCE and therefore cannot go red when the detection is removed — their non-vacuity controls go red instead. All nine directions are carried:

  • pin itself red: (a) (e) (f) (g)
  • carried by its control: (b) (c) (d) (h) (i) — "make those two authors ONE", "drop the retraction", "write that same correction as a SECOND Claim:", "give that same row a login", "move that same word to the OPENING of a line"

Leg B is the narrower, sharper one: removing only the author test reds 3 cases, and pin (b) is among them. That is the pin which distinguishes this card from the cross-seat question — proof the author test is load-bearing and that (b) is not vacuous.

Self-test count: 889 before → 941 after (+52, exactly the registered battery). The 889 was measured by running --self-test in a detached worktree at origin/main 88aa326deb.

Gates

Derived from the worktree with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no hand-fed path list). ⚠️ The first derivation printed STALE TREE — the branch was 2 commits behind origin/main and 8 files the derivation reads had changed — so origin/main was merged in first and the list re-derived on the merged tree at 7424cf3f44; the --repo assertion holds against this checkout's origin.

34 derived, 34 run, all exit 0. Each exit code captured redirect-then-$?, never across a pipe.

node scripts/check-adr-0087-registration.mjs --base origin/main    :: exit 0
node scripts/check-adr-0087-registration.mjs --self-test           :: exit 0
node scripts/check-changeset-no-major.mjs --base origin/main       :: exit 0
node scripts/check-changeset-no-major.mjs --self-test              :: exit 0
node scripts/check-ci-filter-parity.mjs                            :: exit 0
node scripts/check-closing-keyword-parity.mjs                      :: exit 0
node scripts/check-closing-keyword-parity.mjs --self-test          :: exit 0
node scripts/check-comment-mask-corpus.mjs                         :: exit 0
node scripts/check-declaration-mirrors.mjs                         :: exit 0
node scripts/check-declaration-mirrors.mjs --self-test             :: exit 0
node scripts/check-scripts-symbol-anchors.mjs                      :: exit 0
node scripts/check-scripts-symbol-anchors.mjs --self-test          :: exit 0
node scripts/check-self-test-wired.mjs                             :: exit 0
node scripts/check-self-test-wired.mjs --self-test                 :: exit 0
node scripts/check-self-test-workflow-commands.mjs                 :: exit 0
node scripts/check-self-test-workflow-commands.mjs --self-test     :: exit 0
node scripts/check-whole-set-label-write.mjs                       :: exit 0
node scripts/check-whole-set-label-write.mjs --self-test           :: exit 0
node scripts/pm/bare-root-worklist.mjs --self-test                 :: exit 0
pnpm check:agent-test-spelling                                     :: exit 0
pnpm check:bash32-floor                                            :: exit 0
pnpm check:changeset-gate-self-tests                               :: exit 0
pnpm check:cli-command-ids                                         :: exit 0
pnpm check:cross-package-test-inputs                               :: exit 0
pnpm check:driver-memory-census                                    :: exit 0
pnpm check:entry-guard                                             :: exit 0
pnpm check:nul-bytes                                               :: exit 0
pnpm check:parse-guard                                             :: exit 0
pnpm check:pm-clause2-carriers                                     :: exit 0
pnpm check:pm-dispatch-gates                                       :: exit 0
pnpm check:pnpm-filter-targets                                     :: exit 0
pnpm check:ratchet-remedy-authority                                :: exit 0
pnpm check:refd-timer-probe                                        :: exit 0
pnpm check:watch-hint-literal                                      :: exit 0

Reconciled with --ran, exit codes included: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN — "a DERIVED zero — all 34 recorded an exit code and none of them is 3".

Repo-wide pnpm lint (eslint . --no-inline-config): exit 0. The heavy run took a ticket through scripts/pm/os-verify-lock.sh (slot issue-18828-dev), queued behind the seat's own dispatch-gates.mjs --self-test.

node scripts/pm/check-clause2-carriers.mjs --pair on this PR is reported in the dispatch report — this card carries one claim comment, so the row is silent on it.

skip-changeset: scripts/pm/** publishes nothing from any released package — the whole diff is one non-published script.


Generated by Claude Code

os-justin and others added 5 commits September 18, 2026 00:12
…ition gets its reader

The claim protocol forbids a second `Claim:` — the rule sits in this file's own
#17366 docblock — and the governing-claim selector READ one as a designed
SUPERSESSION: `rejected: 1 … a SUPERSEDED claim`, exit 0. Five cards carried the
forbidden second line at 2026-09-18T00:05Z and nothing refused any of them.

`claimRepeats` is a sibling pure reader beside `claimRetractions` (one
derivation of「retracted」, MEMBERSHIP first as #18719 set it) and names every
author holding more than one LIVE claim comment; `c8SecondClaimSameSeat` renders
it as row C8 — a VERDICT at EXIT_PAIR_ADVERSE, never a note — and the input
record gains `claim.repeat`, the reading the SUPERSEDED sentence used to be the
only trace of.

`CLAIM_COMMENT_MARKER` unwidened (read through the sibling's one reading),
`CLAIM_SELECTION_RULE` and the governing-claim choice unchanged for every other
shape, `claimCarrierSelection` still a pure function of the rows it is handed.

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

A new self-test battery (51 cases) registered in the roster, floor 31 -> 32:
same seat twice with no retraction ⇒ named at exit 4 with both ids and both
repairs; different authors ⇒ supersession as today; a re-claim after a
`Release:` or an id-naming retraction ⇒ RETRACTED as today, wording
byte-unchanged; a `Clause-②-correction:` row ⇒ silent; a decorated second claim
⇒ counted through the sibling's one reading; an unparsed `Branch:` ⇒ still
named; three claims ⇒ ONE refusal naming three; an unattributable row ⇒ fail
closed; a later comment that merely quotes the word ⇒ silent. Every direction
carries a non-vacuity control, and the five measured instances (2026-09-18T00:05Z)
are replayed from their real rows with #18559's correction as the control.

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

The first ablation leg aborted the whole run on a `[0].ids` read, which names no
pin at all; both reads are defensive now. Adds the case that makes the
fail-closed guard observable: two unattributable rows are not ONE seat, so
`null` never groups with `null`. Battery 51 -> 52 cases.

Claude-Session: https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu
Co-authored-by: Claude <noreply@anthropic.com>
@os-justin os-justin added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 18, 2026 — with Claude
@os-justin
os-justin marked this pull request as ready for review September 18, 2026 01:41
@os-justin
os-justin added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit e8ba892 Sep 18, 2026
37 checks passed
@os-justin
os-justin deleted the claude/issue-18828-second-claim-same-seat-refused branch September 18, 2026 02:30
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…d an 'end' node (objectstack-ai#18688)

Part of objectstack-ai#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`; **objectstack-ai#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 objectstack-ai#153 item 1, comment
`5724940095`, maintainer 「其他同意」):

- Inside `loop` / `parallel` branch / `try_catch` (try **and** catch)
bodies at any depth, `FlowSchema.superRefine` refuses **five** node
types: `screen`, `wait`, `approval`, `approval_revise`, and `end`.
- ⛔ **`map` and `subflow` are NOT refused by type.** They pause exactly
when the child flow their `config.flowName` names pauses — a *different
metadata record*, not in hand at parse. Refusing them by type would also
refuse `loop { map(synchronous child) }`, which runs correctly today.
- `packages/spec` keeps its published identifier
`FLOW_PAUSE_CAPABLE_NODE_TYPES`; only its **contents** narrow.
- **`packages/services` is untouched by this round** — measured, zero
paths. The 5-tests-in-3-files cost the section below describes **does
not occur**: the whole package runs **138 files / 1652 tests, all
passing**, with a false-green control proving the test read the rebuilt
artifact and not a stale `dist`.

⚠️ **Corrected by the seat, and it is a DECLARED DEVIATION from ruling
D's letter — ⛔ not a clean pass.** 「zero paths」 is true of **this
round's commits** and ⛔ NOT of the PR's cumulative diff:
`packages/services/service-automation/src/end-node-refused-outcome.test.ts`
(+57/−35) is in the diff, from the earlier round's commit `87973cab8d1`.
Ruling D clause 1 says 「`packages/services` untouched; **the 5 tests**
and objectstack-ai#15616's suite stand」 — and objectstack-ai#15788's region-`end` case **was one of
those 5**. ⇒ the ruling's premise 「B breaks nothing」 was **false for
that one case**: clause 1 itself orders `end` refused at parse, and
`registerFlow` parses, so the old run-time assertion is unreachable by
construction. What was done: the fixture is **byte-identical**, case
count **12 → 12**, and the assertion is **strengthened** (region path,
message text, and that nothing registered) so it fails again the day the
shape becomes declarable. ⛔ No test deleted, skipped or quarantined; ⛔
no engine source moved; objectstack-ai#15616's suite and the other three files are
untouched and green. The at-tier review measured all of this and ruled
it non-blocking — but it is a deviation and it is stated here rather
than buried.

**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 objectstack-ai#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: **objectstack-ai#18688 exit 4 on C8** — this seat held **two live `Claim:`
comments** on objectstack-ai#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 objectstack-ai#24, batch objectstack-ai#145 item 5 —
objectstack-ai#15646 (comment)
(maintainer 「同意,其他也同意」), with the batch objectstack-ai#146 scope addition —
objectstack-ai#15646 (comment)
(maintainer 「146 同意」), which attached objectstack-ai#3267's 禁 ruling and absorbed
objectstack-ai#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** | objectstack-ai#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 | objectstack-ai#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 | objectstack-ai#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) | objectstack-ai#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.** objectstack-ai#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 (objectstack-ai#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 objectstack-ai#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 (objectstack-ai#16314, objectstack-ai#14456) are a genuine re-home
onto a top-level delegating node, and `loop { subflow }` over five rows
with one failing is the shape objectstack-ai#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 objectstack-ai#15616's
and objectstack-ai#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:

- **A — judge the node TYPE** (what this PR implements). Closes this
card's own reproduction, covers all three region kinds, and is the only
reading under which C is the *complete* fix the ruling's own reasoning
requires. **Cost, measured:** it also refuses `loop { map(synchronous
child) }` — a shape that runs correctly today and was deliberately fixed
12 days ago by objectstack-ai#15616 / PR objectstack-ai#15648, whose regression suite is 3 of the 5
failures above.
- **B — judge only the UNCONDITIONALLY pausing types** (`screen` /
`wait` / `approval` / `approval_revise`) plus `end`. Refuses nothing
that works today, and the 3 `map` failures disappear. **Cost:** this
card's own reproduction — `loop { try_catch { map(pausing child) } }` —
stays declarable and stays silently green, so the card is not closed.

There is no third reading available to a parse. **Recommendation: A, as
implemented** — objectstack-ai#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 objectstack-ai#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 objectstack-ai#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:

- **a pause-capable node** in a region body — the message names the
node, the region scope (`loop 'sweep' body → try_catch 'guard' try`),
why a region body cannot host it, and the fix;
- **an `end` node** in a region body, whatever its `outcome` — an `end`
there was a no-op, and a refusing one was converted into a region error
at the same boundary (objectstack-ai#15788). The ruled prescription is the message: a
region body cannot end the run; put the `end` on the top-level graph.

`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). ⛔ objectstack-ai#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 objectstack-ai#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:

- The ruling's own parenthetical, "`map` / `subflow` **with a pausing
child**", describes the defect population rather than a decidable rule
population, and its "a shape the runtime never honoured" is exact for
`end`, `screen`, `wait` and the approval pair but not for a `map` over a
synchronous child, which runs today. The PR takes the capability reading
— the only one that makes C the complete fix the ruling's own reasoning
requires — and the changeset states the cost in the author's own terms.
Noted so a reviewer reads the widening deliberately rather than
discovering it.
- The card body and the batch objectstack-ai#145 ruling both say objectstack-ai#15617 is open; it is
**closed / completed**. Nothing here depends on it.
- `engine.ts:9937` in the ruling reads `engine.ts:9970` on this tree —
line numbers are clues, and this one was re-read rather than trusted.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho

---
_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

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants