Skip to content

Commit 3da65cc

Browse files
committed
fix(pm): the queue guard — a CLEAR reached through a generated-artifact lift no longer reports itself as a clear that matched nothing (#15406)
The merge-queue log for PR #15284 printed, one line under its own `LIFTED skills/objectstack-ui/references/react-blocks.md` note: ✅ CLEAR — the diff touches no governed surface, so this guard has nothing to judge. … ⛔ ZERO review lookups were made: the path test runs first and returns Both sentences are false for that run. The path test MATCHED (the diff's eleventh file is on the `skills/**` surface), and the register's own recompute ran and certified it. Read back from the log, a compliant landing under the 2026-09-01 generated-artifact ruling is indistinguishable from a guard that never saw the file. Report-only: `guardVerdict` now carries the paths the register lifted (default `[]`), and the `clear` rendering picks between the zero-cost clear — kept BYTE-FOR-BYTE on both legs, so the 2026-08-27 pull_request byte-identity constraint is untouched — and a clear reached through a lift, which names the lifted paths and says the recompute ran. No predicate, verdict, exit code or API cost changes. `liftedPathsBetween` derives what was lifted from the row lists on either side of `liftGeneratedExceptions`, not from its prose notes, and is deliberately conservative across rows (the #11084 fence is per-row). Self-test: 133 → 144 cases; new battery replays #15284's real 11-path file list, one commit, PR 15284, zero reviews of any kind. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
1 parent d30ccb9 commit 3da65cc

1 file changed

Lines changed: 204 additions & 11 deletions

File tree

scripts/pm/check-governed-queue-guard.mjs

Lines changed: 204 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -302,11 +302,12 @@ const SELF_TEST_BATTERIES = Object.freeze({
302302
'the PR-head reader: throws, and the caller no longer refuses on it': 3,
303303
'the WIRING pin: the workflow still spells this context name': 8,
304304
'⭐ #14063: the environment the exemption needs, pinned to the YAML': 7,
305+
'⭐ #15406: a CLEAR reached through a lift is not a clear that saw nothing': 10,
305306
});
306307

307308
// DELETING an entry silences that battery's floor exactly as effectively as
308309
// zeroing it, so the roster's own size is pinned too.
309-
const SELF_TEST_BATTERY_FLOOR = 17;
310+
const SELF_TEST_BATTERY_FLOOR = 18;
310311

311312
// The key an assertion is filed under when no battery is open. It is not a
312313
// declared battery, so it reds by the same set difference rather than silently
@@ -559,12 +560,12 @@ export function unreadableApproval(reason) {
559560
* The verdict, as data. Pure — every branch of the decision is here, and the
560561
* renderer and the exit code both read it rather than re-deriving it.
561562
*/
562-
export function guardVerdict({ event, governed = [], unattributed = [], approvals = new Map(), apiCalls = 0, headNotes = [] }) {
563+
export function guardVerdict({ event, governed = [], unattributed = [], approvals = new Map(), apiCalls = 0, headNotes = [], lifted = [] }) {
563564
const entries = governed.map((entry) => ({
564565
...entry,
565566
approval: approvals.get(entry.pr) ?? unreadableApproval('no review reading was recorded for this pull request'),
566567
}));
567-
const base = { event, entries, unattributed, apiCalls, headNotes, contextName: CHECK_CONTEXT_NAME };
568+
const base = { event, entries, unattributed, apiCalls, headNotes, lifted, contextName: CHECK_CONTEXT_NAME };
568569

569570
if (entries.length === 0 && unattributed.length === 0) {
570571
return { ...base, conclusion: 'clear', exitCode: EXIT_CLEAR, refusalKind: null };
@@ -611,11 +612,36 @@ export function renderGuardVerdict(verdict) {
611612
);
612613

613614
if (verdict.conclusion === 'clear') {
615+
// ⭐ TWO DIFFERENT CLEARS, and #15406 is what conflating them costs. The
616+
// wording below used to be unconditional, so a run in which the register
617+
// LIFTED a governed path printed "the diff touches no governed surface"
618+
// immediately under its own LIFTED line — and "the path test runs first and
619+
// returns", when the path test had matched and the register's recompute had
620+
// run. A landing read back from that log looks like a guard that never saw
621+
// the file, which is exactly the reading the post-merge audit was filed on.
622+
// A verdict may not deny its own evidence: the zero-cost clear keeps its
623+
// wording BYTE-FOR-BYTE (both legs, so the pull_request leg's byte-identity
624+
// constraint is untouched), and a clear reached THROUGH a lift says so.
625+
const lifted = verdict.lifted ?? [];
626+
if (lifted.length === 0) {
627+
lines.push(
628+
' ✅ CLEAR — the diff touches no governed surface, so this guard has nothing to judge.',
629+
` Derived from GOVERNED_SURFACES in scripts/pm/check-governed-merges.mjs (${GOVERNED_SURFACES.length} surfaces),`,
630+
' never from a restated list. ⛔ ZERO review lookups were made: the path test runs first and returns,',
631+
' so a GitHub API outage can never block a diff that touches nothing governed.',
632+
);
633+
return lines.join('\n');
634+
}
614635
lines.push(
615-
' ✅ CLEAR — the diff touches no governed surface, so this guard has nothing to judge.',
636+
' ✅ CLEAR — this diff DID touch a governed surface, and every hit was LIFTED by the generated-artifact',
637+
' register; nothing hand-authored is left for this guard to judge. ⚠️ This is NOT the "no governed',
638+
' path in the diff" clear: the path test MATCHED, the register recomputed provenance on this tree,',
639+
' and the lift line(s) printed above are that recompute\'s record. Lifted here:',
640+
...lifted.slice(0, 12).map((p) => ` - ${p}`),
641+
...(lifted.length > 12 ? [` … and ${lifted.length - 12} more`] : []),
616642
` Derived from GOVERNED_SURFACES in scripts/pm/check-governed-merges.mjs (${GOVERNED_SURFACES.length} surfaces),`,
617-
' never from a restated list. ⛔ ZERO review lookups were made: the path test runs first and returns,',
618-
' so a GitHub API outage can never block a diff that touches nothing governed.',
643+
' never from a restated list. ⛔ ZERO review lookups were made: the approval predicate is reached only',
644+
' by a governed path the register did not lift, and there was none.',
619645
);
620646
return lines.join('\n');
621647
}
@@ -773,10 +799,10 @@ export function renderGuardVerdict(verdict) {
773799
* still governs everything the verdict is derived FROM; it never governed
774800
* things the verdict merely mentions.
775801
*/
776-
export async function runGuard({ event, rows, fetchReviews, fetchPullHead }) {
802+
export async function runGuard({ event, rows, fetchReviews, fetchPullHead, lifted = [] }) {
777803
const { governed, unattributed } = decomposeGovernedWork(rows);
778804
if (governed.length === 0 && unattributed.length === 0) {
779-
return guardVerdict({ event, governed, unattributed, apiCalls: 0 });
805+
return guardVerdict({ event, governed, unattributed, apiCalls: 0, lifted });
780806
}
781807
const approvals = new Map();
782808
const headNotes = [];
@@ -812,7 +838,7 @@ export async function runGuard({ event, rows, fetchReviews, fetchPullHead }) {
812838
approvals.set(entry.pr, unreadableApproval(String(error?.message ?? error).split('\n')[0]));
813839
}
814840
}
815-
return guardVerdict({ event, governed, unattributed, approvals, apiCalls, headNotes });
841+
return guardVerdict({ event, governed, unattributed, approvals, apiCalls, headNotes, lifted });
816842
}
817843

818844
// ── git (diff decomposition; zero API) ──────────────────────────────────────
@@ -914,6 +940,34 @@ export async function liftGeneratedExceptions(root, baseSha, rows, notes, recomp
914940
return out;
915941
}
916942

943+
/**
944+
* Which registered paths `liftGeneratedExceptions` actually LIFTED, derived
945+
* from the row lists on either side of it. Pure, so the rendering that depends
946+
* on it is pinned offline.
947+
*
948+
* Read from the rows rather than from the notes: the notes are prose for a
949+
* human, and a verdict that parsed them back would be deriving a decision from
950+
* a rendering. Membership is still the register's own `generatedExceptionFor` —
951+
* a path that vanished for any other reason is not reported as a lift.
952+
*
953+
* ⚠️ Deliberately conservative across rows: a path kept by ANY row is not
954+
* listed, because the #11084 fence is per-row and one row's co-edit can keep a
955+
* path governed that another row's recompute certified. Under-reporting a lift
956+
* only ever costs a line of log; over-reporting one would put "we lifted it"
957+
* next to a verdict that did not.
958+
*/
959+
export function liftedPathsBetween(before, after) {
960+
const kept = new Set((Array.isArray(after) ? after : []).flatMap((r) => r?.paths ?? []));
961+
const out = [];
962+
for (const row of Array.isArray(before) ? before : []) {
963+
for (const p of row?.paths ?? []) {
964+
if (kept.has(p) || generatedExceptionFor(p) === null || out.includes(p)) continue;
965+
out.push(p);
966+
}
967+
}
968+
return out;
969+
}
970+
917971
/**
918972
* The workflow's toolchain wiring, as data — the #14063 half of the wiring pin.
919973
*
@@ -1048,10 +1102,13 @@ async function main() {
10481102

10491103
const notes = [];
10501104
let rows;
1105+
let lifted = [];
10511106
try {
10521107
const mergeBase = git(repoRoot, ['merge-base', context.baseSha, context.headSha]).trim();
10531108
rows = enumerateRows(repoRoot, mergeBase, context.headSha, context.namedPull);
1109+
const beforeLift = rows;
10541110
rows = await liftGeneratedExceptions(repoRoot, mergeBase, rows, notes);
1111+
lifted = liftedPathsBetween(beforeLift, rows);
10551112
} catch (error) {
10561113
console.error(`⛔ ${CHECK_CONTEXT_NAME}: could not read the diff (${String(error?.message ?? error).split('\n')[0]}).`);
10571114
return EXIT_CANNOT_RUN;
@@ -1066,7 +1123,7 @@ async function main() {
10661123
const fetchReviews = makeReviewReader(reader);
10671124
const fetchPullHead = makePullHeadReader(reader);
10681125

1069-
const verdict = await runGuard({ event: context.event, rows, fetchReviews, fetchPullHead });
1126+
const verdict = await runGuard({ event: context.event, rows, fetchReviews, fetchPullHead, lifted });
10701127
const report = [`${context.label}${rows.length} commit(s) in range`, ...notes, renderGuardVerdict(verdict)].join('\n');
10711128
console.log(report);
10721129

@@ -1708,6 +1765,141 @@ export async function selfTest() {
17081765
}
17091766
assert('a-recompute-that-THROWS-never-lifts-it-propagates-into-CANNOT-RUN', liftThrew !== null && /EACCES/.test(liftThrew), String(liftThrew));
17101767

1768+
// ── ⭐ #15406: a CLEAR reached through a lift is not a clear that saw nothing ─
1769+
//
1770+
// Replays the real diff shape of objectstack#15284 — the landing that was
1771+
// filed as "the mixed-diff diversion did not fire". It had NOT failed: the
1772+
// diff's single governed path was the register's own `spec-react-blocks`
1773+
// row, the recompute certified it byte-exact, and the queue leg cleared with
1774+
// zero approvals exactly as the 2026-09-01 ruling provides for. What failed
1775+
// was the LOG. Its verdict line read, directly under its own LIFTED line:
1776+
//
1777+
// ✅ CLEAR — the diff touches no governed surface, so this guard has
1778+
// nothing to judge.
1779+
// … ⛔ ZERO review lookups were made: the path test runs first and returns
1780+
//
1781+
// Both sentences are false for that run: the path test MATCHED, and the
1782+
// register's recompute ran. Read back from the merge queue log, that landing
1783+
// is indistinguishable from a guard that never saw the file — which is the
1784+
// reading the post-merge audit row was filed on. ⛔ The predicate is NOT what
1785+
// these cases pin: the verdict, the exit code and the API cost are asserted
1786+
// to be the SAME as before, and only the words change.
1787+
battery('⭐ #15406: a CLEAR reached through a lift is not a clear that saw nothing');
1788+
// #15284's real file list, in its merged order.
1789+
const pr15284Paths = [
1790+
'.changeset/list-view-grouping-server-side-contract.md',
1791+
'content/docs/references/api/protocol.mdx',
1792+
'content/docs/references/data/object.mdx',
1793+
'content/docs/references/ui/view.mdx',
1794+
'packages/spec/api-surface/ui.json',
1795+
'packages/spec/export-origins/ui.json',
1796+
'packages/spec/src/ui/index.ts',
1797+
'packages/spec/src/ui/view-grouping-query.test.ts',
1798+
'packages/spec/src/ui/view-grouping-query.ts',
1799+
'packages/spec/src/ui/view.zod.ts',
1800+
'skills/objectstack-ui/references/react-blocks.md',
1801+
];
1802+
const pr15284ReactBlocks = 'skills/objectstack-ui/references/react-blocks.md';
1803+
const pr15284Row = (paths = pr15284Paths) => ({
1804+
sha: 'f502898a49530a1c85e58f3c4d2b340c0e1cb909',
1805+
subject: 'feat(spec): list-view grouping is server-side (#15284)',
1806+
pr: 15284,
1807+
paths,
1808+
});
1809+
// main()'s own wiring, reproduced: lift, derive what was lifted, then judge.
1810+
// ⛔ Not the `endToEnd` helper above — the defect lived in the step BETWEEN
1811+
// those two, so a helper that skips it cannot see it.
1812+
const asMainDoes = async (recompute, rows, io = {}) => {
1813+
const notes = [];
1814+
const after = await liftGeneratedExceptions('/w', 'base', rows, notes, recompute);
1815+
const lifted = liftedPathsBetween(rows, after);
1816+
const verdict = await runGuard({
1817+
event: 'merge_group',
1818+
rows: after,
1819+
fetchPullHead: io.head ?? (() => HEAD),
1820+
fetchReviews: io.reviews ?? (() => []),
1821+
lifted,
1822+
});
1823+
return { verdict, notes, lifted, text: renderGuardVerdict(verdict) };
1824+
};
1825+
const noApi = () => {
1826+
throw new Error('the API must not be reached — the only governed path was lifted');
1827+
};
1828+
// The classification leg first: the file IS on the register, and the diff DID
1829+
// hit a governed surface before anything was lifted. Both are facts the old
1830+
// verdict line denied.
1831+
assert(
1832+
'the-one-skills-path-in-15284-is-a-register-CANDIDATE-not-hand-authored-content',
1833+
generatedExceptionFor(pr15284ReactBlocks)?.id === 'spec-react-blocks',
1834+
JSON.stringify(generatedExceptionFor(pr15284ReactBlocks)),
1835+
);
1836+
assert(
1837+
'⭐ the-mixed-diff-rule-DID-match-15284-before-the-lift-one-hit-out-of-eleven-paths',
1838+
governedPathsIn(pr15284Paths).flatMap((s) => s.files).join() === pr15284ReactBlocks,
1839+
JSON.stringify(governedPathsIn(pr15284Paths)),
1840+
);
1841+
const certified15284 = await asMainDoes(
1842+
verified('byte-equal to the react-blocks generator recomputed on this tree (fixture)'),
1843+
[pr15284Row()],
1844+
{ head: noApi, reviews: noApi },
1845+
);
1846+
// The verdict itself is UNCHANGED — the 2026-09-01 ruling working, with an
1847+
// approver set that does not contain the merging account and no review of any
1848+
// kind on the pull request.
1849+
assert(
1850+
'a-certified-regeneration-still-CLEARS-with-zero-reviews-and-zero-api-calls',
1851+
certified15284.verdict.conclusion === 'clear' && certified15284.verdict.exitCode === EXIT_CLEAR && certified15284.verdict.apiCalls === 0,
1852+
JSON.stringify({ c: certified15284.verdict.conclusion, e: certified15284.verdict.exitCode, api: certified15284.verdict.apiCalls }),
1853+
);
1854+
assert(
1855+
'liftedPathsBetween-names-exactly-the-path-the-register-lifted',
1856+
certified15284.lifted.join() === pr15284ReactBlocks,
1857+
JSON.stringify(certified15284.lifted),
1858+
);
1859+
assert(
1860+
'⭐ the-CLEAR-line-does-NOT-claim-the-diff-touched-no-governed-surface',
1861+
!certified15284.text.includes('the diff touches no governed surface'),
1862+
certified15284.text,
1863+
);
1864+
assert(
1865+
'⭐ the-CLEAR-line-does-NOT-claim-the-path-test-returned-before-matching',
1866+
!certified15284.text.includes('the path test runs first and returns'),
1867+
certified15284.text,
1868+
);
1869+
assert(
1870+
'the-CLEAR-line-names-the-lifted-path-and-says-the-recompute-ran',
1871+
certified15284.text.includes(pr15284ReactBlocks) &&
1872+
/LIFTED/.test(certified15284.text) &&
1873+
/recomputed provenance on this tree/.test(certified15284.text),
1874+
certified15284.text,
1875+
);
1876+
assert(
1877+
'and-it-still-reports-the-zero-lookup-cost-a-reader-checks-for',
1878+
/ZERO review lookups/.test(certified15284.text),
1879+
certified15284.text,
1880+
);
1881+
// The other direction, on the SAME path: a hand edit to that file is not
1882+
// lifted, so the identical file list refuses. The shape is not an exemption
1883+
// for `references/react-blocks.md`; it is an exemption for a recompute.
1884+
const handEdited15284 = await asMainDoes(refused('the generator does not certify this tree (fixture)'), [pr15284Row()]);
1885+
assert(
1886+
'a-hand-edit-to-the-SAME-path-still-REFUSES-the-same-file-list',
1887+
handEdited15284.verdict.exitCode === EXIT_REFUSED_UNAPPROVED && handEdited15284.lifted.length === 0,
1888+
JSON.stringify({ e: handEdited15284.verdict.exitCode, lifted: handEdited15284.lifted }),
1889+
);
1890+
// And the zero-cost clear is untouched, to the byte, on BOTH legs — the
1891+
// 2026-08-27 byte-identity constraint on the pull_request leg included.
1892+
for (const event of [EVENT_MERGE_GROUP, EVENT_PULL_REQUEST]) {
1893+
const nothing = renderGuardVerdict(guardVerdict({ event, governed: [], unattributed: [], apiCalls: 0 }));
1894+
assert(
1895+
`a-clear-with-nothing-lifted-keeps-its-pre-15406-wording-byte-for-byte: ${event}`,
1896+
nothing.includes(' ✅ CLEAR — the diff touches no governed surface, so this guard has nothing to judge.') &&
1897+
nothing.includes(' never from a restated list. ⛔ ZERO review lookups were made: the path test runs first and returns,') &&
1898+
nothing.includes(' so a GitHub API outage can never block a diff that touches nothing governed.'),
1899+
nothing,
1900+
);
1901+
}
1902+
17111903
// ── the PR-head reader: throws, and the caller no longer refuses on it ───
17121904
//
17131905
// The READER's own contract is unchanged — a non-2xx or an unparseable body
@@ -1855,7 +2047,8 @@ export async function selfTest() {
18552047
'regeneration (clears with zero approvals and zero API calls; still refuses on an uncertified recompute, on ' +
18562048
'drift, on a hand-authored sibling, and on a recompute that throws), and the workflow wiring pin including the ' +
18572049
'dependency install the recompute needs, its register-agnostic filter-free form, and its continue-on-error ' +
1858-
'degradation).',
2050+
'degradation), and the #15406 replay of PR #15284 — a clear reached THROUGH a lift no longer reports itself as a ' +
2051+
'clear that matched nothing, while the zero-cost clear keeps its wording byte-for-byte on both legs.',
18592052
);
18602053

18612054
selfTestReachedVerdict = true;

0 commit comments

Comments
 (0)