code wiki / _hdl_build / nx_treecanon_gate.nx

nx_treecanon_gate.nx source

↩ module page · 612 lines · 31742 B

1// nx_treecanon_gate.nx -- THE DIVERGENCE GATE THAT REFUSES (debt 1785622769, operator directive 2// 2026-08-01: "ONE canonical tree per generation with a lineage manifest ... a divergence gate that 3// REFUSES a build when the deploying tree's copy diverges from canonical"). 4// 5// MEASURED 2026-08-03, and it is why this exists: NAS buildroot vs laptop nxc2 diverge on 6// 8,258 files -- 3,705 NAS-newer, 1,838 laptop-newer, 739 NAS-only, 1,976 laptop-only. NEITHER 7// TREE IS A SUPERSET IN EITHER DIRECTION. A name-level diff saw only 969 of those, because the 8// dangerous class is files present in BOTH with DIFFERENT BYTES (5,543 of them). 9// ⇒ ★A NAME-LEVEL TREE DIFF IS NOT A DIVERGENCE CHECK. It cannot see the failure that actually 10// bites -- editing a file in one tree and compiling the other copy, which is precisely how the 11// unfreed-mmap fix had to be applied twice (the incident that opened this debt). 12// 13// SCOPE, DELIBERATE AND STATED: this gate does NOT try to reconcile the fork. Blocking on all 14// 8,258 would block every build, and mass-merging on a size screen would be DESTRUCTIVE -- byte 15// size is a SCREEN, not proof of newer (banked law: newer mtime + FEWER bytes = REVERT). It 16// enforces a CANON LIST from knowledge/tree_canon.conf (rule 11: the policy is DATA), starting at 17// the crown jewels and growing by measurement. Stopping the bleeding beats an unfinished cleanup. 18// 19// T0 LOADED both manifests parse and index (non-vacuity floor on the inputs) 20// T1 CANON READ the canon list loads and is non-empty (an empty list would pass vacuously) 21// T2 CANON CLEAN every canon path is present in BOTH trees and byte-identical -- REFUSES otherwise 22// T3 BITE the detector fires on a REAL divergent path and stays silent on a REAL identical 23// one, both discovered from the manifests at runtime (no hardcoded fixture) 24// T4 SETTLED OPTIONAL, needs argv[4]: resamples every listed canon file on the LIVE tree 25// TC_SETTLE_MS later and REFUSES if any moved -- T1..T3 read manifests, which are 26// claims about a moment already past and so cannot see a snapshot taken mid-write 27// 28// T5 CONFIRMED OPTIONAL, needs argv[4]: for every canon row T2 called divergent, hashes the LIVE 29// file and asks whether the manifests or the bytes are right -- separating a REAL fork 30// from a STALE MANIFEST. The two manifests are refreshed on DIFFERENT CLOCKS (NAS side 31// hourly by nx_treediverge_beat, laptop side per SessionStart), so manifest lag alone 32// can make identical trees look forked and block every seat's build. 33// 34// usage: nx_treecanon_gate <manifestCANON> <manifestOTHER> <canon.conf> [liveTreeRoot] 35// liveTreeRoot must be the tree manifestCANON describes (e.g. buildroot/runtime on the NAS). 36// license_tier: ORIGINAL No hw writes (Rule 26). expect_exit: 0 37import "nx_syscalls.nx" 38import "nx_gate_verdict.nx" 39import "nx_sha256.nx" // T5 needs the SAME digest nx_treehash wrote into the manifests 40 41const TC_HBUCKETS: i64 = 65536 42const TC_MAXENT: i64 = 200000 43const TC_BUFCAP: i64 = 16777216 44const TC_CONFCAP: i64 = 262144 45const TC_HASH_SEED: i64 = 5381 46const TC_SPACE: i64 = 32 47const TC_NL: i64 = 10 48const TC_CR: i64 = 13 49const TC_HASHCH: i64 = 35 50const TC_BANG: i64 = 33 // '!' row prefix: the BUILD LANE freezes every build while that row 51 // diverges (nx_sov_build_run tree-canon admission); for THIS gate the 52 // identity check is the same either way, so the marker is stripped 53const TC_PATHCAP: i64 = 1024 54const TC_FILECAP: i64 = 4194304 // per-file read cap for T4; largest canon source is ~290KB 55const TC_MAXCANON: i64 = 256 56const TC_DIGEST_BYTES: i64 = 32 57const TC_EMPTY_RESERVE: i64 = 4294967296 // sys_read_file's reserve for a 0-byte file (see nx_treehash) 58const TC_ID_FULLBYTES: i64 = 7 // 15 hex chars of identity = 7 whole bytes + 1 high nibble 59const TC_SETTLE_MS: i64 = 4000 // sample window for T4; long enough to catch an active writer 60const TC_SLASH: i64 = 47 61const TC_D0: i64 = 48 62const TC_D9: i64 = 57 63// CONTENT-IDENTITY UPGRADE (2026-08-06). A row may now be "<64hex> <bytes> <relpath>" (nx_treehash) 64// as well as the legacy "<bytes> <relpath>" (nx_treediff). When the digest is present the identity 65// token is its leading 60 bits, so tc_diverges compares CONTENT rather than LENGTH. 66// WHY THIS WAS URGENT: this gate's own comment claimed "byte-identical", but tc_lookup returned the 67// SIZE and tc_diverges compared sizes -- so a change that preserves length passed it GREEN. A 68// MUTATION-CLASS DEFECT PRESERVES LENGTH BY CONSTRUCTION, and one had already escaped into buildroot 69// (_hdl_build/nx_media_extract.nx, `if hit != 0` vs `if hit == 0`, 4389 bytes on BOTH sides). The 70// crown jewels this gate guards -- nx_syscalls.nx, nx_gate_verdict.nx -- are imported by every organ 71// in the estate, so a size-only canon check was the widest blind spot in the build lane. 72// 60 bits (not 64) keeps the token NON-NEGATIVE, which is load-bearing: tc_lookup reserves -1 for 73// ABSENT, and a token with the sign bit set would be silently read as "file missing". 74const TC_HEXID_CHARS: i64 = 15 75const TC_SHA_CHARS: i64 = 64 76const TC_LC_A: i64 = 97 77const TC_LC_F: i64 = 102 78 79// two independent indexes: A (canonical/NAS) and B (the tree being compared) 80static tc_ha: *i64 = 0 as *i64 81static tc_hb: *i64 = 0 as *i64 82static tc_ka: *i64 = 0 as *i64 83static tc_kb: *i64 = 0 as *i64 84static tc_sa: *i64 = 0 as *i64 85static tc_sb: *i64 = 0 as *i64 86static tc_na: i64 = 0 87static tc_nb: i64 = 0 88 89func tc_readall(path: *u8, outn: *i64, cap: i64) -> *u8 { 90 let fd: i64 = sys_openat_rd(path) 91 if fd < 0 { outn[0] = 0 - 1; return 0 as *u8 } 92 let buf: *u8 = sys_mmap(cap) 93 var tot: i64 = 0 94 var go: i64 = 1 95 while go == 1 { 96 let r: i64 = sys_read(fd, ((buf as i64) + tot) as *u8, cap - tot - 1) 97 if r <= 0 { go = 0 } else { tot = tot + r } 98 if tot >= cap - 1 { go = 0 } 99 } 100 sys_close(fd) 101 buf[tot] = 0 as u8 102 outn[0] = tot 103 return buf 104} 105 106// Content hash of the LIVE file at <root>/<rel>; -1 when unreadable. Not cryptographic on purpose -- 107// the only question is "did these bytes change in the last few seconds", and a rolling hash answers it. 108// Returns a STATUS (0 = read and hashed, -1 = could not be read) and writes the hash to outh[0]. 109// >>THE STATUS AND THE VALUE TRAVEL ON SEPARATE CHANNELS, DELIBERATELY.<< The first cut returned the 110// hash itself and used -1 for "unreadable". But this is a 64-bit rolling multiply: it overflows and 111// lands on a NEGATIVE value for about half of all inputs, which is indistinguishable from the error 112// sentinel. Measured: the same file read as "unreadable" alone and as fine inside a 6-file run, and 113// 5 then 6 of 12 canon files were reported unreadable when all 12 were provably present and readable. 114// The comparison below then SKIPPED every negative-hashing file, so a file that moved could go 115// undetected while the gate printed a confident number. 116// ★★★★★★AN ERROR SENTINEL DRAWN FROM THE VALUE'S OWN RANGE IS NOT AN ERROR CHANNEL -- IT IS A 117// COLLISION WAITING FOR THE RIGHT INPUT, AND IT FAILS SILENTLY BECAUSE BOTH SIDES LOOK LEGITIMATE. 118func tc_filehash(root: *u8, rel: *u8, nbox: *i64, outh: *i64) -> i64 { 119 let path: *u8 = sys_mmap(TC_PATHCAP) 120 var o: i64 = 0 121 var t: i64 = 0 122 while root[t] != (0 as u8) { path[o] = root[t]; o = o + 1; t = t + 1 } 123 path[o] = TC_SLASH as u8 124 o = o + 1 125 t = 0 126 while rel[t] != (0 as u8) { path[o] = rel[t]; o = o + 1; t = t + 1 } 127 path[o] = 0 as u8 128 let b: *u8 = tc_readall(path, nbox, TC_FILECAP) 129 let n: i64 = nbox[0] 130 if n <= 0 { sys_munmap(path, TC_PATHCAP); return 0 - 1 } 131 // (status -1 below is now ONLY ever a real read failure; the hash cannot reach this channel) 132 // A read that exactly fills the buffer is indistinguishable from a TRUNCATED one, and a hash over 133 // truncated bytes is a confident wrong answer -- it would read as MOVING forever. Refuse instead. 134 if n >= TC_FILECAP - 1 { sys_munmap(b, TC_FILECAP); sys_munmap(path, TC_PATHCAP); return 0 - 1 } 135 var h: i64 = TC_HASH_SEED 136 var i: i64 = 0 137 while i < n { h = h * 131 + (b[i] as i64); i = i + 1 } 138 // >>FREE WHAT YOU ALLOCATED.<< tc_readall mmaps `cap` on EVERY call and never frees, and T4 calls 139 // it 12 canon files x 2 passes = 24 times. The first cut of this tooth passed TC_BUFCAP (16MB) and 140 // leaked ~384MB; sys_mmap then began failing and FIVE OF TWELVE canon files silently reported 141 // "unreadable" -- WHILE T4 STILL SAID PASS. That is the same unfreed-mmap class this gate's own 142 // header names as the incident that opened its debt. 143 sys_munmap(b, TC_FILECAP) 144 sys_munmap(path, TC_PATHCAP) 145 outh[0] = h 146 return 0 147} 148 149// Walk the canon conf and hash every listed LIVE file into out[]. Returns how many were sampled. 150// ⚠The caller must pass a FRESHLY READ conf buffer each time: the row parser below NUL-terminates 151// each line in place (exactly as the T1/T2 walk does), so a second pass over the same buffer would 152// find no line ends at all. 153func tc_sample_all(cb: *u8, lc: i64, root: *u8, out: *i64, st: *i64, cap: i64, nb2: *i64) -> i64 { 154 var cnt: i64 = 0 155 var p: i64 = 0 156 var ls: i64 = 0 157 while p <= lc { 158 var eol: i64 = 0 159 if p == lc { eol = 1 } 160 if p < lc { if cb[p] == (TC_NL as u8) { eol = 1 } } 161 if eol == 1 { 162 var e: i64 = p 163 if e > ls { if cb[e-1] == (TC_CR as u8) { e = e - 1 } } 164 if e > ls { 165 if cb[ls] != (TC_HASHCH as u8) { 166 cb[e] = 0 as u8 167 var rs: i64 = ls 168 if cb[rs] == (TC_BANG as u8) { rs = rs + 1 } 169 if rs < e { 170 let rel: *u8 = ((cb as i64) + rs) as *u8 171 if cnt < cap { st[cnt] = tc_filehash(root, rel, nb2, ((out as i64) + cnt * 8) as *i64); cnt = cnt + 1 } 172 } 173 } 174 } 175 ls = p + 1 176 } 177 p = p + 1 178 } 179 return cnt 180} 181 182// ---- T5 SUPPORT: the identity token of the LIVE file, comparable to a manifest row ---- 183// WHY THIS EXISTS. T2 compares two MANIFESTS, and the estate refreshes them on DIFFERENT CLOCKS: 184// treecanon_nas_hash.mf is written by the hourly nx_treediverge_beat, treecanon_laptop_hash.mf by a 185// laptop SessionStart hook. So a file edited between those two moments reports DIVERGENT while both 186// trees hold byte-identical content -- MEASURED 2026-08-07 on this very file: the manifests disagreed 187// (22082 vs 23619) while both trees held 23619, and regenerating the NAS manifest cleared it. 188// A gate that REFUSES on that is a build outage manufactured by a clock, not by a fork. 189// ⇒ T5 asks the only question that settles it: WHAT DO THE LIVE BYTES SAY? 190// ★★★★★★A DISAGREEMENT BETWEEN TWO RECORDS OF THE PAST IS NOT EVIDENCE ABOUT THE PRESENT -- GO AND 191// LOOK AT THE THING ITSELF. 192// Returns 0 and writes the token to outid[0]; returns -1 when the file cannot be read. 193// The token is the leading 60 bits of the sha256, derived from the DIGEST BYTES directly -- exactly 194// the value tc_parse builds from the manifest's 15 leading hex chars, so the two are comparable 195// without a hex round-trip. Matches nx_treehash's known-good read/free idiom byte for byte. 196func tc_live_id(root: *u8, rel: *u8, outid: *i64) -> i64 { 197 let path: *u8 = sys_mmap(TC_PATHCAP) 198 var o: i64 = 0 199 var t: i64 = 0 200 while root[t] != (0 as u8) { path[o] = root[t]; o = o + 1; t = t + 1 } 201 path[o] = TC_SLASH as u8 202 o = o + 1 203 t = 0 204 while rel[t] != (0 as u8) { path[o] = rel[t]; o = o + 1; t = t + 1 } 205 path[o] = 0 as u8 206 let dg: *u8 = sys_mmap(TC_DIGEST_BYTES) 207 let ln: *i64 = sys_mmap(16) as *i64 208 ln[0] = 0 209 let buf: *u8 = sys_read_file(path, ln) 210 if (buf as i64) == 0 { 211 sys_munmap(ln as *u8, 16) 212 sys_munmap(dg, TC_DIGEST_BYTES) 213 sys_munmap(path, TC_PATHCAP) 214 return 0 - 1 215 } 216 let n: i64 = ln[0] 217 sha256_digest(buf, n, dg) 218 // Free EXACTLY what sys_read_file reserved -- the 0-byte case reserves TC_EMPTY_RESERVE, and 219 // freeing the wrong length is how the first cut of T4 leaked ~384MB and started reporting real 220 // files as unreadable while still printing PASS. 221 if n > 0 { sys_munmap(buf, n + 16) } else { sys_munmap(buf, TC_EMPTY_RESERVE + 16) } 222 var v: i64 = 0 223 var k: i64 = 0 224 while k < TC_ID_FULLBYTES { v = (v * 256) + (dg[k] as i64); k = k + 1 } 225 v = (v * 16) + ((dg[TC_ID_FULLBYTES] as i64) >> 4) 226 outid[0] = v 227 sys_munmap(ln as *u8, 16) 228 sys_munmap(dg, TC_DIGEST_BYTES) 229 sys_munmap(path, TC_PATHCAP) 230 return 0 231} 232 233func tc_hash(s: *u8) -> i64 { 234 var h: i64 = TC_HASH_SEED 235 var i: i64 = 0 236 while s[i] != (0 as u8) { h = ((h * 33) + (s[i] as i64)) & 0x7fffffff; i = i + 1 } 237 return h & (TC_HBUCKETS - 1) 238} 239func tc_streq(a: *u8, b: *u8) -> i64 { 240 var i: i64 = 0 241 while a[i] != (0 as u8) { if a[i] != b[i] { return 0 } i = i + 1 } 242 if b[i] != (0 as u8) { return 0 } 243 return 1 244} 245 246func tc_hexval(c: i64) -> i64 { 247 if c >= TC_D0 { if c <= TC_D9 { return c - TC_D0 } } 248 if c >= TC_LC_A { if c <= TC_LC_F { return c - (TC_LC_A - 10) } } 249 return 0 - 1 250} 251// 1 iff the row at `start` opens with 64 lowercase-hex chars followed by a space. 252// nx_treehash writes 64 '-' for an UNREADABLE file, which is NOT hex, so an unreadable row falls 253// through to the legacy branch and fails to parse rather than matching anything. 254func tc_is_hashrow(buf: *u8, start: i64, end: i64) -> i64 { 255 if start + TC_SHA_CHARS + 1 > end { return 0 } 256 var i: i64 = 0 257 while i < TC_SHA_CHARS { 258 if tc_hexval(buf[start+i] as i64) < 0 { return 0 } 259 i = i + 1 260 } 261 if buf[start + TC_SHA_CHARS] != (TC_SPACE as u8) { return 0 } 262 return 1 263} 264// parse a manifest row in place; NUL-terminates the path at `end` and writes the IDENTITY TOKEN: 265// "<64hex> <bytes> <relpath>" -> leading 60 bits of the sha256 (CONTENT identity) 266// "<bytes> <relpath>" -> the byte size (the legacy SCREEN, back-compat) 267// STRICTLY STRONGER, NEVER WEAKER: pointed at size manifests this behaves exactly as before. 268func tc_parse(buf: *u8, start: i64, end: i64, szout: *i64) -> *u8 { 269 if tc_is_hashrow(buf, start, end) == 1 { 270 var t: i64 = 0 271 var k: i64 = 0 272 while k < TC_HEXID_CHARS { t = (t * 16) + tc_hexval(buf[start+k] as i64); k = k + 1 } 273 var j: i64 = start + TC_SHA_CHARS + 1 274 var g2: i64 = 1 275 while g2 == 1 { 276 if j >= end { g2 = 0 } else { 277 if buf[j] == (TC_SPACE as u8) { g2 = 0 } else { j = j + 1 } 278 } 279 } 280 if j >= end { return 0 as *u8 } 281 let p2: i64 = j + 1 282 if p2 >= end { return 0 as *u8 } 283 buf[end] = 0 as u8 284 szout[0] = t 285 return ((buf as i64) + p2) as *u8 286 } 287 var i: i64 = start 288 var v: i64 = 0 289 var any: i64 = 0 290 var go: i64 = 1 291 while go == 1 { 292 if i >= end { go = 0 } else { 293 let c: i64 = buf[i] as i64 294 if c >= TC_D0 { if c <= TC_D9 { v = v * 10 + (c - TC_D0); any = 1; i = i + 1 } else { go = 0 } } else { go = 0 } 295 } 296 } 297 if any == 0 { return 0 as *u8 } 298 if i >= end { return 0 as *u8 } 299 if buf[i] != (TC_SPACE as u8) { return 0 as *u8 } 300 let p: i64 = i + 1 301 if p >= end { return 0 as *u8 } 302 buf[end] = 0 as u8 303 szout[0] = v 304 return ((buf as i64) + p) as *u8 305} 306 307// index one manifest into (htab, keys, sizes); returns row count 308func tc_index(buf: *u8, n: i64, htab: *i64, keys: *i64, sizes: *i64) -> i64 { 309 var i: i64 = 0 310 var ls: i64 = 0 311 var cnt: i64 = 0 312 let szbox: *i64 = sys_mmap(8) as *i64 313 while i <= n { 314 var eol: i64 = 0 315 if i == n { eol = 1 } 316 if i < n { if buf[i] == (TC_NL as u8) { eol = 1 } } 317 if eol == 1 { 318 var e: i64 = i 319 if e > ls { if buf[e-1] == (TC_CR as u8) { e = e - 1 } } 320 if e > ls { 321 let rel: *u8 = tc_parse(buf, ls, e, szbox) 322 if rel != (0 as *u8) { 323 if cnt < TC_MAXENT { 324 keys[cnt] = rel as i64 325 sizes[cnt] = szbox[0] 326 var h: i64 = tc_hash(rel) 327 var guard: i64 = 0 328 while guard < TC_HBUCKETS { 329 if htab[h] == 0 { htab[h] = cnt + 1; guard = TC_HBUCKETS } else { 330 h = (h + 1) & (TC_HBUCKETS - 1) 331 guard = guard + 1 332 } 333 } 334 cnt = cnt + 1 335 } 336 } 337 } 338 ls = i + 1 339 } 340 i = i + 1 341 } 342 return cnt 343} 344 345// look up a path; returns size, or -1 if absent 346func tc_lookup(rel: *u8, htab: *i64, keys: *i64, sizes: *i64) -> i64 { 347 var h: i64 = tc_hash(rel) 348 var guard: i64 = 0 349 while guard < TC_HBUCKETS { 350 let slot: i64 = htab[h] 351 if slot == 0 { return 0 - 1 } 352 let idx: i64 = slot - 1 353 if tc_streq(rel, keys[idx] as *u8) == 1 { return sizes[idx] } 354 h = (h + 1) & (TC_HBUCKETS - 1) 355 guard = guard + 1 356 } 357 return 0 - 1 358} 359 360// THE DETECTOR: 1 = this path DIVERGES (absent from either tree, or different bytes) 361func tc_diverges(rel: *u8) -> i64 { 362 let a: i64 = tc_lookup(rel, tc_ha, tc_ka, tc_sa) 363 let b: i64 = tc_lookup(rel, tc_hb, tc_kb, tc_sb) 364 if a < 0 { return 1 } 365 if b < 0 { return 1 } 366 if a != b { return 1 } 367 return 0 368} 369 370func main(argc: i64, argv: *i64) -> i64 { 371 let ctr: *i64 = gv_ctr() 372 gv_head("nx_treecanon gate -- a build must never compile a copy that diverges from canon" as *u8) 373 if argc < 4 { 374 gv_puts("usage: nx_treecanon_gate <manifestCANON> <manifestOTHER> <canon.conf>\n" as *u8) 375 gv_puts(" manifests come from: nx_treediff <dir> <outfile>\n" as *u8) 376 return 2 377 } 378 let pa: *u8 = argv[1] as *u8 379 let pb: *u8 = argv[2] as *u8 380 let pc: *u8 = argv[3] as *u8 381 382 tc_ha = sys_mmap(TC_HBUCKETS*8) as *i64 383 tc_hb = sys_mmap(TC_HBUCKETS*8) as *i64 384 tc_ka = sys_mmap(TC_MAXENT*8) as *i64 385 tc_kb = sys_mmap(TC_MAXENT*8) as *i64 386 tc_sa = sys_mmap(TC_MAXENT*8) as *i64 387 tc_sb = sys_mmap(TC_MAXENT*8) as *i64 388 389 let nbox: *i64 = sys_mmap(8) as *i64 390 let ba: *u8 = tc_readall(pa, nbox, TC_BUFCAP) 391 let la: i64 = nbox[0] 392 let bb: *u8 = tc_readall(pb, nbox, TC_BUFCAP) 393 let lb: i64 = nbox[0] 394 if la > 0 { tc_na = tc_index(ba, la, tc_ha, tc_ka, tc_sa) } 395 if lb > 0 { tc_nb = tc_index(bb, lb, tc_hb, tc_kb, tc_sb) } 396 gv_puts(" measured: canon tree rows=" as *u8); gv_num(tc_na) 397 gv_puts(" other tree rows=" as *u8); gv_num(tc_nb); gv_puts("\n" as *u8) 398 var t0: i64 = 0 399 if tc_na > 0 { if tc_nb > 0 { t0 = 1 } } 400 gv_check("T0 LOADED both manifests parsed and indexed" as *u8, t0, ctr) 401 402 // ---- global divergence tally: context, NOT the blocking check ---- 403 var div: i64 = 0 404 var same: i64 = 0 405 var i: i64 = 0 406 while i < tc_na { 407 let rel: *u8 = tc_ka[i] as *u8 408 let b2: i64 = tc_lookup(rel, tc_hb, tc_kb, tc_sb) 409 if b2 < 0 { div = div + 1 } else { 410 if b2 != tc_sa[i] { div = div + 1 } else { same = same + 1 } 411 } 412 i = i + 1 413 } 414 gv_puts(" measured: of the canon tree's rows, " as *u8); gv_num(div) 415 gv_puts(" diverge and " as *u8); gv_num(same) 416 gv_puts(" match in the other tree (CONTEXT ONLY -- not blocking)\n" as *u8) 417 418 // ---- the canon list ---- 419 let cb: *u8 = tc_readall(pc, nbox, TC_CONFCAP) 420 let lc: i64 = nbox[0] 421 var listed: i64 = 0 422 var bad: i64 = 0 423 var stale: i64 = 0 // manifest-divergent, but the LIVE bytes match the other tree 424 var confirmed: i64 = 0 // manifest-divergent AND the live bytes confirm it 425 var unresolved: i64 = 0 // could not adjudicate (unreadable, or both manifests match live) 426 if lc > 0 { 427 var p: i64 = 0 428 var ls: i64 = 0 429 while p <= lc { 430 var eol: i64 = 0 431 if p == lc { eol = 1 } 432 if p < lc { if cb[p] == (TC_NL as u8) { eol = 1 } } 433 if eol == 1 { 434 var e: i64 = p 435 if e > ls { if cb[e-1] == (TC_CR as u8) { e = e - 1 } } 436 if e > ls { 437 if cb[ls] != (TC_HASHCH as u8) { 438 cb[e] = 0 as u8 439 var rs: i64 = ls 440 if cb[rs] == (TC_BANG as u8) { rs = rs + 1 } 441 if rs < e { 442 let rel: *u8 = ((cb as i64) + rs) as *u8 443 listed = listed + 1 444 if tc_diverges(rel) == 1 { 445 bad = bad + 1 446 let cid: i64 = tc_lookup(rel, tc_ha, tc_ka, tc_sa) 447 let oid: i64 = tc_lookup(rel, tc_hb, tc_kb, tc_sb) 448 gv_puts(" DIVERGENT CANON: " as *u8); gv_puts(rel) 449 gv_puts(" canonid=" as *u8); gv_num(cid) 450 gv_puts(" otherid=" as *u8); gv_num(oid) 451 gv_puts("\n" as *u8) 452 // T5: ask the live bytes which record is telling the truth. 453 if argc >= 5 { 454 let lidbox: *i64 = sys_mmap(16) as *i64 455 let lst: i64 = tc_live_id(argv[4] as *u8, rel, lidbox) 456 if lst != 0 { 457 unresolved = unresolved + 1 458 gv_puts(" LIVE UNREADABLE -- cannot adjudicate this row\n" as *u8) 459 } 460 if lst == 0 { 461 let lid: i64 = lidbox[0] 462 gv_puts(" liveid=" as *u8); gv_num(lid) 463 // The live tree IS the canon tree this gate runs on. If the live 464 // bytes match the OTHER manifest but not the canon one, the trees 465 // agree and it is the CANON MANIFEST that lagged. 466 if lid == oid { 467 if lid != cid { 468 stale = stale + 1 469 gv_puts(" >>STALE CANON MANIFEST: live bytes MATCH the other tree; this is manifest lag, NOT a fork.<<\n" as *u8) 470 } 471 } 472 if lid != oid { 473 confirmed = confirmed + 1 474 gv_puts(" >>FORK CONFIRMED AGAINST LIVE BYTES: the trees really do differ here.<<\n" as *u8) 475 } 476 if lid == oid { if lid == cid { unresolved = unresolved + 1; gv_puts(" >>UNEXPLAINED: live matches BOTH manifests yet they disagree -- read the rows by hand.<<\n" as *u8) } } 477 } 478 sys_munmap(lidbox as *u8, 16) 479 } 480 } 481 482 } 483 } 484 } 485 ls = p + 1 486 } 487 p = p + 1 488 } 489 } 490 gv_puts(" measured: canon paths listed=" as *u8); gv_num(listed) 491 gv_puts(" divergent=" as *u8); gv_num(bad); gv_puts("\n" as *u8) 492 var t1: i64 = 0 493 if listed > 0 { t1 = 1 } 494 gv_check("T1 CANON READ the canon list loaded and is non-empty (an empty list passes vacuously)" as *u8, t1, ctr) 495 var t2: i64 = 0 496 if bad == 0 { t2 = 1 } 497 gv_check("T2 CANON CLEAN every canon path present in BOTH trees and byte-identical" as *u8, t2, ctr) 498 499 // ---- T5 CONFIRMED AGAINST LIVE BYTES (needs argv[4]) ---- 500 // T2 above is DELIBERATELY LEFT ALONE: it is the manifest-agreement contract other consumers read, 501 // and rule 19 says add a field, never redefine one. T5 is the sharper instrument beside it. 502 // The distinction it draws is the difference between a real build outage and a manufactured one: 503 // STALE -- the manifests disagree but the live bytes agree with the other tree. Refusing here 504 // blocks every seat for a reason that does not exist. Regenerate the manifest. 505 // CONFIRMED -- the live bytes really do differ from the other tree. This is the fork the gate was 506 // built to refuse, and it is the only class that should ever stop a build. 507 // ★★★★★A GATE THAT CANNOT SAY WHY IT REFUSED WILL EVENTUALLY BE SWITCHED OFF FOR REFUSING WRONGLY. 508 if argc >= 5 { 509 gv_puts(" adjudicated against live bytes: stale_manifest=" as *u8); gv_num(stale) 510 gv_puts(" confirmed_fork=" as *u8); gv_num(confirmed) 511 gv_puts(" unresolved=" as *u8); gv_num(unresolved); gv_puts("\n" as *u8) 512 if stale > 0 { 513 gv_puts(" >>" as *u8); gv_num(stale) 514 gv_puts(" of the " as *u8); gv_num(bad) 515 gv_puts(" divergent canon row(s) are MANIFEST LAG, not fork. The canon manifest is behind the\n" as *u8) 516 gv_puts(" live tree -- regenerate it (nx_treehash <tree> <manifest>) before reading T2 as a fork.<<\n" as *u8) 517 } 518 var t5: i64 = 0 519 if confirmed == 0 { if unresolved == 0 { t5 = 1 } } 520 gv_check("T5 NO CONFIRMED FORK every divergent canon row was adjudicated against the live bytes and none is a real fork" as *u8, t5, ctr) 521 } 522 if argc < 5 { 523 gv_puts(" T5 NOT ADJUDICATED -- without a live tree root, a manifest disagreement cannot be told\n" as *u8) 524 gv_puts(" >>apart from a stale manifest. T2 alone cannot distinguish a fork from a clock.<<\n" as *u8) 525 } 526 527 // ---- T3 BITE: calibrate the detector on REAL rows found at runtime, no hardcoded fixture ---- 528 // A gate whose detector cannot fire is a voter with information content zero (council rule). 529 var badpath: *u8 = 0 as *u8 530 var goodpath: *u8 = 0 as *u8 531 i = 0 532 while i < tc_na { 533 let rel: *u8 = tc_ka[i] as *u8 534 let b3: i64 = tc_lookup(rel, tc_hb, tc_kb, tc_sb) 535 if b3 >= 0 { 536 if b3 != tc_sa[i] { if badpath == (0 as *u8) { badpath = rel } } 537 if b3 == tc_sa[i] { if goodpath == (0 as *u8) { goodpath = rel } } 538 } 539 i = i + 1 540 } 541 var fires_bad: i64 = 0 542 var fires_good: i64 = 1 543 if badpath != (0 as *u8) { fires_bad = tc_diverges(badpath) } 544 if goodpath != (0 as *u8) { fires_good = tc_diverges(goodpath) } 545 if badpath != (0 as *u8) { gv_puts(" bite fixture DIVERGENT: " as *u8); gv_puts(badpath); gv_puts("\n" as *u8) } 546 if goodpath != (0 as *u8) { gv_puts(" bite fixture IDENTICAL: " as *u8); gv_puts(goodpath); gv_puts("\n" as *u8) } 547 gv_bite("T3 BITE divergence detector fires on a real divergent path, silent on a real identical one" as *u8, fires_bad, fires_good, ctr) 548 549 // ---- T4 STABILITY: WERE THE CANON FILES HOLDING STILL WHILE WE MEASURED THEM? ---- 550 // T1-T3 above compare two MANIFESTS. A manifest is a claim about a moment that has already 551 // passed, so this gate can return a confident GREEN over a snapshot taken WHILE a writer was 552 // mid-file -- and the estate has already paid for exactly that: a torn copy of 553 // nx_gate_verdict.nx was published this session with a hash matching NEITHER tree, on a `!` 554 // row, which freezes every build. The manifest legs structurally cannot see it: both sides 555 // agreed, because both sides described the same torn bytes. 556 // ⇒ THIS TOOTH SAMPLES THE LIVE FILES TWICE, TC_SETTLE_MS APART, AND ASKS WHETHER THEY MOVED. 557 // ★★★★★★A MANIFEST CANNOT TELL YOU WHETHER IT WAS TAKEN AT A SAFE MOMENT -- ONLY A SECOND 558 // SAMPLE CAN. Additive by construction: with no argv[4] the check is not added to the ballot 559 // and every existing caller keeps its exact previous verdict. 560 if argc >= 5 { 561 let liveroot: *u8 = argv[4] as *u8 562 let nb2: *i64 = sys_mmap(64) as *i64 563 let h1: *i64 = sys_mmap(8 * TC_MAXCANON) as *i64 564 let h2: *i64 = sys_mmap(8 * TC_MAXCANON) as *i64 565 let st1: *i64 = sys_mmap(8 * TC_MAXCANON) as *i64 566 let st2: *i64 = sys_mmap(8 * TC_MAXCANON) as *i64 567 // ⚠A FRESH CONF READ PER PASS: tc_sample_all NUL-terminates each row in place, so a second 568 // walk over the first buffer would find no line ends and silently sample nothing -- a 569 // vacuous PASS, the exact failure class this tooth exists to catch. 570 let cf1: *u8 = tc_readall(pc, nbox, TC_CONFCAP) 571 let s1: i64 = tc_sample_all(cf1, nbox[0], liveroot, h1, st1, TC_MAXCANON, nb2) 572 sys_sleep_ms(TC_SETTLE_MS) 573 let cf2: *u8 = tc_readall(pc, nbox, TC_CONFCAP) 574 let s2: i64 = tc_sample_all(cf2, nbox[0], liveroot, h2, st2, TC_MAXCANON, nb2) 575 var moved: i64 = 0 576 var unread: i64 = 0 577 var cmp: i64 = s1 578 if s2 < cmp { cmp = s2 } 579 i = 0 580 while i < cmp { 581 // Flat on purpose: status decides membership, THEN the hashes are compared. A file that 582 // could not be read on EITHER pass is unchecked, never quietly counted as unmoved. 583 var ok: i64 = 0 584 if st1[i] == 0 { if st2[i] == 0 { ok = 1 } } 585 if ok == 0 { unread = unread + 1 } 586 if ok == 1 { if h1[i] != h2[i] { moved = moved + 1 } } 587 i = i + 1 588 } 589 gv_puts(" live root: " as *u8); gv_puts(liveroot); gv_puts("\n" as *u8) 590 gv_puts(" sampled " as *u8); gv_num(cmp) 591 gv_puts(" canon files twice, " as *u8); gv_num(TC_SETTLE_MS) 592 gv_puts("ms apart -- moved=" as *u8); gv_num(moved) 593 gv_puts(" unreadable=" as *u8); gv_num(unread); gv_puts("\n" as *u8) 594 // A row count that changed between passes means the conf ITSELF is being edited; that is a 595 // moving target too, and it must not pass quietly just because no file hash differed. 596 if s1 != s2 { gv_puts(" >>THE CANON CONF CHANGED BETWEEN PASSES (" as *u8); gv_num(s1); gv_puts(" -> " as *u8); gv_num(s2); gv_puts(" rows)<<\n" as *u8) } 597 var t4: i64 = 0 598 // ★AN UNREADABLE CANON FILE IS AN UNCHECKED CANON FILE. Counting it and passing anyway is how 599 // the first cut of this tooth reported PASS over 5 of 12 files it never actually hashed. 600 if unread > 0 { gv_puts(" >>" as *u8); gv_num(unread); gv_puts(" CANON FILE(S) COULD NOT BE READ -- they were NOT checked for movement.<<\n" as *u8) } 601 if moved == 0 { if unread == 0 { if s1 == s2 { if cmp > 0 { t4 = 1 } } } } 602 gv_check("T4 CANON SETTLED every listed canon file is byte-stable across a resample; no snapshot was taken mid-write" as *u8, t4, ctr) 603 } 604 if argc < 5 { 605 gv_puts(" T4 STABILITY NOT SAMPLED -- pass a live tree root as argv[4] to check for mid-write snapshots.\n" as *u8) 606 gv_puts(" >>Without it this gate can only tell you the two MANIFESTS agree, never that they were taken at a safe moment.<<\n" as *u8) 607 } 608 609 let rc: i64 = gv_verdict("TREECANON-GATE" as *u8, ctr, 610 "canon paths are byte-identical across both trees; a build compiling a forked copy is refused" as *u8) 611 return rc 612}