Skip to content

feat(pm): H35 — flag a gate-label removal with no dual-carrier clear behind it - #12407

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-11881-gate-label-removal-patrol
Aug 26, 2026
Merged

feat(pm): H35 — flag a gate-label removal with no dual-carrier clear behind it#12407
os-steve merged 1 commit into
mainfrom
claude/issue-11881-gate-label-removal-patrol

Conversation

@claude

@claudeclaudeBot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Fixes#11881

Adds H35 to the half-state patrol: a report-only row that flags a gate-semantic label removal with no matching review-chain evidence behind it. Scope is exactly the triage seat's 2026-08-25 14:58Z ruling — detection only. No gate is weakened, no label is ever written, and escalation/enforcement stay out by that ruling's own words.

The gap this closes

H31 already compares the contract-review gate's two carriers, and its own header states what it cannot do: 「闸门被剥不是红灯是放行」 — a stripped gate is a green light, and 「被剥」 and 「从未挂过」 are indistinguishable in the state. Every reader in the file until now reads state. H35 reads the event that produced it, which makes it the file's first reader of history.

Transport: zero per-card timeline fetches

The dispatch brief hypothesised a bounded per-card timeline fetch. Measurement found a better shape and the row costs nothing per card: GET /repos/{repo}/issues/events is a repo-wide, newest-first stream carrying the full issue payload (number, state, current labels, body) on every row, so the whole population is one paginated window of the kind the file already keeps three of. Pull requests ride the same endpoint, which is what lets one window see both carriers of a dual-carrier gate.

The window is time-bounded (12h = two patrol cycles at the 6-hourly cadence) with a 30-page quota backstop, and a run that hits the cap says so in the summary line — a short window must never read as a clean board.

The evidence is structural, not prose — and the prose version was measured and rejected

references/contract-review.md names the evidence as 「PASS 评论 + 标签缺失 + PR head 自复审后未动」. Read literally that makes a PASS comment the discriminator, and it does not survive measurement: the verdicts are free prose whose wording varies card to card — **Contract review: PASS**, **Contract review — PASS**, ## Post-merge contract-review verdict: **PASS** were all live in one 18-hour window.

predicate over 35 card-side removalsevidence foundwould flag
strict Contract review: PASS530
loose contract review … PASS1025
any PASS token269

Widening the regex until the rest match is the tolerant-consumer antipattern this repo forbids by name, and the end state is worse than noise: the "any PASS" reading matched 26 of 35 including threads whose PASS was about something else — a check that can barely fail, which is the shape this file exists to catch rather than add.

So the row uses the protocol's other, machine-readable definition of the same event: 「PR 与卡双载体同笔挂」…「PASS 双载体同笔清标」. A legitimate clear leaves two removals, one per carrier, seconds apart, same actor. A strip leaves one.

The threshold is a reading, not a preference

Measured corpus: 160 pages, 16,000 events, 2026-08-22T15:35:07Z … 2026-08-26T01:26:58Z (3.41 days), 415 gate-label events, 206 removals. Gap from each removal to the nearest opposite-carrier removal by the same actor:

<=1s 50 | <=2s 65 | <=3s 5 | <=5s 21 | <=10s 16 | <=30s 11 | <=60s 4
(60,90]s 0 <- the distribution is EMPTY here
<=120s 4 | <=300s 1 | then 1000s, 3344s, ... hours

The same-stroke cluster ends at 101s; the next observation is 275s (a 2.7x jump). H35_SAME_STROKE_SECONDS = 120 sits inside that empty region, so no value between 102 and 274 classifies the corpus differently.

