Skip to content

fix(pm): three machine-judgeable half-state rows — pm-state ONE-OF, pm:queue rot, contract-review carrier pair (H29/H30/H31) - #11884

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-11179-half-state-rules
Aug 25, 2026
Merged

fix(pm): three machine-judgeable half-state rows — pm-state ONE-OF, pm:queue rot, contract-review carrier pair (H29/H30/H31)#11884
hotlong merged 1 commit into
mainfrom
claude/issue-11179-half-state-rules

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes#11179

Three new report-only rows on the half-state patrol — the pm-state ONE-OF
invariant, pm:queue rot, and the contract-review gate's two carriers — plus a
measured verdict on the two families this card proposed that turn out not to
need code. One file, scripts/pm/check-half-states.mjs.

Inventory first: what was already there

Re-verified against origin/main (4019e16c) before anything was written, as
the re-dispatch ordered:

familyverdict on main
assigned ⊕ pm:queueshipped — H24
pm:queue + pm:dispatchedshipped — H3
pm-state exclusivity, pm:awaiting-maintainer carriershipped — H25
general pm-state ONE-OFmissing ⇒ H29 below
pm:queue rotmissing ⇒ H30 below
contract-review paired carriermissing ⇒ H31 below
H14 strip-when-emptyshipped — see below, recorded rather than re-landed
freshness (conclusion older than the body's last edit)not machine-judgeable here — see below

AC1 — H29: the pm state labels are ONE-OF, generally

H3 and H25 are both this invariant, each pinned to the one carrier that was
measured drifting. Between them the vocabulary has six members and fifteen
pairs, and eleven pairs had no reader at all — including the two this card
was filed on (pm:queue × needs-user-decision, where the queue label's own
definition is 「无可问之事」; and pm:queue × pm:blocked, the dual-hang that
sat three days). H29 asks the invariant itself.

Acceptance: a card carrying two pm state labels produces exactly one row,
naming both claims rather than both labels — and the pairs another row already
owns are reported by that row and not here. H3's pair and every pair containing
pm:awaiting-maintainer are excluded, each pinned in both directions
(silent here, and the owning row does fire on the same card). A card carrying
three states still reports the pairs nobody else owns rather than going quiet
because one of them fired.

Live specimen on today's board, pinned as a fixture: #11534 carries
needs-user-decision + pm:blocked — a third pair, in a third direction, which
is the argument for asking the invariant instead of pinning pairs one at a time.

The vocabulary is derived from H25's list rather than re-typed
(PM_EXCLUSIVE_STATE_LABELS = [AWAITING_MAINTAINER_LABEL, ...AWAITING_MAINTAINER_EXCLUSIVE_LABELS]),
so the two rows cannot disagree about what a pm state is. It is the third
pm:* label set in this file and it is deliberately none of the other two —
H13's visibility set carries finding/pm:epic/pm:seat, H22's residue set
carries pm:blocking and drops needs-user-decision. All three pairings are
pinned in the self-test, the same guard H22 already keeps against H13's set.

AC2 — H30: a pm:queue card rotting unclaimed

Acceptance: an open pm:queue card with no activity past the horizon
produces one report-only row that names the age, names the threshold, and asks
for one explicit transition — never a grade, never a label from the script.
An unreadable updated_at flags rather than reading as fresh (#4690).

The horizon is 3 days, and the argument is a measurement, not a precedent
copy.
The aging shape is H11's — updated_at, report-only, threshold named
in the row. The number is not, and reusing H11's 7 days would have been the
mistake: 7d is calibrated for a parked state, where waiting is legal.
pm:queue is the one active state, where waiting is the defect. Measured
over all 40 open pm:queue cards on 2026-08-24:

idle>1d>2d>3d>4d>5d>7d
cards17108430

At 7d the row cannot fire on today's board at all — a check that cannot fail is
the shape this file exists to catch, not to add. At 1d it reports 43% of the
queue, which is queue depth. 3d clears the ordinary depth, still exceeds the
measured three-day dual-hang this card was filed on, and names 8 of 40 — a
minority a human can walk. Five live rows are pinned as fixtures (three fire,
two stay clean), so the calibration cannot rot silently.

Adjacency pinned: H24 is silent (no assignee), H18 is silent (no pm:retriage),
H11 is silent (pm:queue is not a parked state) — which is why this needed a
row rather than a widening.

AC3 — H31: the contract-review gate carried on ONE of its two carriers

needs:contract-review is a dual carrier (maintainer 2026-08-22,
「两边都挂好」): hung in one stroke, cleared in one stroke, each carrier written
read-modify-write with a read-back. Two writes, one postcondition, and
nothing ever checked the pair. The measured miss: a PASS verdict was posted, the
PR carrier was cleared, the card carrier was not.

Acceptance: when a delivering open PR exists, the card's carrier and the
PR's carrier are compared and any disagreement produces one row naming the PR.
Both directions are covered, and the sentence says which one it is — the
card-side hang (a gate outliving a passed review) and, the dangerous one, a gate
present on the PR and absent on the card, because 「闸门被剥不是红灯是放行」: a
stripped gate is a green light and 「被剥」 and 「从未挂过」 are
indistinguishable in the evidence. A row comparing carriers is the only reader
that can tell them apart.

Live specimen, pinned: card #11427 carries the gate; its delivering draft PR
#11844 carries documentation, size/l, dependencies, tests, tooling and not
the gate.

Deliberately NOT landed — the companion "premature hang" rule. The
re-dispatch's inventory mentioned flagging a gated card with no PR carrier. That
shape is the protocol's own prescribed sequence:
references/contract-review.md says 「PR 一存在即挂,报告先于 PR 到达则先挂卡侧、
ACCEPT 时补齐 PR 侧」. Reporting it would report correct behaviour as a defect.
The other live carrier, #10025 (gated, pm:blocked, no open PR), is pinned
as a clean case for exactly that reason.

Cost, declared: zero requests. The open-PR listing is already in hand
(H7/H12/H21 list it; H8 already passes it around for the same question), and the
delivery relation is prDeliversCard — H8's, shared rather than re-derived, so
the two rows can never disagree about which PR delivers which card. Merged PRs
are out of scope by decision: the gate governs the open PR, and a merged carrier
is a closed-out stroke rather than a live half-write. A PR row whose labels is
not an array is excluded from the comparison rather than counted as bare —
reading an unreadable carrier as an absent one would manufacture a gate finding
out of a read failure.

AC4 — H14's strip-when-empty: already shipped, recorded not re-landed

The proposal was "recompute the dependent set each patrol and STRIP when it is
empty or every named dependent is closed/404". H14's stale direction is
exactly that and has been on main since before this card:
carries && dependents.length === 0 fires, and because buildBlockingIndex is
built from the open listing, a dependent that has been closed or 404s
contributes no edge and lands in that branch by construction. The shipped row is
also better calibrated than the proposal in two ways the proposal did not
have: it is suspended when the index is known incomplete (unreadable evidence is
not absent evidence), and it states its repo-local boundary instead of claiming
exhaustiveness — cross-repo Blocked-by: edges are protocol-legal. Re-landing
it would have been a duplicate row on one fact. No code; recorded here.

AC5 — the freshness warning: the premise does not hold on this transport

The card proposes: 「结论型评论时间戳早于卡正文最后编辑时间戳」 → warn. The
fact is judgeable only if both stamps are readable, and one of them is not.
This file already carries the measurement, in the Blocked-by: channel's
"Stated boundary" note: an issue's body-edit time is on no REST payload at
all
(the issues API carries created_at/updated_at only; body edit history
lives behind GraphQL userContentEdits; the timeline endpoint does not record
body edits either), and this file is REST-only by a standing operational note.
The one proxy in reach, updated_at, is worse than nothing — it bumps when a
comment is posted, so "newer than the body's last edit" is false for every
comment ever written and the check would read nothing while looking like it read.

So the honest options are all decisions, not implementations: move this file off
REST for one row, accept a proxy the file already ruled worse than nothing, or
record the measurement and leave the freshness duty with the seat protocol. No
code was written on a guess
— the open question goes back with the report. A
predicate that cannot fail is the shape this file exists to catch.

references/state-machine.md: zero lines, argued

No doc row is added, and this is a deliberate call rather than an omission. The
file sits at its ratchet ceiling (43, headroom 0), so a row must be paid for by
consolidation — and every line still available for consolidation is inside the
in-flight hunks of PR #11874
, which is ACCEPTed and awaiting human merge and
which pays for its own two rows out of the same three stale status notes. Paying
twice from one purse is a guaranteed textual conflict with a PR this card was
told to compose cleanly with. The general ONE-OF rule is already stated in that
file for the pm:awaiting-maintainer carrier, and that section names
check-half-states.mjs as the authority for enforcement detail — which is where
this diff puts it. The header catalogue inside the script is skipped for the
same reason (its H28 entry occupies the identical anchor), following the
existing precedent that H21, H22 and H23 have no catalogue entry either; every
new row carries its full rationale at the predicate site, as H24–H27 do.

Reverse verification

Self-test 1058 → 1149 cases (+91). Three ablations, each anchored on the
exact text intended to change, each in a script carrying an
EXIT INT TERM trap that restores the file, each with the mutation proven on
disk
by counting the injected and the deleted text separately, and each
restore verified byte-identical with cmp against the pre-mutation copy. No
build/dist leg applies — this is a plain .mjs the self-test imports from
source.

#mutationon diskdirection
1H29's pair collection neutered (if (pairs.length === 0)if (true))injected 1 / deleted 0RED15 of 1149 case(s) failed
2H31's card-side-first early return removed (=== 0if (false))injected 1 / deleted 0GREEN — reported as observed, see below
3H31's bare-carrier filter neutered (barePrs[])injected 1 / deleted 0RED11 of 1149 case(s) failed

Ablation 2's green is a real measurement, not a failed run. Removing the
early return does not change any verdict, because the two reporting branches
both require a non-empty PR set: the card-side-first silence is
over-determined. The line stays — it states the protocol rule at the point a
future third branch would otherwise silently violate it — but a reviewer should
know it is documentary rather than load-bearing today, which is why ablation 3
was run against a predicate that is.

A note on the assertion shape. The observation recorded on PR #11874
predicate(...).includes(...) turns a null-return regression into a TypeError
crash instead of a named case — is honoured for the new blocks: every message
assertion here goes through a helper that checks typeof first and returns a
describing string. It shows in the ablation output, which reads
got "NO MESSAGE (null)", want true on 20 of the 26 failing rows rather than
dying at the first one. The existing H26 block is not restructured — that is
a diff of its own, and this card had to merge cleanly onto PR #11874.

One honest correction to my own proof. Ablation 2's first on-disk check
counted if (delivering.length === 0) return null; and reported 1 after a
successful mutation — because H8 carries the identical line. The count was not
anchored tightly enough to be evidence; what actually proved uniqueness was the
mutation script's own assert count == 1 against the full line including its
trailing comment
. Ablation 3's proof is anchored on text unique to the target.

Gates

Derived at b83214bf with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no path
args — the script took its own change set from the merge base). It printed
"--repo 'objectstack-ai/objectstack' checked against this checkout's 'origin'
remote — it holds"
and "change set derived from git — 1 path(s) vs merge base
4019e16"
, matching 11 families. Exit codes captured before any pipe
(redirect-then-capture). Every line below is the gate's own verdict:

  • ✓ check-half-states self-test: 1149 cases pass. (was 1058)
  • ✓ check-agent-test-spelling: 0 violations — 357 file(s) · 3459 bare -- token(s) · 1109 launcher-rooted run(s) · 8 separator(s) JUDGED
  • OK: 16 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob. (cross-package-test-inputs, both invocations)
  • ✓ check:entry-guard: 148 scripts/ file(s) — every entry guard goes through invoked-as.mjs; 105 export bindings, 95 of them inert on import
  • ✓ check:parse-guard: 147 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs.
  • ✓ check-partof-closing-keyword self-test: 28 cases pass.
  • check-closing-keyword-parity: OK (3 parsers agree on all 9 keywords and both measured separators; sweep found 5 file(s) carrying the grammar across 6619 tracked file(s), all registered).
  • ✓ check:pnpm-filter-targets: 135/168 --filter occurrence(s) across 26 file(s) resolve against 78 workspace package(s)
  • check-nul-bytes: OK (scanned 6614 text file(s) -- 6614 tracked, 0 untracked-not-ignored; skipped 5 binary; no raw ASCII control bytes). — run on the standing clause, not because the derivation named it

The 11th matched row is node scripts/pm/check-half-states.mjs under
half-state-patrol.yml — the live network sweep, not a local gate; its
offline half is check:pm-half-states above. Invoking
scripts/check-partof-closing-keyword.mjs directly first reported
NOT WIRED — neither PR_BODY nor PR_NUMBER is set … This is a wiring or usage failure, NOT a verdict; it is green when handed this body, and its self-test
form is green above.

skip-changeset applies — the diff is scripts/pm/** only, so it publishes
nothing.

Notes for the reviewer


Generated by Claude Code

…ier pair machine-judgeable (H29/H30/H31)
Part of the half-state patrol's rule families: the general pm-state
exclusivity row, the pm:queue staleness row, and the dual-carrier
contract-review gate comparison. Report-only, no label ever written from
the script.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMTpSRF5CjMmQBFfPtPCwJ
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(pm): pm:queue 半态「已判未转」——两条可机判的 H 规则,加 #11086 的一条输入

3 participants

@os-zhuang@hotlong@claude