@@ -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 && / E A C C E S / . 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+ / L I F T E D / . test ( certified15284 . text ) &&
1873+ / r e c o m p u t e d p r o v e n a n c e o n t h i s t r e e / . test ( certified15284 . text ) ,
1874+ certified15284 . text ,
1875+ ) ;
1876+ assert (
1877+ 'and-it-still-reports-the-zero-lookup-cost-a-reader-checks-for' ,
1878+ / Z E R O r e v i e w l o o k u p s / . 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