Three outcomes, never two (#4690)

A lone removal is not automatically a strip, and the corpus says so loudly: 34 of 36 lone removals were gates hung on the card only — the PR carrier never carried the label at all (verified per-PR: PR #12401 and PR #12287 have zero gate events in their entire history). For a single-carrier gate a lone clear is what a correct clear looks like, so flagging it would report the board's majority shape as a violation.

Each removal is therefore judged against its own hang, which the same window already carries:

  • half-write — hung across both carriers, cleared on one. The finding.
  • unjudgeable — hung lone, cleared lone. Reported as unjudged, not as clean.
  • undated — no hang inside the window. Declines, and is counted.
  • paired / rehung — silent (a re-hung label is the read-back working; a row for it would report the control as a defect).

Replayed against the real corpus with the shipped code

verdicts: {"paired":169,"unjudgeable":32,"rehung":3,"undated":2,"half-write":0}
ROWS EMITTED over 3.41 days: 3 => 0.22 per 6h run

The half-write class measured ZERO, and that is itself the result: where the dual-carrier discipline is actually followed it holds — 0 half-write clears in 206 removals. The exposure has moved to single-carrier gates, which no carrier comparison (H31's or this one's) can ever adjudicate. Closing that half needs a producer-side change — hang the PR carrier as 「PR 一存在即挂」 already requires, or give the verdict a canonical marker — which this row records rather than decides.

Because a zero-output class is indistinguishable from an unreachable one, the summary line carries the unjudgeable residue so a quiet H35 section cannot read as "the gate is watched".

Self-test, and the vacuity guard

1380 -> 1429 cases pass (+49). The suite includes an explicit vacuity guard, and the ablation the brief asks for was run against it — mutate the classifier so half-write is unreachable:

deleted-text occurrences : 0 (want 0) <- proof the mutation landed on disk
injected-text occurrences: 1 (want 1)
hash before=247f33f1... after=f897b4c0...
TypeError lines: 0 <- #12390's property, NOT regressed
x check-half-states self-test: 8 of 1429 case(s) failed.
x H35: hung dual + cleared lone -> half-write (got "unjudgeable", want "half-write")
x H35 vacuity guard: the half-write class is REACHABLE (got false, want true)

The suite ran to completion and named the rows under their own case names rather than aborting on a TypeError — the regression #12390 closed is intact. Restore proven by byte comparison, not by exit code: worktree blob 247f33f1ecdaea2f0bb8b94287a295d35a4d12c4 equals HEAD:scripts/pm/check-half-states.mjs, git diff HEAD empty, zero occurrences of the injected form.

Verification

All of the below was run on the final commit 5c14edb4 (tree clean, git status --porcelain empty), against merge base cdbd9204b.

Gate families derived from the actual diff (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, no paths — 1 path in the change set): 14 families, all green. Exit codes captured before any pipe via PIPESTATUS; verdict lines are each gate's own.

check:agent-test-spelling check:bash32-floor check:cli-command-ids
check:cross-package-test-inputs check:entry-guard check:parse-guard
check:partof-closing-keyword check:pm-half-states check:pnpm-filter-targets
check-ci-filter-parity check-closing-keyword-parity
check-cross-package-test-inputs check-partof-closing-keyword
check-half-states --self-test (1429 cases pass)

Heavy work went through scripts/pm/os-verify-lock.sh. The live sweep cannot run in an agent container (--probe -> exit 3, PREREQUISITE NOT MET, the transport note the file's own header documents), so the live path is CI's to prove; the predicate was instead replayed offline against the real 16,000-event corpus above.

Changeset

None, deliberately — and this is a repo convention read from the tree, not a judgement call: the last 7 scripts/pm/**-only merges all carry zero .changeset/ files. This PR publishes nothing, so it takes the skip-changeset label instead.

Scope

One file: scripts/pm/check-half-states.mjs (row + self-test). The fence held — scripts/pm/dispatch-gates.mjs is untouched (PR #12386 holds it, and it was only ever run here, never edited). half-state-patrol.yml needed no change: its issues: write permission already covers the issue-event endpoint, since a pull request is an issue to it — the one claim in this PR that only CI can settle.

Out of scope by the ruling and left alone: sections 1 and 2 of the card are complete records, not work.


Generated by Claude Code

…behind it
H31 compares the contract-review gate's two carriers as they STAND; its own
header says what it cannot do — 「闸门被剥不是红灯是放行」, and 「被剥」 and
「从未挂过」 are indistinguishable in the state. H35 reads the EVENT that
produced the state, which is the first reader of history in this file.
Transport: `GET /repos/{repo}/issues/events` is a repo-wide, newest-first
stream carrying the full issue payload, so this costs ZERO per-card timeline
fetches — the trade H15 and H16 decline by name. One time-bounded window
(12h = two patrol cycles) with a 30-page quota backstop that reports when it
binds.
Evidence is STRUCTURAL, not prose. The protocol's 「PASS 双载体同笔清标」 makes a
legitimate clear two removals seconds apart, one per carrier; a strip leaves
one. Measured (160 pages, 16,000 events, 3.41 days): the same-stroke cluster
ends at 101s and the next observation is 275s, so the 120s threshold sits in an
empty region. A PASS-comment predicate was measured and REJECTED — the verdicts
are free prose (three spellings live in one 18h window), a strict marker matched
5 of 35 removals and an "any PASS" reading matched 26, which is a check that can
barely fail.
Three outcomes, never two (#4690): `half-write` (hung dual, cleared lone) is the
finding; `unjudgeable` (hung lone, cleared lone — a single-carrier gate) is
reported AS unjudged, because 34 of 36 lone removals in the corpus were gates
the PR side never carried and flagging them would report the board's majority
shape as a violation; `undated` declines. Report-only throughout.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JANH3y7qe3MD8aLaLXci8N
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] A gate label removed by a SEAT is still undetectable — the whole-set-PUT gate cannot reach that surface

2 participants

@os-steve@claude