From 5c14edb4b75983a1addc37a7967f893c243e61f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 01:44:32 +0000 Subject: [PATCH] =?UTF-8?q?feat(pm):=20H35=20=E2=80=94=20flag=20a=20gate-l?= =?UTF-8?q?abel=20removal=20with=20no=20dual-carrier=20clear=20behind=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01JANH3y7qe3MD8aLaLXci8N --- scripts/pm/check-half-states.mjs | 590 +++++++++++++++++++++++++++++++ 1 file changed, 590 insertions(+) diff --git a/scripts/pm/check-half-states.mjs b/scripts/pm/check-half-states.mjs index 9fe2d93a27..247f33f1ec 100644 --- a/scripts/pm/check-half-states.mjs +++ b/scripts/pm/check-half-states.mjs @@ -5676,6 +5676,359 @@ export function h34ClaimShapedNonCanonicalSeparator(issue, commentBodies) { ); } +// --------------------------------------------------------------------------- +// H35 — a gate label REMOVED with no matching review-chain evidence (#11881). +// +// H31 above compares the gate's two carriers as they stand NOW and says, in its +// own header, exactly what it cannot do: 「闸门被剥不是红灯是放行」 — a stripped +// gate is a GREEN light, and 「被剥」 and 「从未挂过」 are indistinguishable in +// the evidence. A label that was removed is ABSENT, and absence has two causes. +// Every reader in this file until now has been a reader of STATE, so none of +// them can separate the two. This row reads the EVENT that produced the state. +// +// The filing card's measurement is what makes the question concrete: on PR +// #11470 the erasing actor was a SEAT (`claude[bot]`, 33 and 92 minutes after +// the Auto Label job), not a workflow — so the whole-set-PUT gate that shipped +// for that incident sweeps `.github/workflows/**`, `.github/actions/**` and +// `scripts/**` and cannot reach the actor at all. Seats write through the API at +// runtime. The compensating control the card asks for is DETECTION, and the +// triage ruling (2026-08-25 14:58Z) scoped this card to exactly that: a +// report-only patrol, adding detection and weakening no gate. ⛔ Escalation and +// enforcement are a LATER card and deliberately absent here. +// +// ## The transport, and why this row costs no per-card fetch +// +// The obvious reading — fetch each card's timeline — is the trade this sweep +// declines everywhere it arises (H15 declines it by name for the age of a +// label; H16's header forbids "fixing" its proxy with one). This row does not +// need it. `GET /repos/{repo}/issues/events` is a REPO-WIDE, newest-first +// stream of the same `labeled`/`unlabeled` rows, and it carries the full issue +// payload — number, state, CURRENT labels, body — on every row. So the whole +// population is one paginated window of the shape this file already keeps three +// of (H8's merged-PR window, H23's commit window, H22's closed-issue window), +// and the per-card cost is zero. PRs arrive through it too: a pull request IS +// an issue to this endpoint, which is what lets one window see both carriers of +// a dual-carrier gate. +// +// MEASURED 2026-08-26T01:26:58Z, 160 pages: 16,000 events spanning +// 2026-08-22T15:35:07Z … 2026-08-26T01:26:58Z = 3.41 days ⇒ ~4,691 events/day. +// +// ## What counts as "matching review-chain evidence" — and why it is STRUCTURAL +// +// `references/contract-review.md` names the evidence for a legitimate clear: +// 「PASS 评论 + 标签缺失 + PR head 自复审后未动 = 已复审清标,不是被剥」. Read +// literally that makes the discriminator a PASS COMMENT, and a predicate built +// on it does not survive measurement. The verdicts are free prose and their +// 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 — so over the 35 card-side removals in +// that window a strict marker matched 5 and a loose one matched 10. Widening +// the regex until the rest match is the tolerant-consumer antipattern this repo +// forbids by name, and its end state is worse than noise: an "any PASS token" +// reading matched 26 of 35, including threads whose PASS was about something +// else entirely — a check that can barely fail, which is the shape this file +// exists to CATCH rather than to add. +// +// The protocol leaves a second, MACHINE-READABLE definition of the same event, +// and this row uses that one: 「PR 与卡双载体同笔挂」…「PASS 双载体同笔清标」 — +// the gate is hung in one stroke and cleared in one stroke, ACROSS BOTH +// CARRIERS. A legitimate clear therefore leaves TWO removals, one per carrier, +// seconds apart, by the same actor. A strip leaves ONE. That is a structural +// invariant taken from the protocol's own words, not a parse of prose, and it +// is the reason this row can decline the comment fetch as well as the timeline +// fetch. +// +// ## The stroke window, derived from the measured gap distribution +// +// Same corpus, 206 gate removals: for each, the gap to the nearest removal on +// the OPPOSITE carrier kind 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 and the next observation is 275s — a +// 2.7x jump across an empty region. `H35_SAME_STROKE_SECONDS = 120` sits inside +// that gap, so the threshold is a reading rather than a preference: no value +// between 102 and 274 classifies the corpus differently. The tail past 120s is +// hours wide, which is a different stroke by any reading. The batching is why +// the cluster has width at all — the review Routine runs hourly and 「每小时一轮 +// 即天然攒批」, and a measured batch cleared 4 PRs + 4 cards in 14 seconds. +// +// ## 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 that had been hung on the CARD ONLY — the +// PR carrier never carried the label at all (verified per-PR: PR #12401 and PR +// #12287, the two most recent, have zero gate events in their entire history). +// For a single-carrier gate there is no second carrier to clear, so a lone +// clear is exactly what a CORRECT clear looks like, and flagging it would +// report the majority shape of the board as a violation. +// +// So the removal is judged against its own HANG, which the same window already +// carries: a gate hung in a dual stroke and cleared in a lone one is the pair +// written half way — that is the finding. A gate hung lone and cleared lone is +// internally consistent and gets the file's UNJUDGED treatment instead of a +// verdict: there is no structural evidence in EITHER direction, which is not +// the same as evidence of correctness, and 「read, and it carries nothing」 vs +// 「could not be read」 is the pair this whole file refuses to conflate. A +// removal whose hang predates the window is `undated` and is counted, never +// guessed at. +// +// ⚠️ The unjudgeable class is the honest residue of this card, and it is the +// LARGER half: measured 29 over 3.41 days against 0 half-writes. Where the +// dual-carrier discipline is actually followed it holds — 0 half-write clears +// in 206 removals — and 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 (a canonical machine-readable verdict, or +// the PR-side hang that 「PR 一存在即挂」 already requires), which is a decision +// this row records rather than takes. +// +// ## What is reported, and the deliberate asymmetry in the two classes +// +// `half-write` is reported for any carrier, open or closed: a gate cleared half +// way on a PR that then merged is the bypass that already happened, and the +// population is ~0/day so it cannot flood the report. `unjudgeable` is reported +// only while the carrier is still OPEN and the label still ABSENT — that is the +// subset a reader can still act on, and it is the difference between 0.22 rows +// per run and 8.5. Measured live subset: 3 cards over 3.41 days. +// +// A removal whose label is back is SILENT in both classes. That is the +// read-back working — the card's own §2 names the 13-minute re-application on +// #11470 as「consistent with an accidental loss caught by read-back」— and a row +// for it would report the control functioning as a defect. +// +// Report-only, and emphatically: like H31 this row's subject is a GATE. ⛔ Never +// a label written from this script — a sweeper that re-hung a review gate would +// be issuing the review verdict, which is 自查放行. +// --------------------------------------------------------------------------- + +/** + * The gate-semantic label family this row patrols. + * + * MEASURED on the live repo 2026-08-26 (`GET /labels`, 57 labels): the family + * has exactly ONE member. It is a LIST rather than the bare constant because + * the ruling names a family and the next gate label must join it here rather + * than fork a row — but the list is not speculative padding, and + * `needs-user-decision` deliberately stays out of it: it marks a card awaiting + * a maintainer, not a review chain with a dual-carrier hang/clear protocol, so + * the same-stroke invariant below is meaningless for it. + */ +export const GATE_SEMANTIC_LABELS = [CONTRACT_REVIEW_LABEL]; + +/** Is this label one the row patrols? */ +export function isGateSemanticLabel(name) { + return GATE_SEMANTIC_LABELS.includes(String(name ?? '')); +} + +/** + * How far apart two carrier writes can be and still be 「同笔」 — 120s, read + * out of the empty region between the measured 101s and 275s (header above). + */ +export const H35_SAME_STROKE_SECONDS = 120; + +/** + * The issue-event production rate, MEASURED — the divisor the window below + * uses, in the same executable shape H8's window uses `MEASURED_MERGES_PER_DAY`. + * + * read 2026-08-26T01:26:58Z, `GET /repos/{repo}/issues/events`, 160 pages + * window 2026-08-22T15:35:07Z … 2026-08-26T01:26:58Z (3.41 days) + * rows 16,000 events, of which 415 carried a gate-semantic label + * rate 16,000 / 3.41 = ~4,691 events/day + */ +export const MEASURED_ISSUE_EVENTS_PER_DAY = 4691; + +/** + * The detection horizon — 12h, i.e. TWO patrol cycles at the 6-hourly cadence. + * + * One cycle would put every removal within one run of aging out, so a single + * failed or skipped run loses the finding permanently (this is a horizon, not a + * retry budget — H8's window states the same thing). Two cycles means every + * removal is seen by at least two consecutive runs. Past the horizon the + * finding is not delayed, it is gone: nothing else in this file reads events. + */ +export const H35_EVENT_WINDOW_HOURS = 12; + +/** + * The quota backstop, in pages of 100. + * + * At the measured rate the horizon needs `eventWindowPages()` = 24 pages; the + * cap is 30, which absorbs a day ~25% busier than the corpus before truncating. + * A run that HITS the cap has a short window, and the summary line says so — + * a truncated window must never read as a clean one (#4690). + */ +export const H35_EVENT_PAGE_CAP = 30; + +/** + * Pages of 100 needed to cover `hours` at the measured event rate. The + * arithmetic is executable rather than prose for the reason `windowCoverageDays` + * exists: a rate that moves must move the derivation with it, where a test can + * see it. + */ +export function eventWindowPages( + hours = H35_EVENT_WINDOW_HOURS, + ratePerDay = MEASURED_ISSUE_EVENTS_PER_DAY, + perPage = 100, +) { + if (!Number.isFinite(hours) || !Number.isFinite(ratePerDay) || ratePerDay <= 0) return null; + if (!Number.isFinite(perPage) || perPage <= 0) return null; + return Math.ceil(((hours / 24) * ratePerDay) / perPage); +} + +/** Every `labeled`/`unlabeled` event in a window that carries a gate-semantic label. */ +export function gateLabelEvents(events) { + return (events ?? []).filter( + (e) => + e && + (e.event === 'labeled' || e.event === 'unlabeled') && + isGateSemanticLabel(e.label?.name), + ); +} + +/** Is this event row on a PULL REQUEST carrier rather than a card? */ +function eventOnPullRequest(event) { + return Boolean(event?.issue?.pull_request); +} + +/** Does the carrier this event names still carry the label the event moved? */ +function carrierStillLabelled(event) { + const name = String(event?.label?.name ?? ''); + return (event?.issue?.labels ?? []).some((l) => l?.name === name); +} + +/** + * Is `event` half of a 「同笔」 dual-carrier stroke? True when the SIBLING + * carrier saw the same verb, on the same label, by the same actor, within the + * stroke window. + * + * `siblingNumbers` resolves the other carrier and is INJECTED rather than + * derived here: the sweep answers it with `prDeliversCard` over the PR windows + * it already holds, which is the same delivery relation H8 and H31 read — so + * the three rows can never disagree about which PR delivers which card. It + * returns `null` when the relation is unresolvable (no delivering PR in the + * windows, a body that declares nothing), and an unresolvable sibling means NOT + * PAIRED — which routes the removal to a judged-against-its-hang path below, + * never straight to a finding. + */ +export function pairedAcrossCarriers(event, gateEvents, options = {}) { + const { sameStrokeSeconds = H35_SAME_STROKE_SECONDS, siblingNumbers = () => null } = options; + const at = Date.parse(event?.created_at ?? ''); + if (!Number.isFinite(at)) return false; + const siblings = siblingNumbers(event); + if (!Array.isArray(siblings) || siblings.length === 0) return false; + const wanted = new Set(siblings.map((n) => Number(n))); + const actor = String(event?.actor?.login ?? ''); + const label = String(event?.label?.name ?? ''); + const onPr = eventOnPullRequest(event); + return (gateEvents ?? []).some((o) => { + if (!o || o === event) return false; + if (o.event !== event.event) return false; + if (String(o.label?.name ?? '') !== label) return false; + if (String(o.actor?.login ?? '') !== actor) return false; + if (eventOnPullRequest(o) === onPr) return false; + if (!wanted.has(Number(o.issue?.number))) return false; + const t = Date.parse(o.created_at ?? ''); + return Number.isFinite(t) && Math.abs(t - at) <= sameStrokeSeconds * 1000; + }); +} + +/** The most recent hang of the same label on the same carrier BEFORE `removal`. */ +export function precedingHang(removal, gateEvents) { + const at = Date.parse(removal?.created_at ?? ''); + if (!Number.isFinite(at)) return null; + const label = String(removal?.label?.name ?? ''); + const number = Number(removal?.issue?.number); + const hangs = (gateEvents ?? []) + .filter( + (e) => + e && + e.event === 'labeled' && + String(e.label?.name ?? '') === label && + Number(e.issue?.number) === number && + Number.isFinite(Date.parse(e.created_at ?? '')) && + Date.parse(e.created_at) < at, + ) + .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)); + return hangs[0] ?? null; +} + +/** + * H35's classifier — the three-valued half, asserted directly by the self-test. + * + * @returns one of: + * `'not-applicable'` the row is not an `unlabeled` of a gate-semantic label + * `'rehung'` the carrier carries the label again (read-back worked) + * `'paired'` cleared in a 「同笔」 dual-carrier stroke — the evidence + * `'half-write'` hung in a dual stroke, cleared in a lone one — FINDING + * `'unjudgeable'` hung lone and cleared lone (single-carrier gate) + * `'undated'` no hang inside the window; declines to judge + */ +export function h35RemovalVerdict(removal, gateEvents, options = {}) { + if (!removal || removal.event !== 'unlabeled') return 'not-applicable'; + if (!isGateSemanticLabel(removal.label?.name)) return 'not-applicable'; + if (carrierStillLabelled(removal)) return 'rehung'; + if (pairedAcrossCarriers(removal, gateEvents, options)) return 'paired'; + const hang = precedingHang(removal, gateEvents); + if (!hang) return 'undated'; + return pairedAcrossCarriers(hang, gateEvents, options) ? 'half-write' : 'unjudgeable'; +} + +/** Shared tail — the posture, stated on every row this block emits. */ +const H35_CONTRACT = + 'Report-only: ⛔ never a label written from this script — re-hanging a review gate from a sweeper ' + + 'would be issuing the verdict, which is 自查放行. Detection only; escalation and enforcement are ' + + 'a later card by the 2026-08-25 ruling.'; + +/** + * H35 — null when the removal needs no row, else the finding sentence. + * + * Two classes reach a row, and they say different things on purpose. The + * `open`/`absent` narrowing applies to `unjudgeable` ONLY and the header states + * why: that class is common and actionable only while the carrier is live, + * while `half-write` is ~0/day and names damage that may already have landed. + * + * @param {object} removal — an `unlabeled` event row from the repo-wide window. + * @param {object[]} gateEvents — every gate-semantic label event in that window. + */ +export function h35GateRemovalWithoutEvidence(removal, gateEvents, options = {}) { + const verdict = h35RemovalVerdict(removal, gateEvents, options); + const label = String(removal?.label?.name ?? ''); + const actor = String(removal?.actor?.login ?? 'an unreadable actor'); + const at = String(removal?.created_at ?? 'an unreadable time'); + const carrier = eventOnPullRequest(removal) ? 'PULL REQUEST' : 'CARD'; + + if (verdict === 'half-write') { + return ( + `\`${label}\` was REMOVED from this ${carrier} by \`${actor}\` at ${at} in a LONE stroke, ` + + 'while the hang it clears was written across BOTH carriers — so the pair was cleared half way. ' + + '「PASS 双载体同笔清标」 makes a legitimate clear two removals seconds apart, one per carrier; ' + + 'this one has no sibling within ' + + `${H35_SAME_STROKE_SECONDS}s. Either the clear never reached the second carrier, or the label ` + + 'was stripped — and 「闸门被剥不是红灯是放行」, so the failure direction is TOWARD release: an ' + + 'ungated carrier reads to the enqueue path as one that was never gated. Remedy is a READ, not a ' + + 'write: check the card thread for a current review verdict before re-hanging — 「PASS 评论 + 标签' + + `缺失 + PR head 自复审后未动 = 已复审清标,不是被剥」. ${H35_CONTRACT}` + ); + } + + if (verdict === 'unjudgeable') { + if (removal?.issue?.state !== 'open') return null; + return ( + `\`${label}\` was removed from this open ${carrier} by \`${actor}\` at ${at} and the gate is ` + + 'still absent — UNJUDGED, not clean. The hang it clears was ALSO a lone stroke: this gate only ' + + 'ever had ONE carrier, so 「双载体同笔清标」 leaves no structural evidence in either direction ' + + 'and no carrier comparison — H31\'s or this row\'s — can say whether it was cleared or stripped. ' + + 'The only remaining evidence is a review verdict written as free prose, which has no canonical ' + + 'machine-readable form (measured: a strict marker matched 5 of 35 removals, a loose one 10), so ' + + 'this row declines to parse it rather than widen into a check that cannot fail. Two producer-side ' + + 'repairs would each make this judgeable: hang the PR carrier as 「PR 一存在即挂」 already ' + + `requires, or give the verdict a canonical marker. ${H35_CONTRACT}` + ); + } + + return null; +} + // --------------------------------------------------------------------------- // Report rendering — pure over (findings, counts), so `--self-test` pins both // media offline. The live sweep below picks a renderer and prints it; nothing @@ -5860,6 +6213,20 @@ export function summaryLine(counts, findingCount) { // (a ruling) rather than a transient failure worth re-running. const crossRepoProbed = counts.crossRepoProbed ?? 0; const crossRepoUnreadable = counts.crossRepoUnreadable ?? 0; + // H35's event window (#11881). Reported as a pair for the same reason every + // pair above is: this is the file's ONLY reader of event history, so if the + // window came up short there is no second reader to notice. `truncated` means + // the page cap bound before the horizon was reached — the run saw less than + // its stated 12h and must not read as a board with no gate removals in it. + // The `unjudgeable` count is carried into the summary deliberately: it is the + // measured residue of this row (29 over the 3.41-day derivation corpus, against + // 0 half-writes), and burying it would let a quiet H35 section read as "the + // gate is watched" when most removals are structurally unwatchable. + const gateRemovals = counts.gateRemovals ?? 0; + const gateEventPages = counts.eventPages ?? 0; + const gateUnjudgeable = counts.gate_unjudgeable ?? 0; + const gateUndated = counts.gate_undated ?? 0; + const gateWindowTruncated = Boolean(counts.eventWindowTruncated); // H32's coverage pair — held, own-board seats and how many had their marker // thread read. A shortfall is not silent (an unread thread makes H32 DECLINE // to judge that seat, which is the quiet direction), so this is the only @@ -5924,6 +6291,19 @@ export function summaryLine(counts, findingCount) { 'lane is countable on THIS board — a seat held for a sibling repo\'s lane is out of scope here (its ' + 'inventory is unreadable from this sweep, so an empty-looking queue would mean nothing), and an ' + 'unread thread makes H32 decline to judge that seat rather than accuse it. ' + + `Gate-removal patrol (H35): ${gateRemovals} removal(s) of a gate-semantic label read from ` + + `${gateEventPages} page(s) of the repo-wide issue-event stream over the last ` + + `${H35_EVENT_WINDOW_HOURS}h — no per-card timeline fetch. ${gateUnjudgeable} of them are ` + + 'UNJUDGEABLE (a gate that only ever had ONE carrier leaves 「双载体同笔清标」 no evidence in ' + + 'either direction, so neither H31 nor H35 can say cleared-or-stripped)' + + `${gateUndated > 0 ? `, and ${gateUndated} more had no hang inside the window` : ''}` + + `${ + gateWindowTruncated + ? ` ⚠️ The event window was TRUNCATED at the ${H35_EVENT_PAGE_CAP}-page cap before reaching ` + + `the ${H35_EVENT_WINDOW_HOURS}h horizon — this run saw LESS than its stated window, so a ` + + 'quiet H35 section here is a short read, not a clean board.' + : '.' + } ` + `Report-only: findings are patrol input, not a gate verdict.` ); } @@ -7021,6 +7401,45 @@ async function listRecentDefaultBranchCommits() { return out; } +/** + * H35's repo-wide issue-event window — the ONLY reader of event history in this + * file, and deliberately not a per-card timeline fetch (H35's header carries + * the reasoning; H15 and H16 decline the per-card shape by name). + * + * TIME-bounded with a PAGE cap behind it, rather than pages alone. The stream + * is strictly newest-first, so the horizon is reached by reading until a row + * predates it — on a quiet stretch that is two pages, and the cap only binds + * when the board is busier than the corpus it was derived from. Both bounds are + * reported: `eventPages` counts what was read and `eventWindowTruncated` says + * the horizon was NOT reached, because a short window that reads as a clean one + * is the #4690 direction this file refuses everywhere. + * + * PRs ride this endpoint too (a pull request is an issue to it), which is what + * lets one window see BOTH carriers of a dual-carrier gate. + */ +async function listRecentIssueEvents(stats = {}, nowMs = Date.now()) { + const horizon = nowMs - H35_EVENT_WINDOW_HOURS * 3_600_000; + const out = []; + let reachedHorizon = false; + let page = 1; + for (; page <= H35_EVENT_PAGE_CAP; page++) { + const batch = await rest(`/repos/${OWNER_REPO}/issues/events?per_page=100&page=${page}`); + out.push(...batch); + const oldest = Date.parse(batch[batch.length - 1]?.created_at ?? ''); + if (batch.length < 100 || (Number.isFinite(oldest) && oldest <= horizon)) { + reachedHorizon = true; + break; + } + } + stats.eventPages = Math.min(page, H35_EVENT_PAGE_CAP); + stats.eventRows = out.length; + stats.eventWindowTruncated = !reachedHorizon; + return out.filter((e) => { + const at = Date.parse(e?.created_at ?? ''); + return Number.isFinite(at) && at > horizon; + }); +} + /** * The unscoped listing H13 needs: the domain-without-pm-state shape is * DEFINED by the absence of every label the listings below key on, so no @@ -7426,6 +7845,39 @@ async function sweepInto(findings, seen, seenPrs, seenMerged, seenUnscoped, seen if (gateSplit) findings.push([issue, 'H31', gateSplit]); } + // H35 (#11881) — the EVENT behind the state H31 compares. One repo-wide + // window, no per-card fetch; the sibling resolver below is `prDeliversCard` + // over the two PR windows this sweep already holds, so H8, H31 and H35 read + // ONE delivery relation and can never disagree about which PR delivers which + // card. An unresolvable sibling returns null and the removal is judged + // against its own hang instead — never straight to a finding. + const prWindow = [...mergedWindow, ...openWindow]; + const siblingNumbers = (event) => { + const number = Number(event?.issue?.number); + if (!Number.isFinite(number)) return null; + if (event?.issue?.pull_request) { + const pr = prWindow.find((p) => Number(p?.number) === number); + if (!pr) return null; + const cards = [...seenUnscoped.keys(), ...seen.keys()].filter((n) => + prDeliversCard(pr, String(n)), + ); + return cards.length > 0 ? cards : null; + } + const prs = prWindow.filter((p) => prDeliversCard(p, String(number))).map((p) => p.number); + return prs.length > 0 ? prs : null; + }; + const eventWindow = await listRecentIssueEvents(stats); + const gateEvents = gateLabelEvents(eventWindow); + stats.gateLabelEvents = gateEvents.length; + const removals = gateEvents.filter((e) => e.event === 'unlabeled'); + stats.gateRemovals = removals.length; + for (const removal of removals) { + const verdict = h35RemovalVerdict(removal, gateEvents, { siblingNumbers }); + stats[`gate_${verdict.replace(/-/g, '_')}`] = (stats[`gate_${verdict.replace(/-/g, '_')}`] ?? 0) + 1; + const row = h35GateRemovalWithoutEvidence(removal, gateEvents, { siblingNumbers }); + if (row) findings.push([removal.issue, 'H35', row]); + } + // H14 + H15 — the same unscoped listing, read a second way. It is the right // population for BOTH halves and neither label page could substitute: a // `pm:blocking` card need carry no other label (so the label pages above can @@ -11250,6 +11702,144 @@ function selfTest() { t('H34: the near-miss marker carries no `g` flag', CLAIM_NEAR_MISS_MARKER.global, false); t('H34: …so repeated reads of one line agree', nearMissClaimSeparators(DASH_CLAIM).join() === nearMissClaimSeparators(DASH_CLAIM).join(), true); + // -- H35 — a gate label removed with no matching review-chain evidence ----- + // -- (#11881). Fixtures are event rows in the repo-wide stream's shape. + const gateEvent = (over, { n, pr = false, actor = 'os-seat', at, labels = [] }) => ({ + event: over, + label: { name: CONTRACT_REVIEW_LABEL }, + actor: { login: actor }, + created_at: at, + issue: { + number: n, + state: 'open', + labels: labels.map((name) => ({ name })), + pull_request: pr ? { url: 'x' } : undefined, + html_url: `https://example.invalid/${n}`, + }, + }); + // The delivery relation, stubbed: card 900 <-> PR 901. + const sib900 = (e) => (e.issue.pull_request ? [900] : [901]); + const o900 = { siblingNumbers: sib900 }; + const h35row = (...args) => String(h35GateRemovalWithoutEvidence(...args) ?? ''); + + // The healthy shape: hung across both carriers, cleared across both. + const dualHang = [ + gateEvent('labeled', { n: 900, at: '2026-08-26T10:00:00Z' }), + gateEvent('labeled', { n: 901, pr: true, at: '2026-08-26T10:00:02Z' }), + ]; + const dualClear = [ + gateEvent('unlabeled', { n: 900, at: '2026-08-26T12:00:00Z' }), + gateEvent('unlabeled', { n: 901, pr: true, at: '2026-08-26T12:00:03Z' }), + ]; + const healthy = [...dualHang, ...dualClear]; + t('H35: a dual-carrier clear is the review-chain evidence -> silent', h35RemovalVerdict(dualClear[0], healthy, o900), 'paired'); + t('H35: …and emits no row', h35GateRemovalWithoutEvidence(dualClear[0], healthy, o900), null); + t('H35: …on the PR carrier too', h35RemovalVerdict(dualClear[1], healthy, o900), 'paired'); + + // ⭐ THE FINDING: hung in a dual stroke, cleared on ONE carrier only. + const halfWrite = [...dualHang, dualClear[0]]; + t('H35: hung dual + cleared lone -> half-write', h35RemovalVerdict(dualClear[0], halfWrite, o900), 'half-write'); + t('H35: …and the row fires', typeof h35GateRemovalWithoutEvidence(dualClear[0], halfWrite, o900), 'string'); + t('H35: …naming the carrier it was removed from', h35row(dualClear[0], halfWrite, o900).includes('REMOVED from this CARD'), true); + t('H35: …and the actor', h35row(dualClear[0], halfWrite, o900).includes('`os-seat`'), true); + t('H35: …and the failure direction is toward release', h35row(dualClear[0], halfWrite, o900).includes('闸门被剥不是红灯是放行'), true); + t('H35: …and the remedy is a READ, not a re-hang', h35row(dualClear[0], halfWrite, o900).includes('Remedy is a READ, not a write'), true); + t('H35: …and it states the report-only posture', h35row(dualClear[0], halfWrite, o900).includes('never a label written from this script'), true); + // The half-write class is NOT narrowed to open carriers — a gate cleared half + // way on a PR that then merged is the bypass that already happened. + const closedCarrier = { ...dualClear[0], issue: { ...dualClear[0].issue, state: 'closed' } }; + t('H35: a half-write on a CLOSED carrier still reports', typeof h35GateRemovalWithoutEvidence(closedCarrier, [...dualHang, closedCarrier], o900), 'string'); + + // The single-carrier gate — the measured majority shape, and NOT a finding. + const loneHang = gateEvent('labeled', { n: 900, at: '2026-08-26T10:00:00Z' }); + const loneClear = gateEvent('unlabeled', { n: 900, at: '2026-08-26T12:00:00Z' }); + const singleCarrier = [loneHang, loneClear]; + t('H35: hung lone + cleared lone -> unjudgeable, never a violation', h35RemovalVerdict(loneClear, singleCarrier, o900), 'unjudgeable'); + t('H35: …and the row says UNJUDGED rather than clean', h35row(loneClear, singleCarrier, o900).includes('UNJUDGED, not clean'), true); + t('H35: …and names both producer-side repairs', h35row(loneClear, singleCarrier, o900).includes('PR 一存在即挂'), true); + t('H35: …and refuses to parse the prose verdict', h35row(loneClear, singleCarrier, o900).includes('declines to parse it'), true); + // …but only while the carrier is live: the narrowing that keeps this class at + // ~0.22 rows/run instead of ~8.5 (header's measured figures). + const closedLone = { ...loneClear, issue: { ...loneClear.issue, state: 'closed' } }; + t('H35: an unjudgeable clear on a CLOSED carrier emits no row', h35GateRemovalWithoutEvidence(closedLone, [loneHang, closedLone], o900), null); + t('H35: …though it still classifies as unjudgeable', h35RemovalVerdict(closedLone, [loneHang, closedLone], o900), 'unjudgeable'); + + // Re-hung: the read-back worked. Reporting it would call the control a defect. + const rehung = gateEvent('unlabeled', { n: 900, at: '2026-08-26T12:00:00Z', labels: [CONTRACT_REVIEW_LABEL] }); + t('H35: a removal whose label is BACK -> rehung, silent', h35RemovalVerdict(rehung, [...dualHang, rehung], o900), 'rehung'); + t('H35: …and emits no row', h35GateRemovalWithoutEvidence(rehung, [...dualHang, rehung], o900), null); + + // Three input states, never two (#4690): no hang in the window = decline. + t('H35: a removal with no hang in the window -> undated, not a finding', h35RemovalVerdict(dualClear[0], [dualClear[0]], o900), 'undated'); + t('H35: …and emits no row', h35GateRemovalWithoutEvidence(dualClear[0], [dualClear[0]], o900), null); + // An unresolvable sibling must not manufacture a finding: it degrades to the + // hang comparison, which for a lone hang is `unjudgeable`. + t('H35: an unresolvable sibling degrades, never accuses', h35RemovalVerdict(loneClear, singleCarrier, { siblingNumbers: () => null }), 'unjudgeable'); + + // Scope: only `unlabeled`, only gate-semantic labels. + t('H35: a `labeled` row is not applicable', h35RemovalVerdict(dualHang[0], healthy, o900), 'not-applicable'); + const otherLabel = { ...loneClear, label: { name: 'size/l' } }; + t('H35: a non-gate label is not applicable', h35RemovalVerdict(otherLabel, [otherLabel], o900), 'not-applicable'); + t('H35: `needs-user-decision` is deliberately NOT in the family', isGateSemanticLabel('needs-user-decision'), false); + t('H35: the gate label IS', isGateSemanticLabel(CONTRACT_REVIEW_LABEL), true); + t('H35: the family and H31 share ONE constant', GATE_SEMANTIC_LABELS.includes(CONTRACT_REVIEW_LABEL), true); + + // ⚠️ THE VACUITY GUARD. H35's half-write class measured ZERO over the 3.41-day + // derivation corpus — a true reading of a board where the dual-carrier + // discipline holds, and indistinguishable from a predicate that CANNOT fire. + // These two cases are the difference, and they must be read as a pair: the + // classifier reaches `half-write` on a constructed input, and a mutation that + // makes the row go permanently silent turns them red HERE rather than passing + // as a quiet board. ⛔ Do not delete either one to make an ablation quieter. + t('H35 vacuity guard: the half-write class is REACHABLE', h35RemovalVerdict(dualClear[0], halfWrite, o900) === 'half-write', true); + t('H35 vacuity guard: …and produces a non-empty row', h35row(dualClear[0], halfWrite, o900).length > 0, true); + // The stroke window is a threshold read out of an EMPTY region of the measured + // distribution (101s .. 275s), so these two pin both of its sides. + const slowPair = [ + ...dualHang, + dualClear[0], + gateEvent('unlabeled', { n: 901, pr: true, at: '2026-08-26T12:01:30Z' }), + ]; + t('H35: a 90s dual clear is still ONE stroke (inside the 120s window)', h35RemovalVerdict(dualClear[0], slowPair, o900), 'paired'); + const hoursApart = [ + ...dualHang, + dualClear[0], + gateEvent('unlabeled', { n: 901, pr: true, at: '2026-08-26T15:00:00Z' }), + ]; + t('H35: a clear hours later is NOT the same stroke', h35RemovalVerdict(dualClear[0], hoursApart, o900), 'half-write'); + // 「同笔」 is one actor's stroke — a different login is not the same write. + const otherActor = [ + ...dualHang, + dualClear[0], + gateEvent('unlabeled', { n: 901, pr: true, actor: 'os-other', at: '2026-08-26T12:00:03Z' }), + ]; + t('H35: a different actor is not 同笔', h35RemovalVerdict(dualClear[0], otherActor, o900), 'half-write'); + + // The window derivation, executable rather than prose (H8's `windowCoverageDays` shape). + t('H35: the horizon is TWO patrol cycles', H35_EVENT_WINDOW_HOURS / PATROL_CADENCE_HOURS, 2); + t('H35: 12h at the measured rate needs 24 pages', eventWindowPages(), 24); + t('H35: …and the cap leaves headroom above that', H35_EVENT_PAGE_CAP > eventWindowPages(), true); + t('H35: a zero rate cannot divide, and says so', eventWindowPages(12, 0), null); + t('H35: the stroke window sits inside the measured empty region', H35_SAME_STROKE_SECONDS > 101 && H35_SAME_STROKE_SECONDS < 275, true); + // The window filter is a TIME horizon; the page cap is only its backstop. + t('H35: gateLabelEvents keeps both verbs', gateLabelEvents(healthy).length, 4); + t('H35: …and drops non-gate labels', gateLabelEvents([...healthy, otherLabel]).length, 4); + t('H35: …and drops non-label events', gateLabelEvents([...healthy, { event: 'closed' }]).length, 4); + + // The summary line carries the residue, so a quiet section cannot read as + // "the gate is watched" when most removals are structurally unwatchable. + const gateCounts = { gateRemovals: 7, eventPages: 24, gate_unjudgeable: 5, gate_undated: 1 }; + t('H35 summary: the removal count is reported', summaryLine(gateCounts, 1).includes('7 removal(s) of a gate-semantic label'), true); + t('H35 summary: …with the pages read', summaryLine(gateCounts, 1).includes('24 page(s) of the repo-wide issue-event stream'), true); + t('H35 summary: …and states it made no per-card fetch', summaryLine(gateCounts, 1).includes('no per-card timeline fetch'), true); + t('H35 summary: …and carries the unjudgeable residue', summaryLine(gateCounts, 1).includes('5 of them are UNJUDGEABLE'), true); + t('H35 summary: …and the undated count', summaryLine(gateCounts, 1).includes('1 more had no hang inside the window'), true); + t('H35 summary: a truncated window is announced, never silent', summaryLine({ ...gateCounts, eventWindowTruncated: true }, 0).includes('TRUNCATED'), true); + t('H35 summary: …and says a quiet section is a SHORT READ', summaryLine({ ...gateCounts, eventWindowTruncated: true }, 0).includes('short read, not a clean board'), true); + t('H35 summary: an untruncated window makes no such claim', summaryLine(gateCounts, 1).includes('TRUNCATED'), false); + // Absent counts degrade to 0, never to `undefined` — H32's pair does the same. + t('H35 summary: absent counts degrade to 0', summaryLine({}, 0).includes('0 removal(s) of a gate-semantic label'), true); + // -- The `[::]` collapse (#12090): behaviour-preserving, asserted as such --- // The class held U+003A TWICE, never the fullwidth U+FF1A its shape implied. // These cases pin that the collapse changed nothing a reader could observe.