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}