code wiki / (root) / nx_deployjrnl_lib.nx

nx_deployjrnl_lib.nx source

↩ module page · 433 lines · 18421 B

1// nx_deployjrnl_lib.nx -- THE ONE RULER for deploy history and ROLLBACK SURVIVABILITY. 2// 3// WHY THIS EXISTS. On 2026-08-25 verification established that nx_docportal_admin_daemon had been 4// deployed at least TWICE, and that the second write EVICTED the pre-change binary from its .prev 5// rollback slot. Attribution came back UNVERIFIED for a STRUCTURAL reason: the estate has no deploy 6// journal. No instrument could answer "was a deploy performed, on what, and what did it destroy". 7// The effect was readable from artifact mtimes; the CALL was not readable at all. 8// 9// WHAT THIS IS, STATED PLAINLY: a RECONSTRUCTION, not a source-of-truth record. This capability is 10// NOT written by the deploy path (that path is a daemon and is out of scope). It infers deploys by 11// comparing the artifacts a deploy leaves behind, between one scan and the next. What that costs in 12// fidelity is enumerated at dj_transition below and printed on every status artifact -- A 13// RECONSTRUCTION THAT DECLARES ITSELF IS WORTH FAR MORE THAN ONE THAT IMPLIES IT WAS WRITTEN AT THE 14// SOURCE, so the method line travels with the data and is never separated from it. 15// 16// WHY A LIB AND NOT A FUNCTION IN THE ORGAN: the organ and its gate must classify identically, or 17// the gate is testing something the organ does not do. Every judgement below is a PURE function of 18// already-measured numbers, so the gate exercises the SHIPPING ruler rather than a paraphrase of 19// it. There is exactly one copy, so the two cannot disagree. 20// 21// license_tier: ORIGINAL 22import "nx_syscalls.nx" 23import "nx_sha256.nx" 24 25// ---- syscall / layout constants ------------------------------------------------------------ 26// newfstatat is the ONLY way to read an mtime in this tree (measured 2026-07-30: sys_stat and statx 27// have zero matches estate-wide). nx_mtime_probe.nx proved these offsets byte-exact against stat. 28// PINNED to the x86_64 number, which is what this estate builds. An earlier revision of this file 29// split it by target the way nx_syscalls.nx splits getdents64 -- and THAT BROKE THE ORGAN, silently 30// and completely: both consts survived into the build, the second won, and on x86_64 the RV64 31// number 79 is getcwd. So the call returned a length instead of filling a stat buffer, rc was 32// non-zero, EVERY stat failed, every mtime came back 0, the size prefilter matched nothing, and the 33// census reported 1,776 rollback slots as unbanked when many of them were banked -- a 34// false-positive machine, which is worse than no detector at all. 35// THE LESSON IS A RULE THIS ESTATE ALREADY HAS: PROVE A REFACTOR, DO NOT ASSERT IT. An unverified 36// portability flourish is not conservative just because it looks more careful. 37// RV64 newfstatat is 79. Switching belongs with a real RV64 build that can RUN the gate, not a guess. 38const DJ_SYS_NEWFSTATAT: i64 = 262 39 40const DJ_AT_FDCWD_L: i64 = 0 - 100 41const DJ_STATBUF_BYTES: i64 = 256 42const DJ_STAT_OFF_SIZE: i64 = 48 43const DJ_STAT_OFF_MTIME: i64 = 88 44 45const DJ_DENTS_BUF_BYTES: i64 = 65536 46const DJ_DIGEST_BYTES: i64 = 32 47const DJ_HEX_BYTES: i64 = 64 48const DJ_HEXSLOT: i64 = 72 49const DJ_LENSLOT: i64 = 16 50const DJ_NAME_SLOT: i64 = 256 51const DJ_PATH_SLOT: i64 = 512 52const DJ_MODE_644: i64 = 420 53const DJ_NL: i64 = 10 54const DJ_SPACE: i64 = 32 55const DJ_DOT_B: i64 = 46 56const DJ_DASH: i64 = 45 57const DJ_ZERO_B: i64 = 48 58const DJ_HEXA: i64 = 87 59const DJ_NIBBLE: i64 = 15 60const DJ_TEN: i64 = 10 61 62// ---- THE PURE RULERS ----------------------------------------------------------------------- 63// Transition classes. A deploy shifts live -> prev and installs the new binary at live, so between 64// two scans the pair (live_sha, prev_sha) moves in a way that NAMES what happened. 65const DJ_T_NEW: i64 = 0 66const DJ_T_UNCHANGED: i64 = 1 67const DJ_T_DEPLOY_WITNESSED: i64 = 2 68const DJ_T_DEPLOY_MULTI: i64 = 3 69const DJ_T_PREV_ONLY: i64 = 4 70const DJ_T_GONE: i64 = 5 71 72// THE RESOLUTION BOUND, MADE DETECTABLE INSTEAD OF SILENT. A reconstruction can only see the two 73// generations an artifact pair holds, so two deploys inside one scan window would ordinarily 74// collapse into one row and the intermediate binary would never be witnessed. It does not have to 75// be silent: after exactly ONE deploy the new prev MUST equal the live recorded at the last scan. 76// When it does not, at least two deploys occurred in the window and a binary passed through the 77// serving slot that this instrument never hashed. That is DJ_T_DEPLOY_MULTI -- the instrument 78// proving its own blindness rather than reporting a clean single deploy it cannot justify. 79func dj_transition(seen: i64, live_changed: i64, prev_is_was_live: i64, prev_changed: i64) -> i64 { 80 if seen == 0 { return DJ_T_NEW } 81 if live_changed == 1 { 82 if prev_is_was_live == 1 { return DJ_T_DEPLOY_WITNESSED } 83 return DJ_T_DEPLOY_MULTI 84 } 85 if prev_changed == 1 { return DJ_T_PREV_ONLY } 86 return DJ_T_UNCHANGED 87} 88 89// Rollback risk. Bank membership is decided by CONTENT, never by filename -- see dj_bank_has. 90const DJ_R_OK: i64 = 0 91const DJ_R_AT_RISK: i64 = 1 92const DJ_R_NO_ROLLBACK: i64 = 2 93 94func dj_risk(prev_present: i64, prev_banked: i64) -> i64 { 95 if prev_present == 0 { return DJ_R_NO_ROLLBACK } 96 if prev_banked == 1 { return DJ_R_OK } 97 return DJ_R_AT_RISK 98} 99 100// THE HAZARD THAT ACTUALLY FIRED. A deploy shifts live -> prev, so whatever prev holds RIGHT NOW is 101// overwritten by that deploy. If those bytes exist nowhere durable they are destroyed, and an 102// older-generation binary CANNOT BE REBUILT, so the loss is permanent. This is deliberately a 103// DERIVED alias of dj_risk and not a second predicate: two guards for one invariant is the 104// duplicate-ruler defect, and the copy would drift toward the flattering answer. 105func dj_would_destroy_only_rollback(prev_present: i64, prev_banked: i64) -> i64 { 106 if dj_risk(prev_present, prev_banked) == DJ_R_AT_RISK { return 1 } 107 return 0 108} 109 110// Verdict. UNPROVEN comes FIRST and is not a failure: an axis that could not enumerate has produced 111// no evidence, and abstaining is the only honest answer. It must never be spelled the same way as a 112// clean run, or a blind instrument reads as a healthy one. 113const DJ_V_GREEN: i64 = 0 114const DJ_V_AMBER: i64 = 1 115const DJ_V_RED: i64 = 2 116const DJ_V_UNPROVEN: i64 = 3 117 118// RED is reserved for a loss that HAPPENED (an irreplaceable artifact was evicted) or for proven 119// blindness (a deploy that could not be witnessed). AT-RISK is the NORMAL state after one healthy 120// deploy -- making it RED would paint the board permanently red, and a detector that is always red 121// is one everyone learns to ignore. It ratchets instead. 122func dj_verdict(irreplaceable: i64, unwitnessed: i64, atrisk: i64, floor: i64, observed: i64) -> i64 { 123 if observed == 0 { return DJ_V_UNPROVEN } 124 if irreplaceable > 0 { return DJ_V_RED } 125 if unwitnessed > 0 { return DJ_V_RED } 126 if atrisk > floor { return DJ_V_AMBER } 127 return DJ_V_GREEN 128} 129 130// The ratchet TIGHTENS on a fall and NEVER rewrites its baseline on a rise. A ratchet that moved its 131// own floor upward when the number got worse would launder every regression green, which is the 132// exact failure mode a ratchet exists to prevent. 133func dj_new_floor(atrisk: i64, floor: i64) -> i64 { 134 if atrisk < floor { return atrisk } 135 return floor 136} 137 138func dj_verdict_name(v: i64) -> *u8 { 139 if v == DJ_V_GREEN { return "GREEN" as *u8 } 140 if v == DJ_V_AMBER { return "AMBER" as *u8 } 141 if v == DJ_V_RED { return "RED" as *u8 } 142 return "UNPROVEN" as *u8 143} 144 145func dj_transition_name(t: i64) -> *u8 { 146 if t == DJ_T_NEW { return "BASELINE" as *u8 } 147 if t == DJ_T_UNCHANGED { return "UNCHANGED" as *u8 } 148 if t == DJ_T_DEPLOY_WITNESSED { return "DEPLOY-WITNESSED" as *u8 } 149 if t == DJ_T_DEPLOY_MULTI { return "DEPLOY-MULTI-UNWITNESSED" as *u8 } 150 if t == DJ_T_PREV_ONLY { return "PREV-ONLY-CHANGED" as *u8 } 151 return "GONE" as *u8 152} 153 154// ---- string helpers ------------------------------------------------------------------------ 155func dj_slen(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n } 156func dj_puts(s: *u8) -> i64 { sys_write(1, s, dj_slen(s)); return 0 } 157 158func dj_streq(a: *u8, b: *u8) -> i64 { 159 var i: i64 = 0 160 while a[i] != (0 as u8) { 161 if a[i] != b[i] { return 0 } 162 i = i + 1 163 } 164 if b[i] != (0 as u8) { return 0 } 165 return 1 166} 167 168func dj_ends_with(s: *u8, suf: *u8) -> i64 { 169 let n: i64 = dj_slen(s) 170 let m: i64 = dj_slen(suf) 171 if m > n { return 0 } 172 var i: i64 = 0 173 while i < m { 174 if s[n - m + i] != suf[i] { return 0 } 175 i = i + 1 176 } 177 return 1 178} 179 180func dj_cat(d: *u8, o: i64, s: *u8) -> i64 { 181 var i: i64 = 0 182 var p: i64 = o 183 while s[i] != (0 as u8) { d[p] = s[i]; p = p + 1; i = i + 1 } 184 return p 185} 186 187// MSB-first, ZERO allocation. An emitter that mmaps a scratch buffer per call leaks at page 188// granularity, and this one is called several times per row. 189func dj_catn(d: *u8, o: i64, v: i64) -> i64 { 190 var m: i64 = v 191 var p: i64 = o 192 if m < 0 { d[p] = DJ_DASH as u8; p = p + 1; m = 0 - m } 193 var div: i64 = 1 194 while m / div >= DJ_TEN { div = div * DJ_TEN } 195 while div > 0 { 196 let q: i64 = m / div 197 d[p] = (q - (q / DJ_TEN) * DJ_TEN + DJ_ZERO_B) as u8 198 p = p + 1 199 div = div / DJ_TEN 200 } 201 return p 202} 203 204func dj_num(v: i64) -> i64 { 205 let t: *u8 = sys_mmap(DJ_NAME_SLOT) 206 let n: i64 = dj_catn(t, 0, v) 207 sys_write(1, t, n) 208 sys_munmap(t, DJ_NAME_SLOT) 209 return 0 210} 211 212// ---- stat / hash --------------------------------------------------------------------------- 213// outs[0] = mtime epoch, outs[1] = size. Returns 1 on success, 0 when the path cannot be stat'd. 214// WHAT MTIME MEANS HERE: a deploy that installs by rename carries the STAGED file's mtime, so this 215// is the artifact's WRITE time -- a proxy for the deploy time, not the deploy time. It is recorded 216// as evidence and labelled as such; the scan epoch is what orders the journal. 217func dj_stat(path: *u8, outs: *i64) -> i64 { 218 let sb: *u8 = sys_mmap(DJ_STATBUF_BYTES) 219 let rc: i64 = __syscall(DJ_SYS_NEWFSTATAT, DJ_AT_FDCWD_L, path as i64, sb as i64, 0, 0, 0) 220 if rc != 0 { 221 sys_munmap(sb, DJ_STATBUF_BYTES) 222 outs[0] = 0 223 outs[1] = 0 224 return 0 225 } 226 let mp: *i64 = ((sb as i64) + DJ_STAT_OFF_MTIME) as *i64 227 let sp: *i64 = ((sb as i64) + DJ_STAT_OFF_SIZE) as *i64 228 outs[0] = mp[0] 229 outs[1] = sp[0] 230 sys_munmap(sb, DJ_STATBUF_BYTES) 231 return 1 232} 233 234func dj_hex_into(d: *u8, out: *u8) -> i64 { 235 var i: i64 = 0 236 while i < DJ_DIGEST_BYTES { 237 let v: i64 = d[i] as i64 238 let hi: i64 = (v >> 4) & DJ_NIBBLE 239 let lo: i64 = v & DJ_NIBBLE 240 if hi < DJ_TEN { out[i*2] = (DJ_ZERO_B+hi) as u8 } else { out[i*2] = (DJ_HEXA+hi) as u8 } 241 if lo < DJ_TEN { out[i*2+1] = (DJ_ZERO_B+lo) as u8 } else { out[i*2+1] = (DJ_HEXA+lo) as u8 } 242 i = i + 1 243 } 244 out[DJ_HEX_BYTES] = 0 as u8 245 return 0 246} 247 248// Writes 64 hex chars + NUL into outhex. RETURNS THE BYTE COUNT, or -1 when the file cannot be 249// read. Composes sys_read_file, which sizes its buffer from the file and therefore cannot 250// short-read, and the CANONICAL sha256 (re-implementing that primitive is refused estate-wide). 251// A hasher that digested an empty buffer for a missing file would hand back a real-looking 64-hex 252// answer for a file that does not exist -- a lying instrument. Fail loud instead. 253func dj_hash_file(path: *u8, outhex: *u8) -> i64 { 254 let ln: *i64 = sys_mmap(DJ_LENSLOT) as *i64 255 let buf: *u8 = sys_read_file(path, ln) 256 if (buf as i64) == 0 { 257 sys_munmap(ln as *u8, DJ_LENSLOT) 258 return 0 - 1 259 } 260 let n: i64 = ln[0] 261 let dg: *u8 = sys_mmap(DJ_DIGEST_BYTES) 262 sha256_digest(buf, n, dg) 263 dj_hex_into(dg, outhex) 264 sys_munmap(dg, DJ_DIGEST_BYTES) 265 if n > 0 { sys_munmap(buf, n) } 266 sys_munmap(ln as *u8, DJ_LENSLOT) 267 return n 268} 269 270// ---- directory walk ------------------------------------------------------------------------ 271const DJ_W_OPENFAIL: i64 = 0 272const DJ_W_OK: i64 = 1 273const DJ_W_CAP: i64 = 2 274const DJ_W_READERR: i64 = 3 275 276// Collect every entry of `dir` whose name ends with `suffix` (an empty suffix matches all) into a 277// fixed-slot arena. Composes sys_getdents64, which is ARCH-SWITCHED in nx_syscalls.nx -- a 278// hard-pinned 217 dispatches as an unrelated call on RV64. 279// 280// It LOOPS until getdents64 returns 0: one getdents64 call is not a directory listing, and a prefix 281// published as a total is a silent truncation. It REFUSES at the cap rather than returning a short 282// list, because a cap reached in silence becomes a measurement nobody knows is partial. And it 283// separates an error on the FIRST read from a genuine end-of-directory: without O_DIRECTORY a 284// non-directory path opens fine and getdents64 returns negative, which would otherwise read as a 285// confident "this directory is empty". 286func dj_walk(dir: *u8, suffix: *u8, names: *u8, slot: i64, cap: i64, outn: *i64, outskip: *i64) -> i64 { 287 outn[0] = 0 288 outskip[0] = 0 289 let dfd: i64 = sys_openat_rd(dir) 290 if dfd < 0 { return DJ_W_OPENFAIL } 291 let buf: *u8 = sys_mmap(DJ_DENTS_BUF_BYTES) 292 let nm: *u8 = sys_mmap(DJ_NAME_SLOT) 293 var n: i64 = 0 294 var skipped: i64 = 0 295 var go: i64 = 1 296 var first: i64 = 1 297 var rc: i64 = DJ_W_OK 298 while go == 1 { 299 let nread: i64 = sys_getdents64(dfd, buf, DJ_DENTS_BUF_BYTES) 300 if nread < 0 { 301 if first == 1 { rc = DJ_W_READERR } 302 go = 0 303 } 304 if nread == 0 { go = 0 } 305 if nread > 0 { 306 first = 0 307 var pos: i64 = 0 308 while pos < nread { 309 let rec: *u8 = ((buf as i64) + pos) as *u8 310 let reclen: i64 = dirent_reclen(rec) 311 let dtype: i64 = dirent_type(rec) 312 let src: *u8 = dirent_name(rec) 313 var nl: i64 = 0 314 var over: i64 = 0 315 while src[nl] != (0 as u8) { 316 if nl < slot - 1 { nm[nl] = src[nl] } else { over = 1 } 317 nl = nl + 1 318 } 319 if over == 1 { nl = slot - 1 } 320 nm[nl] = 0 as u8 321 var take: i64 = 1 322 if over == 1 { take = 0 } 323 if dtype == DT_DIR { take = 0 } 324 if nl == 0 { take = 0 } 325 if nl == 1 { if nm[0] == (DJ_DOT_B as u8) { take = 0 } } 326 if nl == 2 { if nm[0] == (DJ_DOT_B as u8) { if nm[1] == (DJ_DOT_B as u8) { take = 0 } } } 327 if take == 1 { if dj_ends_with(nm, suffix) == 0 { take = 0 } } 328 if take == 1 { 329 if n >= cap { 330 rc = DJ_W_CAP 331 go = 0 332 pos = nread 333 } 334 if n < cap { 335 dj_cat(names, n * slot, nm) 336 names[n * slot + nl] = 0 as u8 337 n = n + 1 338 } 339 } 340 if take == 0 { if over == 1 { skipped = skipped + 1 } } 341 if pos < nread { 342 if reclen <= 0 { pos = nread } else { pos = pos + reclen } 343 } 344 } 345 } 346 } 347 sys_close(dfd) 348 sys_munmap(buf, DJ_DENTS_BUF_BYTES) 349 sys_munmap(nm, DJ_NAME_SLOT) 350 outn[0] = n 351 outskip[0] = skipped 352 return rc 353} 354 355// ---- bank membership ----------------------------------------------------------------------- 356// MEMBERSHIP IS BY CONTENT, NEVER BY NAME. Bank files carry free-form tags (.pre-ahead-20260825, 357// .prechange-20260822, ...), so a name-keyed lookup would report a banked artifact as ABSENT the 358// moment someone chose a tag we did not predict -- and "this rollback target is unbanked" is 359// precisely the answer that raises an alarm. Hashing every bank file once and matching digests 360// cannot be fooled by a tag. 361func dj_bank_has(bankhex: *u8, nbank: i64, hex: *u8) -> i64 { 362 var i: i64 = 0 363 while i < nbank { 364 let e: *u8 = ((bankhex as i64) + i * DJ_HEXSLOT) as *u8 365 if dj_streq(e, hex) == 1 { return 1 } 366 i = i + 1 367 } 368 return 0 369} 370 371// ---- status round-trip --------------------------------------------------------------------- 372// The status artifact is parsed back by its OWN producer on the next scan, so a format drift breaks 373// this organ's next run instead of silently misleading a consumer. 374// 375// Field reads are ANCHORED on a preceding space so a key can never match inside a longer key, and 376// the writer is required never to repeat a field name in prose -- a parser that reads the data as 377// if it were the answer is a defect this estate has already paid for more than once. 378// Parse a non-negative decimal. Returns -1 for empty or non-numeric input rather than 0, because a 379// ratchet floor that silently read a corrupt conf as 0 would refuse every future scan. 380func dj_atoi(s: *u8) -> i64 { 381 if s[0] == (0 as u8) { return 0 - 1 } 382 var i: i64 = 0 383 var v: i64 = 0 384 while s[i] != (0 as u8) { 385 let c: i64 = s[i] as i64 386 if c < DJ_ZERO_B { return 0 - 1 } 387 if c > DJ_ZERO_B + 9 { return 0 - 1 } 388 v = v * DJ_TEN + (c - DJ_ZERO_B) 389 i = i + 1 390 } 391 return v 392} 393 394func dj_field(buf: *u8, ls: i64, le: i64, key: *u8, out: *u8, outcap: i64) -> i64 { 395 let kl: i64 = dj_slen(key) 396 var i: i64 = ls 397 while i + kl < le { 398 var hit: i64 = 1 399 if i > ls { 400 let b: i64 = buf[i-1] as i64 401 var okb: i64 = 0 402 if b == DJ_SPACE { okb = 1 } 403 if b == DJ_NL { okb = 1 } 404 if okb == 0 { hit = 0 } 405 } 406 if hit == 1 { 407 var k: i64 = 0 408 while k < kl { 409 if buf[i+k] != key[k] { k = kl + 1 } else { k = k + 1 } 410 } 411 if k > kl { hit = 0 } 412 } 413 if hit == 1 { 414 var j: i64 = i + kl 415 var o: i64 = 0 416 while j < le { 417 let c: i64 = buf[j] as i64 418 var stop: i64 = 0 419 if c == DJ_SPACE { stop = 1 } 420 if c == DJ_NL { stop = 1 } 421 if stop == 1 { j = le } else { 422 if o < outcap - 1 { out[o] = buf[j]; o = o + 1 } 423 j = j + 1 424 } 425 } 426 out[o] = 0 as u8 427 return 1 428 } 429 i = i + 1 430 } 431 out[0] = 0 as u8 432 return 0 433}