From 260a96e4d53fe274ec093942b7771f60d6e613bd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 09:44:00 +0000 Subject: [PATCH] =?UTF-8?q?fix(pm):=20tell=20a=20completed=20clause-?= =?UTF-8?q?=E2=91=A1=20review=20from=20a=20gate=20that=20never=20ran?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--pair` answered C3 / exit 4 on a legitimately cleared pair: a `Clause-②: yes` declaration is history and stays on the thread, while the label is state that a completed review clears from both carriers by rule, so every pair that completes its review landed in exactly C3's trigger shape. Read literally, the landing check's precondition (2) could only be satisfied between the review PASS and the label clear — the wrong order. C3 now reads the `needs:contract-review` label EVENT STREAM on both carriers and answers four distinguishable states: never hung (the fail-open, unchanged), hung-then-cleared with the head unmoved (the completed state, clean), hung-then-cleared with the head moved since (the re-hang-owed state), and bound on one carrier only (the strip signature). An unreadable stream is UNJUDGED, never clean. No verdict comment is read: the PASS conjunct of the recovery rule stays human. No spelling is relaxed, and the tool still writes nothing. Event streams are fetched only for pairs already in C3's candidate shape, so sweep cost stays bounded. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Msg17tAHJ3jVTYFgHydCm2 --- scripts/pm/check-clause2-carriers.mjs | 414 ++++++++++++++++++++++++-- 1 file changed, 391 insertions(+), 23 deletions(-) diff --git a/scripts/pm/check-clause2-carriers.mjs b/scripts/pm/check-clause2-carriers.mjs index 14f4dce5d6..7339615543 100644 --- a/scripts/pm/check-clause2-carriers.mjs +++ b/scripts/pm/check-clause2-carriers.mjs @@ -91,6 +91,47 @@ * the one thing the whole clause-② chain forbids. Same call H31 makes, for the * same reason. * + * **It does not read verdict comments, and the PASS half of the recovery rule + * stays human.** `references/contract-review.md` states the human reading as + * 「PASS + 无标 + head 未动 = 已清标非被剥;head 后移或无结论才重挂」. C3 + * mechanizes the second and third conjuncts — 无标, from the labels, and + * head 未动, from the head commit's date — and deliberately not the first: + * #12409 measured a strict `Contract review: PASS` marker at 5 of 35 removals + * against 26 for an "any PASS token" reading, i.e. a check that can barely fail, + * and reading a verdict to clear a gate is the 自查放行 this file already + * refuses. Consequence for a reader of exit 0: it means "the gate was bound by + * the discipline and cleared, and nothing has moved since", NEVER "the review + * passed". Precondition ① of the landing check is still a human reading a PASS + * comment on the card. + * + * ## How a COMPLETED review is told from a gate that never ran (#14155) + * + * A `Clause-②: yes` declaration is HISTORY — it stays on the thread forever. The + * label is STATE — a completed review clears it from both carriers, by rule. So + * every clause-② pair that completes its review lands in exactly the shape C3 + * hunts: declared `yes`, gate on neither carrier. Measured 2026-09-01 on PR + * #13864 / card #13657 — PASS at head `9af92aa3`, both carriers cleared nine + * seconds apart, C3 fired on the legitimate clear. Read literally that made the + * landing check's precondition ② unsatisfiable after its own clear: `--pair` + * could only answer 0 in the window BETWEEN the PASS and the clear, which is the + * wrong order, and every landed pair kept its C3 row on every later sweep. + * + * The evidence that separates the two needs no verdict comment: the **label + * event stream** on each carrier. A gate that was bound leaves a `labeled`; a + * review that completed leaves an `unlabeled`; a fail-open leaves neither. C3 + * therefore reads both carriers' streams and answers four distinguishable + * states — never hung (the fail-open, the row as it was), hung-then-cleared with + * the head unmoved (the completed state: clean), hung-then-cleared with the head + * moved since (the 重挂-owed state), and bound on one carrier only (one removal + * where a legitimate clear leaves two — the strip signature). An unreadable + * stream is UNJUDGED, never clean. + * + * **The cost stays bounded**: streams are fetched for pairs already in C3's + * candidate shape (declared `yes`, bare on both carriers) and for nobody else, + * and the head commit only once both carriers read cleared. `needsGateHistory` + * is the one predicate the reader and the UNJUDGED accounting share, so the set + * that owes a stream and the set that gets one cannot drift apart. + * * ## Exit codes — the refusal to read as clean, in one table * * Sweep mode (default, and `--json`): @@ -112,7 +153,14 @@ * `--pair` is a PREDICATE about the pair named on the command line — a fact * about THAT PR, which is why it may answer adversely where the sweep may not. * It shares 1/2/3 with the sweep and adds one code of its own: - * 0 the pair's clause-② limbs are legible and its carriers agree. + * 0 the pair's clause-② limbs are legible and its carriers agree — which + * INCLUDES the completed state: a declared `yes` whose gate was hung on + * both carriers, cleared from both, and whose head has not moved since + * (#14155). ⚠️ 0 is not "the review passed"; the PASS reading is human and + * is precondition ① of the landing check, not this exit code. + * 2 also the answer when a C3 candidate's event stream or head commit could + * not be read: an unread stream is not a never-hung gate, so it is UNJUDGED + * rather than either verdict. * 4 they do not. Deliberately NOT 3: a verdict about the PAIR must be * impossible to confuse with "the environment could not answer", so a * seat reading `$?` cannot turn a refusal into a clearance. And ⛔ never @@ -139,6 +187,7 @@ import { EXIT_PREREQUISITE_NOT_MET, PROXY_FLAG, governingClaim, + isGateSemanticLabel, labelNames, prDeliversCard, proxyRearmPlan, @@ -443,30 +492,196 @@ export function c2DeclarationUnreadable(pair) { } } +// --------------------------------------------------------------------------- +// The gate's HISTORY — what tells a completed review from one that never ran +// --------------------------------------------------------------------------- + /** - * C3 — a declared `yes` with the gate on NEITHER carrier. + * The gate's history on ONE carrier, read from that carrier's own label events. + * + * ⚠️ The rows arrive OLDEST FIRST, so a caller that reads only the first page + * of a long carrier gets the oldest events and never sees the removal — which + * reads as "still hung" or "never hung" depending on the truncation point. The + * live reader below therefore pages to exhaustion and hands `null` when it + * could not; ⛔ a short read is never a history. * - * The fail-open direction, and the one a carrier COMPARISON is structurally - * blind to: agreement on absence is H31's silent case. Measured live on PR - * #13910 / card #13476 (2026-08-31), where the author had identified their own - * change as clause-② and written it down, and not one of the three mechanisms - * that exist to catch that was in a state to fire. + * Sorted here rather than trusted: the endpoint's order is documented, but this + * function is also fed self-test fixtures, and a reading that depends on the + * caller's ordering is a reading that breaks silently when the caller changes. + * + * @param {{event?: string, created_at?: string, label?: {name?: string}, + * actor?: {login?: string}}[]|null} events — that carrier's event + * rows, or `null` when the stream could NOT be read. + * @returns {{ state: 'never-hung' } + * | { state: 'hung', at: string, actor: string|null } + * | { state: 'cleared', at: string, actor: string|null } + * | { state: 'unreadable' }} */ -export function c3DeclaredYesUngated(pair) { +export function carrierGateHistory(events) { + if (!Array.isArray(events)) return { state: 'unreadable' }; + const gate = []; + for (const e of events) { + if (!e || (e.event !== 'labeled' && e.event !== 'unlabeled')) continue; + // The SAME label constant H31 and H35 read, imported, never restated. + if (!isGateSemanticLabel(e.label?.name)) continue; + const ms = Date.parse(e.created_at ?? ''); + // ⛔ An undated gate event is not a droppable row: dropping it would move + // the "last event" and could turn a live hang into a clear. Refuse instead. + if (!Number.isFinite(ms)) return { state: 'unreadable' }; + gate.push({ verb: e.event, at: String(e.created_at), actor: e.actor?.login ?? null, ms }); + } + if (gate.length === 0) return { state: 'never-hung' }; + gate.sort((a, b) => a.ms - b.ms); + const last = gate[gate.length - 1]; + return { state: last.verb === 'labeled' ? 'hung' : 'cleared', at: last.at, actor: last.actor }; +} + +/** + * Is this pair in C3's candidate shape — the ONLY shape whose event streams are + * read? + * + * The cost bound, stated as a predicate so the live reader and the UNJUDGED + * accounting cannot disagree about which pairs owe an event stream. A pair that + * declared `no`, or that still carries the gate on either carrier, is answered + * from the labels alone and costs nothing extra. + */ +export function needsGateHistory(pair) { const d = cardDeclaration(pair?.cardComments ?? null); - if (d.state !== 'declared' || d.value !== 'yes') return null; + if (d.state !== 'declared' || d.value !== 'yes') return false; const onCard = gated(pair?.cardLabels); const onPr = gated(pair?.prLabels); - if (onCard === null || onPr === null) return null; - if (onCard || onPr) return null; - return ( - `card #${pair.card} declares \`Clause-②: yes\` while NEITHER it nor its delivering open PR ` + - `#${pair.pr}${pair.draft ? ' (draft)' : ''} carries \`${CONTRACT_REVIEW_LABEL}\` — the gate ` + - 'the declaration is supposed to bind is absent from both carriers, so the content limb fired ' + - 'in prose and nothing downstream is holding the door. ⚠️ A comparison of the two carriers ' + - 'cannot see this: agreement on ABSENCE is its silent case, which is why the declaration is ' + - `read here rather than inferred from the labels. ${NEVER_WRITES}` - ); + if (onCard === null || onPr === null) return false; // already UNJUDGED on labels. + return !onCard && !onPr; +} + +/** + * What the two event streams say about the gate this declaration should bind. + * + * ⭐ The distinction #14155 filed: a `Clause-②: yes` declaration is HISTORY and + * stays on the thread forever, while the label is STATE that a completed review + * clears from both carriers by rule — so every pair that completes its review + * lands in the same shape as the fail-open C3 hunts, and before this function + * the two were indistinguishable. The label EVENTS separate them without going + * anywhere near a verdict comment: a gate that was bound leaves a `labeled`, and + * a review that completed leaves an `unlabeled` on each carrier. + * + * ⛔ This is NOT the PASS half. The recovery rule in `references/contract-review.md` + * reads 「PASS + 无标 + head 未动 = 已清标非被剥」; the PASS conjunct stays human, + * because #12409 measured PASS-token matching as a check that can barely fail + * and this file's own header bans verdict-reading as 自查放行. What is + * mechanized here is the other two conjuncts — 无标 and head 未动 — and a clean + * answer therefore means "the gate was bound and cleared by the discipline, and + * nothing has moved since", never "the review passed". + * + * @returns {{ state: 'not-candidate' } + * | { state: 'unreadable', gaps: string[] } + * | { state: 'never-hung' } + * | { state: 'still-hung' } + * | { state: 'half-bound', bound: 'card'|'pr', at: string } + * | { state: 'completed', clearedAt: string } + * | { state: 'moved-after-clear', clearedAt: string, headAt: string }} + */ +export function gateBindingState(pair) { + if (!needsGateHistory(pair)) return { state: 'not-candidate' }; + const card = carrierGateHistory(pair?.cardEvents ?? null); + const pr = carrierGateHistory(pair?.prEvents ?? null); + + const gaps = []; + if (card.state === 'unreadable') gaps.push(`card #${pair?.card}'s label event stream`); + if (pr.state === 'unreadable') gaps.push(`PR #${pair?.pr}'s label event stream`); + if (gaps.length > 0) return { state: 'unreadable', gaps }; + + if (card.state === 'never-hung' && pr.state === 'never-hung') return { state: 'never-hung' }; + // Both carriers are bare NOW (that is the candidate shape), so a trailing + // `labeled` means the stream and the labels disagree — a read this file will + // not reconcile by picking a winner. + if (card.state === 'hung' || pr.state === 'hung') return { state: 'still-hung' }; + if (card.state === 'never-hung' || pr.state === 'never-hung') { + const bound = card.state === 'cleared' ? 'card' : 'pr'; + return { state: 'half-bound', bound, at: bound === 'card' ? card.at : pr.at }; + } + + // Both cleared. The conservative anchor is the EARLIER removal: a review + // concluded before either carrier was touched, so motion after the first + // clear is motion after the conclusion. + const clearedMs = Math.min(Date.parse(card.at), Date.parse(pr.at)); + const clearedAt = Date.parse(card.at) <= Date.parse(pr.at) ? card.at : pr.at; + const headAt = pair?.headCommittedAt ?? null; + const headMs = Date.parse(headAt ?? ''); + if (!Number.isFinite(headMs)) { + return { state: 'unreadable', gaps: [`PR #${pair?.pr}'s head commit date`] }; + } + if (headMs > clearedMs) return { state: 'moved-after-clear', clearedAt, headAt: String(headAt) }; + return { state: 'completed', clearedAt }; +} + +/** + * C3 — a declared `yes` whose gate is on NEITHER carrier, refined by the event + * stream into the states that shape actually covers. + * + * The fail-open direction is the one a carrier COMPARISON is structurally blind + * to: agreement on absence is H31's silent case. Measured live on PR #13910 / + * card #13476 (2026-08-31), where the author had identified their own change as + * clause-② and written it down, and not one of the three mechanisms that exist + * to catch that was in a state to fire. + * + * ⚠️ But bare-on-both is also where a LEGITIMATELY cleared pair lands, measured + * on PR #13864 / card #13657 (2026-09-01, #14155): review PASS at head + * `9af92aa3`, both carriers cleared in one stroke nine seconds apart, and C3 + * fired on the completed state. Read literally, that made the landing check's + * ② unsatisfiable after its own clear — the check could only pass in the window + * between PASS and clear, which is the wrong order. The event stream is what + * tells the two apart, so this row now fires on three distinguishable adverse + * states and stays silent on the completed one. + */ +export function c3DeclaredYesUngated(pair) { + const binding = gateBindingState(pair); + if (binding.state === 'not-candidate') return null; + if (binding.state === 'unreadable') return null; // UNJUDGED by the caller — never silently clean. + if (binding.state === 'completed') return null; // ⭐ #14155: the completed state, and the only clean one. + + const head = `card #${pair.card} declares \`Clause-②: yes\` while NEITHER it nor its delivering open PR #${pair.pr}${pair.draft ? ' (draft)' : ''} carries \`${CONTRACT_REVIEW_LABEL}\``; + const readsEvents = + 'The label EVENT STREAM on both carriers is what separates this from a legitimately cleared ' + + 'pair — a completed review clears the gate from both carriers by rule, so the declaration ' + + '(history, permanent) outliving the label (state, cleared) is the NORMAL end state and is not ' + + 'reported here. ⛔ No verdict comment is read to reach that: the PASS half stays human.'; + + switch (binding.state) { + case 'moved-after-clear': + return ( + `${head} — the gate WAS bound and cleared (last removal ${binding.clearedAt}), but the PR's ` + + `head has MOVED since: its head commit is dated ${binding.headAt}. The review that cleared ` + + 'this gate judged a different tree, so the clear no longer covers what would land. This is ' + + 'the 重挂-owed state the recovery rule already names — 「head 后移或无结论才重挂」 — and ' + + `the re-hang is a seat's act, not this script's. ${readsEvents} ${NEVER_WRITES}` + ); + case 'half-bound': + return ( + `${head} — and the gate was bound on the ${binding.bound === 'card' ? 'CARD' : 'PR'} carrier ` + + `only (cleared ${binding.at}), with no hang ever recorded on the other. ${DUAL_CARRIER} One ` + + 'removal where a legitimate clear leaves two is the strip signature, so this pair cannot be ' + + 'read as a completed review. ⚠️ The repo-wide, windowed form of this question is H35 in ' + + '`check-half-states.mjs`, which judges removals against the 同笔 stroke window; this row is ' + + 'the per-pair form and is unbounded in time, so it also sees a pair gated long before that ' + + `window opened. ${readsEvents} ${NEVER_WRITES}` + ); + case 'still-hung': + return ( + `${head} — yet the label event stream ends on a HANG for at least one carrier, so the two ` + + 'readings of the same gate disagree: the labels say bare, the events say hung. One of them ' + + 'is stale, and this file does not pick a winner by fiat — re-read both carriers before ' + + `treating this pair as either gated or clear. ${readsEvents} ${NEVER_WRITES}` + ); + default: + return ( + `${head}, and the event stream shows the gate was NEVER HUNG on either carrier — the ` + + 'gate the declaration is supposed to bind was never bound at all, so the content limb fired ' + + 'in prose and nothing downstream is holding the door. ⚠️ A comparison of the two carriers ' + + 'cannot see this: agreement on ABSENCE is its silent case, which is why the declaration is ' + + `read here rather than inferred from the labels. ${readsEvents} ${NEVER_WRITES}` + ); + } } /** Every row for one pair, in reporting order. */ @@ -493,6 +708,13 @@ export function pairUnjudged(pair) { if (!Array.isArray(pair?.cardLabels)) gaps.push(`card #${pair?.card}'s labels`); if (!Array.isArray(pair?.prLabels)) gaps.push(`PR #${pair?.pr}'s labels`); if (!Array.isArray(pair?.cardComments)) gaps.push(`card #${pair?.card}'s comment thread`); + // The gate history is owed by C3 CANDIDATES only — the cost bound and the + // accounting read one predicate, so a pair can never owe a stream the live + // reader was never going to fetch. An unread stream is not a never-hung gate. + if (gaps.length === 0) { + const binding = gateBindingState(pair); + if (binding.state === 'unreadable') gaps.push(...binding.gaps); + } if (gaps.length === 0) return null; return ( `pair PR #${pair?.pr} / card #${pair?.card} — UNJUDGED: ${gaps.join(', ')} could not be read. ` + @@ -627,12 +849,61 @@ async function listOpenPulls(repo) { return out; } +/** + * The page cap on ONE carrier's label event stream. + * + * Events arrive OLDEST FIRST, so the reading needs the LAST page, not the + * first: a stream read short does not merely lose detail, it loses the removal + * and reads as a live hang or a never-hung gate. Ten pages is 1,000 events on a + * single card, well past anything the board produces; a carrier that exceeds it + * is answered `null` → UNJUDGED, never clean (#4690). + */ +export const EVENT_PAGE_CAP = 10; + +/** + * One carrier's gate history, paged to exhaustion — or `null`. + * + * ⛔ Never a partial array: a caller cannot tell a short read from a quiet + * carrier, and the whole point of this stream is to distinguish "no hang" from + * "hang I did not read". + */ +async function readCarrierEvents(repo, number) { + const out = []; + for (let page = 1; page <= EVENT_PAGE_CAP; page++) { + const batch = await restOrNull(`/repos/${repo}/issues/${number}/events?per_page=100&page=${page}`); + if (!Array.isArray(batch)) return null; + out.push(...batch); + if (batch.length < 100) return out; + } + return null; // cap hit: the tail is unread, so the history is unread. +} + +/** + * The head commit's date, for the head-motion half — or `null`. + * + * ⚠️ The boundary, stated where the reading is taken: this is COMMIT METADATA, + * not a push timestamp. A normal push writes the committer date at push time, + * which is what makes the reading work; a force-push that lands a deliberately + * backdated commit would read as unmoved. The unforgeable alternative + * (`head_ref_force_pushed`) lives only on the TIMELINE endpoint, which caps at + * 250 events and truncates SILENTLY — and a capped read backing a CLEAN verdict + * is the fail-open shape this family refuses everywhere else, so the exact, + * uncapped, one-request reading is preferred with its residual hole named. The + * PASS conjunct of the recovery rule stays human and is what closes it. + */ +async function readHeadCommitDate(repo, sha) { + if (!sha) return null; + const commit = await restOrNull(`/repos/${repo}/commits/${sha}`); + return commit?.commit?.committer?.date ?? null; +} + /** * Gather the pairs and everything each row needs. * * A PR's delivering card is read from the PR body/branch, so the card set is * whatever those name — no open-issue listing is paged for it. That keeps the - * sweep's cost at one PR listing plus two reads per pair. + * sweep's cost at one PR listing plus two reads per pair, plus — for the C3 + * candidates ALONE — their two event streams and one head commit. */ async function gather(repo, prFilter = null) { const pulls = (await listOpenPulls(repo)).filter((pr) => (prFilter ? pr.number === prFilter : true)); @@ -650,12 +921,29 @@ async function gather(repo, prFilter = null) { pr: pr.number, draft: Boolean(pr.draft), card: Number(n), + headSha: pr?.head?.sha ?? null, prLabels: Array.isArray(pr.labels) ? labelNames(pr) : null, cardLabels: card && Array.isArray(card.labels) ? labelNames(card) : null, cardComments: Array.isArray(comments) ? comments : null, }); } } + + // Second pass — the gate history, for the C3 candidates and nobody else. + // `needsGateHistory` is the SAME predicate the UNJUDGED accounting reads, so + // the set that owes a stream and the set that gets one cannot drift apart. + for (const pair of pairs) { + if (!needsGateHistory(pair)) continue; + pair.cardEvents = await readCarrierEvents(repo, pair.card); + pair.prEvents = await readCarrierEvents(repo, pair.pr); + // The head commit is owed only once both carriers read CLEARED — the one + // state whose verdict turns on head motion. + const card = carrierGateHistory(pair.cardEvents); + const prHist = carrierGateHistory(pair.prEvents); + if (card.state === 'cleared' && prHist.state === 'cleared') { + pair.headCommittedAt = await readHeadCommitDate(repo, pair.headSha); + } + } return { pulls, pairs }; } @@ -813,14 +1101,93 @@ export function selfTest() { t('…and the unjudged accounting names the thread that could not be read', says(pairUnjudged(pair({ cardComments: null })), 'comment thread')); // -- C3, the direction a carrier comparison cannot see ---------------------- - const ungated = c3DeclaredYesUngated(pair({ prLabels: [], cardLabels: [], cardComments: [CLAIM('Clause-②: yes')] })); - t('a declared `yes` with the gate on neither carrier produces a C3 row', typeof ungated === 'string'); + // A declared `yes`, bare on both carriers — C3's candidate shape. The event + // stream is what says WHICH of the four states this is. + const declaredYes = (o) => pair({ prLabels: [], cardLabels: [], cardComments: [CLAIM('Clause-②: yes')], ...o }); + const EV = (verb, at, actor = 'os-support-ai') => ({ + event: verb, + created_at: at, + label: { name: CONTRACT_REVIEW_LABEL }, + actor: { login: actor }, + }); + // The 2026-09-01 measurement (#14155), verbatim: PR #13864 / card #13657. + const CARD_HUNG = EV('labeled', '2026-08-31T16:55:54Z', 'os-warren'); + const CARD_CLEARED = EV('unlabeled', '2026-09-01T08:54:47Z'); + const PR_HUNG = EV('labeled', '2026-08-31T15:19:53Z', 'claude[bot]'); + const PR_CLEARED = EV('unlabeled', '2026-09-01T08:54:56Z'); + const HEAD_AT_PASS = '2026-09-01T08:16:59Z'; // head 9af92aa3, the tree the review judged. + + const neverHung = declaredYes({ cardEvents: [], prEvents: [] }); + const ungated = c3DeclaredYesUngated(neverHung); + t('a declared `yes` the events show was NEVER HUNG still produces a C3 row', typeof ungated === 'string'); t('…and says why a carrier comparison cannot see it', says(ungated, 'agreement on ABSENCE')); + t('…and says in as many words that the gate was never bound', says(ungated, 'NEVER HUNG')); t('a declared `no` with no gate anywhere is NOT a C3 row — that is a decision', c3DeclaredYesUngated(pair({ cardComments: [CLAIM('Clause-②: no')] })) === null); t('a declared `yes` WITH the gate hung is not a C3 row', c3DeclaredYesUngated(pair({ prLabels: [L], cardLabels: [L], cardComments: [CLAIM('Clause-②: yes')] })) === null); t('an ABSENT declaration is not a C3 row — C2 owns that fact, and no row owns it twice', c3DeclaredYesUngated(pair({ cardComments: [CLAIM('Domain: x')] })) === null); t('an unreadable carrier never manufactures a C3 row', c3DeclaredYesUngated(pair({ cardLabels: null, cardComments: [CLAIM('Clause-②: yes')] })) === null); + // -- #14155: the COMPLETED state, and the three it must stay distinct from -- + const completed = declaredYes({ + pr: 13864, + card: 13657, + cardEvents: [CARD_HUNG, CARD_CLEARED], + prEvents: [PR_HUNG, PR_CLEARED], + headCommittedAt: HEAD_AT_PASS, + }); + t('⭐ the measured 2026-09-01 clear (#13864/#13657) produces NO C3 row — the completed state is clean', c3DeclaredYesUngated(completed) === null, JSON.stringify(gateBindingState(completed))); + t('…and the pair is CLEAN overall, not merely C3-silent', pairRows(completed).length === 0 && pairUnjudged(completed) === null); + t('…and the disposition names it `completed`, anchored on the earlier removal', gateBindingState(completed).state === 'completed' && gateBindingState(completed).clearedAt === '2026-09-01T08:54:47Z'); + t('…which is exactly what the landing check\'s ② could never answer before', gateBindingState(completed).state !== 'never-hung'); + + const movedAfter = declaredYes({ + cardEvents: [CARD_HUNG, CARD_CLEARED], + prEvents: [PR_HUNG, PR_CLEARED], + headCommittedAt: '2026-09-01T10:30:00Z', // a commit landed AFTER the clear. + }); + const movedRow = c3DeclaredYesUngated(movedAfter); + t('a cleared gate whose head MOVED afterwards is adverse, not clean', typeof movedRow === 'string'); + t('…and is named as the 重挂-owed state the recovery rule already carries', says(movedRow, '重挂') && says(movedRow, 'head 后移')); + t('…and quotes both dates, so the reader can check the ordering', says(movedRow, '2026-09-01T10:30:00Z') && says(movedRow, '2026-09-01T08:54:47Z')); + t('…and the re-hang is never performed from here', says(movedRow, '自查放行')); + t('the head-motion comparison is against the EARLIER removal — motion after the first clear counts', gateBindingState(declaredYes({ + cardEvents: [CARD_HUNG, CARD_CLEARED], + prEvents: [PR_HUNG, PR_CLEARED], + headCommittedAt: '2026-09-01T08:54:50Z', // between the two removals. + })).state === 'moved-after-clear'); + + const halfBound = declaredYes({ cardEvents: [CARD_HUNG, CARD_CLEARED], prEvents: [] }); + const halfRow = c3DeclaredYesUngated(halfBound); + t('a gate bound on ONE carrier only is adverse — one removal where a clear leaves two', typeof halfRow === 'string'); + t('…and names the strip signature the dual-carrier rule already states', says(halfRow, 'strip signature') && says(halfRow, '两边都挂好')); + t('…and points at H35 rather than re-judging the windowed repo-wide form', says(halfRow, 'H35')); + t('…in both directions', gateBindingState(declaredYes({ cardEvents: [], prEvents: [PR_HUNG, PR_CLEARED] })).bound === 'pr'); + + t('an UNREADABLE stream is never a never-hung gate — it is UNJUDGED (#4690)', c3DeclaredYesUngated(declaredYes({ cardEvents: null, prEvents: [] })) === null); + t('…and the unjudged accounting names the stream that could not be read', says(pairUnjudged(declaredYes({ cardEvents: null, prEvents: [] })), 'label event stream')); + t('…so an unread stream can never render as a clean pair', pairUnjudged(declaredYes({ cardEvents: [CARD_HUNG, CARD_CLEARED], prEvents: null })) !== null); + t('a cleared pair whose HEAD COMMIT could not be read is UNJUDGED, not clean', says(pairUnjudged(declaredYes({ cardEvents: [CARD_HUNG, CARD_CLEARED], prEvents: [PR_HUNG, PR_CLEARED], headCommittedAt: null })), 'head commit date')); + t('labels bare but the stream ending on a HANG is a disagreement, reported not resolved', says(c3DeclaredYesUngated(declaredYes({ cardEvents: [CARD_HUNG], prEvents: [PR_HUNG, PR_CLEARED] })), 'the labels say bare, the events say hung')); + + // -- the event reader itself ------------------------------------------------ + t('an empty stream reads NEVER HUNG — readable, and nothing was hung', carrierGateHistory([]).state === 'never-hung'); + t('a null stream reads UNREADABLE — the distinction the whole row turns on', carrierGateHistory(null).state === 'unreadable'); + t('the LAST event decides, and the reader sorts rather than trusting arrival order', carrierGateHistory([CARD_CLEARED, CARD_HUNG]).state === 'cleared' && carrierGateHistory([CARD_HUNG, CARD_CLEARED]).state === 'cleared'); + t('…so a newest-first page cannot invert the verdict', carrierGateHistory([CARD_CLEARED, CARD_HUNG]).at === CARD_CLEARED.created_at); + t('a re-hung gate reads HUNG, not cleared', carrierGateHistory([CARD_HUNG, CARD_CLEARED, EV('labeled', '2026-09-01T09:30:00Z')]).state === 'hung'); + t('⛔ events for OTHER labels are not the gate\'s history', carrierGateHistory([{ event: 'labeled', created_at: '2026-08-31T08:00:18Z', label: { name: 'priority:p1' } }]).state === 'never-hung'); + t('⛔ non-label events are ignored', carrierGateHistory([{ event: 'ready_for_review', created_at: '2026-08-31T08:00:18Z' }]).state === 'never-hung'); + t('⛔ an UNDATED gate event refuses the whole reading — dropping it could move the last event', carrierGateHistory([CARD_HUNG, { event: 'unlabeled', created_at: null, label: { name: CONTRACT_REVIEW_LABEL } }]).state === 'unreadable'); + t('the gate label is the sibling\'s constant, not a second spelling', carrierGateHistory([EV('labeled', '2026-08-31T16:55:54Z')]).state === 'hung'); + + // -- the cost bound, stated as one predicate both sides read --------------- + t('a declared `no` owes NO event stream — the cost bound', needsGateHistory(pair({ cardComments: [CLAIM('Clause-②: no')] })) === false); + t('a pair still carrying the gate owes none either', needsGateHistory(pair({ prLabels: [L], cardLabels: [L], cardComments: [CLAIM('Clause-②: yes')] })) === false); + t('an ABSENT declaration owes none — C2 already owns that pair', needsGateHistory(pair({ cardComments: [CLAIM('Domain: x')] })) === false); + t('only the C3 candidate shape owes one', needsGateHistory(declaredYes({})) === true); + t('…and a pair that owes nothing is never UNJUDGED for a stream it was never going to be asked for', pairUnjudged(pair({ cardComments: [CLAIM('Clause-②: no')] })) === null); + t('the page cap exists, because the stream arrives OLDEST FIRST and a short read loses the removal', Number.isInteger(EVENT_PAGE_CAP) && EVENT_PAGE_CAP > 0); + // -- the #13910 specimen, end to end --------------------------------------- const specimen = pair({ pr: 13910, card: 13476, prLabels: [], cardLabels: [L], cardComments: [CLAIM('Domain: `domain:engine`')] }); const specimenRows = pairRows(specimen).map((r) => r.code); @@ -847,7 +1214,8 @@ export function selfTest() { } console.log( `✓ check-clause2-carriers self-test: ${cases.length} cases pass (fixed-spelling reader, the ` + - 'four declaration states, the 2026-08-31 seven-pair replay, and the exit register).', + 'four declaration states, the 2026-08-31 seven-pair replay, the four gate-binding states ' + + 'replayed from the 2026-09-01 clear, and the exit register).', ); return 0; }