code wiki / _hdl_build / nx_atomic_publish.nx
nx_atomic_publish.nx source
↩ module page · 762 lines · 40212 B
1// nx_atomic_publish.nx -- ALL-OR-NOTHING FILE PUBLISH WITH COMPARE-AND-SWAP, IN NISHILANG.
2//
3// WHY IT EXISTS. A multi-part source change applied as N sequential anchored edits is N chances to
4// half-land. Measured twice on 2026-08-07: the MCP daemon wedged mid-sequence, edit 1 landed and edits
5// 2-3 did not, leaving a live source with an UNCLOSED BRACE -- a broken file that blocks every build of
6// that target, found only because the artefact was verified rather than the response believed.
7// ★★★★★★A MULTI-PART EDIT OVER A FLAKY TRANSPORT IS AN ATOMICITY PROBLEM, NOT A RETRY PROBLEM:
8// retrying makes a half-applied file MORE likely, not less. Stage the whole file, publish once.
9//
10// WHY IT IS AN ORGAN AND NOT A SHELL HELPER. The first version of this was a .ps1, and it JUDGED:
11// it parsed a refusal to recover a token, classified transient-vs-deterministic, and decided
12// refuse-vs-retry. That is judgement, and judgement belongs in NishiLang -- shell is the wire and
13// nothing else. Here the only laptop-side act left is moving bytes to a scratch path; every decision
14// happens on this side.
15//
16// usage: nx_atomic_publish <staged-src> <target> <expect>
17// expect = "any" -- publish regardless (NAME IT OUT LOUD when you use it)
18// | "absent" -- publish only if the target does not exist yet
19// | <64-hex sha256> -- publish only if the target's CURRENT content hashes to exactly this
20// nx_atomic_publish landed <writelist> -- the READ half: adjudicate ambiguous writes (ap_landed)
21//
22// CAS SEMANTICS, and the direction of failure is the point: on mismatch it REFUSES and PRINTS the live
23// hash, so the caller can re-read, merge, and retry with the right token. It never re-resolves the token
24// itself -- that would turn a concurrent writer's edit into a silent clobber, which is the exact defect
25// compare-and-swap exists to prevent.
26// ★A CAS THAT RESOLVES ITS OWN EXPECTED VALUE IS NOT A CAS, IT IS AN OVERWRITE WITH EXTRA STEPS.
27//
28// ATOMIC BY renameat, NOT BY WRITE ORDER: content goes to <target>.aptmp then renames over the target,
29// so a reader never sees a half-written file and a crash mid-publish leaves the original intact.
30// VERIFY AFTER: the published bytes are re-read and re-hashed before GREEN is printed.
31// license_tier: ORIGINAL expect_exit: 0
32import "nx_syscalls.nx"
33import "nx_sha256.nx"
34
35const AP_STDOUT: i64 = 1
36const AP_PATHCAP: i64 = 1024
37const AP_MODE: i64 = 420 // 0644
38
39func ap_puts(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } sys_write(AP_STDOUT, s, n); return 0 }
40
41func ap_len(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n }
42
43func ap_num(v: i64) -> i64 {
44 let b: *u8 = sys_mmap(32)
45 var m: i64 = v
46 if m < 0 { m = 0 - m; sys_write(AP_STDOUT, "-" as *u8, 1) }
47 let t: *u8 = sys_mmap(32)
48 var k: i64 = 0
49 if m == 0 { t[0] = 48 as u8; k = 1 }
50 while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 }
51 var i: i64 = 0
52 while i < k { b[i] = t[k - 1 - i]; i = i + 1 }
53 sys_write(AP_STDOUT, b, k)
54 return 0
55}
56
57// 32 raw digest bytes -> 64 lowercase hex, NUL terminated.
58func ap_hex(dig: *u8, out: *u8) -> i64 {
59 let d: *u8 = "0123456789abcdef" as *u8
60 var i: i64 = 0
61 while i < 32 {
62 let v: i64 = dig[i] as i64
63 out[i * 2] = d[(v / 16) % 16]
64 out[i * 2 + 1] = d[v % 16]
65 i = i + 1
66 }
67 out[64] = 0 as u8
68 return 64
69}
70
71func ap_streq(a: *u8, b: *u8) -> i64 {
72 var i: i64 = 0
73 while a[i] != (0 as u8) { if a[i] != b[i] { return 0 } i = i + 1 }
74 if b[i] != (0 as u8) { return 0 }
75 return 1
76}
77
78func ap_cat2(out: *u8, a: *u8, b: *u8) -> i64 {
79 var o: i64 = 0
80 var i: i64 = 0
81 while a[i] != (0 as u8) { out[o] = a[i]; o = o + 1; i = i + 1 }
82 i = 0
83 while b[i] != (0 as u8) { out[o] = b[i]; o = o + 1; i = i + 1 }
84 out[o] = 0 as u8
85 return o
86}
87
88// Hash a file into `hex` and hand the caller the bytes it just read; returns the byte count, or -1 when
89// the file cannot be read.
90// ⚠SILENT CAP REMOVED 2026-08-20: this used to copy the file into a fixed 8 MiB scratch buffer before
91// hashing, so any file over that ceiling wrote past the allocation -- a LIMIT-class defect that fails as
92// corruption rather than as a refusal. sha256_digest does not mutate its input, so the copy bought
93// nothing; sys_read_file already sizes its own buffer from the file and cannot short-read. There is now
94// no ceiling to guess in either direction.
95func ap_hash_file(path: *u8, outp: *i64, hex: *u8) -> i64 {
96 let szp: *i64 = sys_mmap(16) as *i64
97 let b: *u8 = sys_read_file(path, szp)
98 if (b as i64) == 0 { return 0 - 1 }
99 let n: i64 = szp[0]
100 let dig: *u8 = sys_mmap(32)
101 sha256_digest(b, n, dig)
102 ap_hex(dig, hex)
103 outp[0] = b as i64
104 return n
105}
106
107// ---------------------------------------------------------------------------------------------------
108// ap_landed -- THE CRASH-WRITE ADJUDICATOR (DM4 on /compare/devmgmt).
109//
110// WHY IT LIVES HERE. This organ already owns the compare-and-swap publish: it knows what a settled
111// artifact is, it can hash one, and its refusal path is the estate's most authoritative "what is
112// actually there right now" answer. Adjudicating "did my write land?" is the READ half of the same
113// question and belongs beside the WRITE half, not in a second ruler.
114//
115// WHAT IT MECHANIZES. On 2026-08-19 two crashes killed thirteen seats mid-action and the in-flight
116// async writes split roughly half landed / half not; every lane adjudicated its own rows BY HAND from
117// artifacts. The same week produced four writes that returned 503-or-nothing and HAD landed, three the
118// same hour that had NOT, a bare empty-object reply that had landed, a debt filed twice because one
119// journal search returned zero, and a whole-function insert that only the CAS guard caught as a
120// duplicate.
121//
122// THE EVIDENCE HIERARCHY, AND THE DIRECTION OF ERROR IS THE POINT.
123// A false LANDED is a lost edit nobody ever re-applies. A false NOT-LANDED costs one idempotent
124// retry. So LANDED is emitted from EXACTLY TWO SITES in this code, and both require a SELF-COMPUTED
125// measurement over a SETTLED artifact matched against an expectation the caller recorded BEFORE the
126// write. Nothing else can reach them -- that is structural, not a promise:
127// tier 1 CONTENT HASH want=h<sha256> authoritative; we compute the digest ourselves
128// tier 2 MARKER COUNT mark= plus mark_pre= a witness; the only tier that can see DOUBLE-APPLIED
129// tier 3 PRE-IMAGE ONLY pre=h<sha256> can only ever prove NOT-LANDED, never LANDED
130// tier 4 NOTHING DECIDABLE -> UNKNOWN with a named reason
131// ⛔A BARE SIZE READ IS NOT EVIDENCE and neither is the transport's reply text. `size=` and `reply=`
132// are accepted, ECHOED so the reader sees what was believed, and NEVER consulted. Measured four times
133// on 2026-08-20: a size read taken right after a dropped write reports the PRE-write size, and an
134// empty / 503 / session-expired reply discriminated nothing in either direction. The record carries
135// the controlled pairs that settle it: the SAME 503 text over LANDED and NOT-LANDED writes in the same
136// hour, and the same bare empty-object reply over both outcomes in one lane.
137//
138// THE IMPRECISION CHOSEN, STATED SO THE NEXT READER DOES NOT TRUST THIS AS EXACT. A create whose target
139// reads absent in BOTH passes is called NOT-LANDED. That can be wrong: a read taken through a caching
140// layer reported ABSENT three times on 2026-08-20 for a file that had landed. It is wrong in the SAFE
141// direction (one idempotent create-retry, refused by the guard if it turns out to exist), this organ
142// reads the filesystem directly rather than through a caching tool, and a transient absence is caught by
143// the two-pass settle. It is still an imprecision and it is named here rather than left to be trusted.
144//
145// SETTLING IS A PRECONDITION, NOT A REFINEMENT. Every path is hashed TWICE -- once in pass A over the
146// whole list, once in pass B after a single settle interval -- because "the artifact says it did not
147// land" is not proof it did not land while a writer is still in flight. Two reads that disagree are
148// UNSETTLED and get UNKNOWN, never a verdict. The interval is paid ONCE for the entire worklist, not
149// per row, so the cost does not scale with the list.
150// settle_ms is READ FROM CONF and its source is announced. The default is not taste: it adopts the
151// sibling calibration in nx_memvel, whose sampling window exists for exactly this purpose (detect a
152// change over an interval longer than the event being watched) rather than inventing a fresh budget.
153//
154// ROW GRAMMAR -- one row per line, TAB-separated key=value tokens; blank lines and lines starting with
155// a double slash are skipped.
156// id=<slug> required
157// path=<file> required -- the artifact to read back
158// kind=publish|promote|create|replace|insert|append selects the RETRY ADVICE, never the verdict
159// want=h<64hex> the sha256 the file was meant to have AFTER the write
160// pre=h<64hex>|absent what the file was BEFORE it ("absent" for a create)
161// mark=<string> a distinctive string the write INTRODUCES (anchored/append kinds)
162// mark_pre=<n> how many times mark occurred BEFORE the write -- REQUIRED with mark, never assumed
163// mark_adds=<n> occurrences one correct apply adds (default APL_DEFAULT_MARK_ADDS, announced)
164// size=<n> reply=<t> RECORDED AND IGNORED, on purpose (see above)
165// An unrecognised token makes the row MALFORMED -> UNKNOWN. A row we cannot parse is a row we must
166// not judge.
167//
168// exit: 0 every row decided | 1 at least one UNKNOWN (a human must look) | 2 usage | 3 list unreadable
169// ---------------------------------------------------------------------------------------------------
170const APL_TAB: i64 = 9
171const APL_NL: i64 = 10
172const APL_CR: i64 = 13
173const APL_EQ: i64 = 61
174const APL_HCHAR: i64 = 104 // the 'h' prefix of an h<hex> compare-and-swap token
175const APL_SLASH: i64 = 47 // '/' -- a doubled slash starts a comment row in the grammar
176const APL_D0: i64 = 48 // ASCII '0' -- the base of decimal parsing, structural
177const APL_D9: i64 = 57
178const APL_DEC_BASE: i64 = 10 // every count in the row grammar is written in decimal
179const APL_NUL_SLACK: i64 = 2 // room to NUL-terminate the working copy AND its final line
180const APL_RC_LIST_UNREADABLE: i64 = 3 // the writelist itself could not be read: not a verdict about any row
181const APL_HEXSLOT: i64 = 80 // 64 hex + NUL, rounded up to a comfortable per-row slot
182const APL_PTRW: i64 = 8 // one i64 per parallel-array element
183const APL_DEFAULT_MARK_ADDS: i64 = 1 // one anchored apply introduces its marker once; stated, not assumed
184// DERIVATION, not a chosen number: nx_memvel samples change over 1200 ms windows precisely because a
185// window must outlast the event it watches. This is the same question (is this file being written right
186// now?) so it reuses that sibling calibration instead of inventing a second budget.
187const APL_SETTLE_MS_DEFAULT: i64 = 1200
188const APL_CONF: *u8 = "knowledge/status/atomic_publish.conf"
189
190const APL_V_LANDED: i64 = 0
191const APL_V_NOT: i64 = 1
192const APL_V_DOUBLE: i64 = 2
193const APL_V_UNKNOWN: i64 = 3
194
195// UNKNOWN reasons -- two causes with different remedies never share a counter
196const APL_R_NONE: i64 = 0
197const APL_R_UNSETTLED: i64 = 1
198const APL_R_UNREADABLE: i64 = 2
199const APL_R_WANT_EQ_PRE: i64 = 3
200const APL_R_THIRD_STATE: i64 = 4
201const APL_R_MARK_FELL: i64 = 5
202const APL_R_MARK_PARTIAL: i64 = 6
203const APL_R_CHANGED_UNDECLARED: i64 = 7
204const APL_R_SIZE_ONLY: i64 = 8
205const APL_R_REPLY_ONLY: i64 = 9
206const APL_R_NO_EXPECTATION: i64 = 10
207const APL_R_MALFORMED: i64 = 11
208const APL_R_MARK_NO_PRE: i64 = 12
209const APL_R_COUNT: i64 = 13 // one past the highest reason code; the counter table size
210
211func apl_reason_name(r: i64) -> *u8 {
212 if r == APL_R_UNSETTLED { return "unsettled-two-reads-differ" as *u8 }
213 if r == APL_R_UNREADABLE { return "artifact-unreadable" as *u8 }
214 if r == APL_R_WANT_EQ_PRE { return "want-equals-pre-indistinguishable" as *u8 }
215 if r == APL_R_THIRD_STATE { return "third-state-content-matches-neither" as *u8 }
216 if r == APL_R_MARK_FELL { return "marker-count-fell-below-pre" as *u8 }
217 if r == APL_R_MARK_PARTIAL { return "marker-count-between-pre-and-applied" as *u8 }
218 if r == APL_R_CHANGED_UNDECLARED { return "changed-but-target-content-undeclared" as *u8 }
219 if r == APL_R_SIZE_ONLY { return "size-only-not-evidence" as *u8 }
220 if r == APL_R_REPLY_ONLY { return "reply-text-not-evidence" as *u8 }
221 if r == APL_R_NO_EXPECTATION { return "no-expectation-recorded" as *u8 }
222 if r == APL_R_MALFORMED { return "malformed-row-unrecognised-or-missing-token" as *u8 }
223 if r == APL_R_MARK_NO_PRE { return "mark-without-mark-pre" as *u8 }
224 return "unnamed" as *u8
225}
226
227// RETRY ADVICE IS A PROPERTY OF THE EDIT KIND, NOT OF THE VERDICT. An insert is not idempotent: its
228// context anchor survives the apply, so a blind retry double-inserts -- and a stale multi-region diff
229// invalidates its own later anchors, measured on the 21-region ladder of 2026-08-20.
230// ⚠THE ANCHORED-REPLACE CAVEAT IS LOAD-BEARING AND WAS NEARLY LEFT OUT. The three-state retry table
231// (OK means it had not landed / NOMATCH means it had) is only sound while the replacement cannot
232// re-match its own anchor. Measured 2026-08-20: an edit whose `new` string CONTAINED its `old` anchor
233// would have inserted the same function twice on a blind retry. The advice carries the condition rather
234// than the conclusion, because the conclusion is false for a shape this estate actually writes.
235func apl_advice(kind: *u8) -> *u8 {
236 if (kind as i64) == 0 { return "KIND-UNDECLARED cannot advise on retry safety -- declare kind= before acting" as *u8 }
237 if ap_streq(kind, "publish" as *u8) == 1 { return "RETRY-SAFE re-issue the whole-file publish under expect=h<now_sha256> -- a CAS publish is idempotent" as *u8 }
238 if ap_streq(kind, "create" as *u8) == 1 { return "RETRY-SAFE re-issue with expect=absent -- the guard refuses if it turns out to exist" as *u8 }
239 if ap_streq(kind, "promote" as *u8) == 1 { return "RETRY-SELF-ADJUDICATING re-issue the promote -- NOTHING-STAGED with live_sha256 equal to your build sha means it IS live" as *u8 }
240 if ap_streq(kind, "register" as *u8) == 1 { return "RETRY-SAFE registration is idempotent -- re-issue and read callable= from the response" as *u8 }
241 if ap_streq(kind, "replace" as *u8) == 1 { return "RETRY-EXACT-CONDITIONAL re-issue the identical anchored edit ONLY IF the replacement cannot re-match its own anchor -- then OK means it had not landed and NOMATCH means it had; if the new text CONTAINS the old, treat it as an insert and RE-DIFF instead" as *u8 }
242 if ap_streq(kind, "insert" as *u8) == 1 { return "RE-DIFF do NOT re-issue -- an insert is not idempotent, its anchor survives the apply and a blind retry double-inserts" as *u8 }
243 if ap_streq(kind, "append" as *u8) == 1 { return "RE-DIFF do NOT re-issue -- an append is not idempotent; check the marker count against the journal first" as *u8 }
244 if ap_streq(kind, "put" as *u8) == 1 { return "CHECK-THE-PLANE-FIRST do NOT blind-retry -- a plane put appends a row, two puts leave two rows, and there is no row-delete primitive to undo the second" as *u8 }
245 return "KIND-UNDECLARED cannot advise on retry safety -- declare kind= before acting" as *u8
246}
247
248func apl_verdict_name(v: i64) -> *u8 {
249 if v == APL_V_LANDED { return "LANDED" as *u8 }
250 if v == APL_V_NOT { return "NOT-LANDED" as *u8 }
251 if v == APL_V_DOUBLE { return "DOUBLE-APPLIED" as *u8 }
252 return "UNKNOWN" as *u8
253}
254
255func apl_slot(base: *u8, i: i64, w: i64) -> *u8 { return ((base as i64) + i * w) as *u8 }
256
257// value side of `key=value` when the token's key is EXACTLY `key`, else 0. Exactness matters: it is what
258// keeps `mark=` from swallowing `mark_pre=` and `mark_adds=`.
259func apl_kv(tok: *u8, key: *u8) -> *u8 {
260 var i: i64 = 0
261 while key[i] != (0 as u8) { if tok[i] != key[i] { return 0 as *u8 } i = i + 1 }
262 if (tok[i] as i64) != APL_EQ { return 0 as *u8 }
263 return ((tok as i64) + i + 1) as *u8
264}
265
266// strip the 'h' of an h<hex> CAS token. Unambiguous: 'h' is not a hex digit, so a bare digest can never
267// be mistaken for a prefixed one.
268func apl_dehex(s: *u8) -> *u8 {
269 if (s[0] as i64) == APL_HCHAR { return ((s as i64) + 1) as *u8 }
270 return s
271}
272
273func apl_atoi(s: *u8) -> i64 {
274 var v: i64 = 0
275 var i: i64 = 0
276 while s[i] != (0 as u8) {
277 let c: i64 = s[i] as i64
278 if c < APL_D0 { return 0 - 1 }
279 if c > APL_D9 { return 0 - 1 }
280 v = v * APL_DEC_BASE + (c - APL_D0)
281 i = i + 1
282 }
283 if i == 0 { return 0 - 1 }
284 return v
285}
286
287// NON-OVERLAPPING occurrences of needle in the first n bytes of buf. The inner compare deliberately
288// runs to completion instead of breaking by clobbering its own cursor -- that idiom erases the answer.
289func apl_count_occ(buf: *u8, n: i64, needle: *u8) -> i64 {
290 let m: i64 = ap_len(needle)
291 if m == 0 { return 0 - 1 }
292 if m > n { return 0 }
293 var c: i64 = 0
294 var i: i64 = 0
295 while i <= n - m {
296 var j: i64 = 0
297 var ok: i64 = 1
298 while j < m {
299 if buf[i + j] != needle[j] { ok = 0 }
300 j = j + 1
301 }
302 if ok == 1 { c = c + 1; i = i + m } else { i = i + 1 }
303 }
304 return c
305}
306
307// Read <path> once: hex-digest it, and count `needle` over the same bytes. Returns the byte count, or
308// -1 when the file cannot be read (which is how ABSENT is detected). occ_out receives the occurrence
309// count, or -1 when no needle was asked for.
310func apl_scan(path: *u8, hexout: *u8, needle: *u8, occ_out: *i64) -> i64 {
311 let szp: *i64 = sys_mmap(16) as *i64
312 let b: *u8 = sys_read_file(path, szp)
313 if (b as i64) == 0 { occ_out[0] = 0 - 1; return 0 - 1 }
314 let n: i64 = szp[0]
315 let dig: *u8 = sys_mmap(32)
316 sha256_digest(b, n, dig)
317 ap_hex(dig, hexout)
318 if (needle as i64) == 0 { occ_out[0] = 0 - 1 } else { occ_out[0] = apl_count_occ(b, n, needle) }
319 return n
320}
321
322// settle_ms from conf, or -1 when the conf is absent or carries no row. The value used is ANNOUNCED by
323// the caller either way: an unannounced default is the defect, not the default itself.
324func apl_conf_settle_ms() -> i64 {
325 let szp: *i64 = sys_mmap(16) as *i64
326 let b: *u8 = sys_read_file(APL_CONF, szp)
327 if (b as i64) == 0 { return 0 - 1 }
328 let n: i64 = szp[0]
329 let key: *u8 = "settle_ms " as *u8
330 let m: i64 = ap_len(key)
331 var found: i64 = 0 - 1
332 var i: i64 = 0
333 while i <= n - m {
334 var atline: i64 = 0
335 if i == 0 { atline = 1 }
336 if i > 0 { if (b[i - 1] as i64) == APL_NL { atline = 1 } }
337 if atline == 1 {
338 var j: i64 = 0
339 var ok: i64 = 1
340 while j < m {
341 if b[i + j] != key[j] { ok = 0 }
342 j = j + 1
343 }
344 if ok == 1 { if found < 0 {
345 var v: i64 = 0
346 var got: i64 = 0
347 var k: i64 = i + m
348 var scanning: i64 = 1
349 while scanning == 1 {
350 if k >= n { scanning = 0 } else {
351 let c: i64 = b[k] as i64
352 if c < APL_D0 { scanning = 0 } else {
353 if c > APL_D9 { scanning = 0 } else {
354 v = v * APL_DEC_BASE + (c - APL_D0); got = 1; k = k + 1
355 }
356 }
357 }
358 }
359 if got == 1 { found = v }
360 } }
361 }
362 i = i + 1
363 }
364 return found
365}
366
367// THE CONTRACT SYMBOL. devmgmt.plan names this rung `ap_landed`, and the watch row measures that the
368// SYMBOL EXISTS -- so the entry point carries the contract name rather than leaving it to live only in
369// prose. ★A COMPLETION SIGNAL THAT KEYS ON A NAME REWARDS WRITING THE NAME: the flip is the receipt,
370// the gate is the proof.
371func ap_landed(listpath: *u8) -> i64 {
372 let szp: *i64 = sys_mmap(16) as *i64
373 let raw: *u8 = sys_read_file(listpath, szp)
374 if (raw as i64) == 0 {
375 ap_puts("AP-LANDED verdict=RED reason=writelist-unreadable path=" as *u8); ap_puts(listpath); ap_puts("\n" as *u8)
376 return APL_RC_LIST_UNREADABLE
377 }
378 let rn: i64 = szp[0]
379 if rn == 0 {
380 ap_puts("AP-LANDED verdict=RED reason=writelist-empty path=" as *u8); ap_puts(listpath); ap_puts("\n" as *u8)
381 return APL_RC_LIST_UNREADABLE
382 }
383 // Own copy so tokens can be NUL-terminated in place without mutating the read buffer.
384 let buf: *u8 = sys_mmap(rn + APL_NUL_SLACK)
385 var c0: i64 = 0
386 while c0 < rn { buf[c0] = raw[c0]; c0 = c0 + 1 }
387 buf[rn] = 0 as u8
388
389 // NO CAP: the row table is sized from the line count of the file itself, so nothing is silently
390 // dropped and there is no ceiling to guess.
391 var lines: i64 = 1
392 var li: i64 = 0
393 while li < rn { if (buf[li] as i64) == APL_NL { lines = lines + 1 } li = li + 1 }
394
395 let a_id: *i64 = sys_mmap(lines * APL_PTRW) as *i64
396 let a_path: *i64 = sys_mmap(lines * APL_PTRW) as *i64
397 let a_kind: *i64 = sys_mmap(lines * APL_PTRW) as *i64
398 let a_want: *i64 = sys_mmap(lines * APL_PTRW) as *i64
399 let a_pre: *i64 = sys_mmap(lines * APL_PTRW) as *i64
400 let a_mark: *i64 = sys_mmap(lines * APL_PTRW) as *i64
401 let a_size: *i64 = sys_mmap(lines * APL_PTRW) as *i64
402 let a_reply: *i64 = sys_mmap(lines * APL_PTRW) as *i64
403 let a_markpre: *i64 = sys_mmap(lines * APL_PTRW) as *i64
404 let a_hasmp: *i64 = sys_mmap(lines * APL_PTRW) as *i64
405 let a_adds: *i64 = sys_mmap(lines * APL_PTRW) as *i64
406 let a_hasadds: *i64 = sys_mmap(lines * APL_PTRW) as *i64
407 let a_bad: *i64 = sys_mmap(lines * APL_PTRW) as *i64
408 let a_nA: *i64 = sys_mmap(lines * APL_PTRW) as *i64
409 let a_nB: *i64 = sys_mmap(lines * APL_PTRW) as *i64
410 let a_occA: *i64 = sys_mmap(lines * APL_PTRW) as *i64
411 let a_occB: *i64 = sys_mmap(lines * APL_PTRW) as *i64
412 let hexA: *u8 = sys_mmap(lines * APL_HEXSLOT)
413 let hexB: *u8 = sys_mmap(lines * APL_HEXSLOT)
414
415 var nrows: i64 = 0
416 var p: i64 = 0
417 while p < rn {
418 var e: i64 = p
419 var sc: i64 = 1
420 while sc == 1 {
421 if e >= rn { sc = 0 } else {
422 if (buf[e] as i64) == APL_NL { sc = 0 } else { e = e + 1 }
423 }
424 }
425 var q: i64 = e
426 if q > p { if (buf[q - 1] as i64) == APL_CR { q = q - 1 } }
427 buf[q] = 0 as u8
428 let line: *u8 = ((buf as i64) + p) as *u8
429 var skip: i64 = 0
430 if q == p { skip = 1 }
431 if skip == 0 { if (line[0] as i64) == APL_TAB { skip = 1 } }
432 if skip == 0 { if ap_len(line) > 1 { if (line[0] as i64) == APL_SLASH { if (line[1] as i64) == APL_SLASH { skip = 1 } } } }
433 if skip == 0 {
434 let r: i64 = nrows
435 a_adds[r] = APL_DEFAULT_MARK_ADDS
436 var t: i64 = p
437 var tsc: i64 = 1
438 while tsc == 1 {
439 var te: i64 = t
440 var isc: i64 = 1
441 while isc == 1 {
442 if te >= q { isc = 0 } else {
443 if (buf[te] as i64) == APL_TAB { isc = 0 } else { te = te + 1 }
444 }
445 }
446 let hit_end: i64 = te
447 buf[te] = 0 as u8
448 let tok: *u8 = ((buf as i64) + t) as *u8
449 if (tok[0] as i64) != 0 {
450 var matched: i64 = 0
451 var v: *u8 = apl_kv(tok, "id" as *u8)
452 if (v as i64) != 0 { a_id[r] = v as i64; matched = 1 }
453 if matched == 0 { v = apl_kv(tok, "path" as *u8); if (v as i64) != 0 { a_path[r] = v as i64; matched = 1 } }
454 if matched == 0 { v = apl_kv(tok, "kind" as *u8); if (v as i64) != 0 { a_kind[r] = v as i64; matched = 1 } }
455 if matched == 0 { v = apl_kv(tok, "want" as *u8); if (v as i64) != 0 { a_want[r] = v as i64; matched = 1 } }
456 if matched == 0 { v = apl_kv(tok, "pre" as *u8); if (v as i64) != 0 { a_pre[r] = v as i64; matched = 1 } }
457 if matched == 0 { v = apl_kv(tok, "mark_pre" as *u8); if (v as i64) != 0 { a_markpre[r] = apl_atoi(v); a_hasmp[r] = 1; matched = 1 } }
458 if matched == 0 { v = apl_kv(tok, "mark_adds" as *u8); if (v as i64) != 0 { a_adds[r] = apl_atoi(v); a_hasadds[r] = 1; matched = 1 } }
459 if matched == 0 { v = apl_kv(tok, "mark" as *u8); if (v as i64) != 0 { a_mark[r] = v as i64; matched = 1 } }
460 if matched == 0 { v = apl_kv(tok, "size" as *u8); if (v as i64) != 0 { a_size[r] = v as i64; matched = 1 } }
461 if matched == 0 { v = apl_kv(tok, "reply" as *u8); if (v as i64) != 0 { a_reply[r] = v as i64; matched = 1 } }
462 if matched == 0 { a_bad[r] = 1 }
463 }
464 if hit_end >= q { tsc = 0 } else { t = hit_end + 1 }
465 }
466 if a_path[r] == 0 { a_bad[r] = 1 }
467 if a_id[r] == 0 { a_id[r] = "(unnamed)" as i64 }
468 if a_hasmp[r] == 1 { if a_markpre[r] < 0 { a_bad[r] = 1 } }
469 if a_hasadds[r] == 1 { if a_adds[r] < 1 { a_bad[r] = 1 } }
470 nrows = nrows + 1
471 }
472 p = e + 1
473 }
474
475 if nrows == 0 {
476 ap_puts("AP-LANDED verdict=RED reason=writelist-has-no-rows path=" as *u8); ap_puts(listpath); ap_puts("\n" as *u8)
477 return APL_RC_LIST_UNREADABLE
478 }
479
480 var settle: i64 = apl_conf_settle_ms()
481 var settle_src: *u8 = "conf" as *u8
482 if settle < 0 { settle = APL_SETTLE_MS_DEFAULT; settle_src = "default-nx_memvel-window" as *u8 }
483
484 // ---- PASS A ----
485 let occp: *i64 = sys_mmap(16) as *i64
486 var r1: i64 = 0
487 while r1 < nrows {
488 if a_bad[r1] == 0 {
489 a_nA[r1] = apl_scan(a_path[r1] as *u8, apl_slot(hexA, r1, APL_HEXSLOT), a_mark[r1] as *u8, occp)
490 a_occA[r1] = occp[0]
491 }
492 r1 = r1 + 1
493 }
494 // ONE settle interval for the WHOLE worklist, not one per row.
495 sys_sleep_ms(settle)
496 // ---- PASS B ----
497 var r2: i64 = 0
498 while r2 < nrows {
499 if a_bad[r2] == 0 {
500 a_nB[r2] = apl_scan(a_path[r2] as *u8, apl_slot(hexB, r2, APL_HEXSLOT), a_mark[r2] as *u8, occp)
501 a_occB[r2] = occp[0]
502 }
503 r2 = r2 + 1
504 }
505
506 let rcount: *i64 = sys_mmap(APL_R_COUNT * APL_PTRW) as *i64
507 var n_landed: i64 = 0
508 var n_not: i64 = 0
509 var n_double: i64 = 0
510 var n_unknown: i64 = 0
511
512 var r: i64 = 0
513 while r < nrows {
514 var v: i64 = APL_V_UNKNOWN
515 var rs: i64 = APL_R_NONE
516 var ev: *u8 = "none" as *u8
517 var done: i64 = 0
518
519 if a_bad[r] == 1 { v = APL_V_UNKNOWN; rs = APL_R_MALFORMED; ev = "row-not-parsed" as *u8; done = 1 }
520
521 let hA: *u8 = apl_slot(hexA, r, APL_HEXSLOT)
522 let hB: *u8 = apl_slot(hexB, r, APL_HEXSLOT)
523 var absA: i64 = 0
524 var absB: i64 = 0
525 if done == 0 { if a_nA[r] < 0 { absA = 1 } }
526 if done == 0 { if a_nB[r] < 0 { absB = 1 } }
527
528 // TIER 0 -- SETTLED, or nothing below it may be trusted.
529 if done == 0 { if absA != absB { v = APL_V_UNKNOWN; rs = APL_R_UNSETTLED; ev = "two-reads-differ-on-presence" as *u8; done = 1 } }
530 if done == 0 { if absA == 0 { if ap_streq(hA, hB) == 0 { v = APL_V_UNKNOWN; rs = APL_R_UNSETTLED; ev = "two-reads-differ-on-content" as *u8; done = 1 } } }
531
532 var pre_absent: i64 = 0
533 if a_pre[r] != 0 { if ap_streq(a_pre[r] as *u8, "absent" as *u8) == 1 { pre_absent = 1 } }
534
535 if done == 0 { if absA == 1 {
536 if pre_absent == 1 { v = APL_V_NOT; ev = "absent-now-and-pre-absent" as *u8; done = 1 }
537 if done == 0 { v = APL_V_UNKNOWN; rs = APL_R_UNREADABLE; ev = "artifact-unreadable" as *u8; done = 1 }
538 } }
539
540 // TIER 1 -- CONTENT HASH. One of the only two sites that can emit LANDED.
541 if done == 0 { if a_want[r] != 0 {
542 let wh: *u8 = apl_dehex(a_want[r] as *u8)
543 var prematch: i64 = 0
544 if pre_absent == 0 { if a_pre[r] != 0 { if ap_streq(apl_dehex(a_pre[r] as *u8), wh) == 1 { prematch = 1 } } }
545 if prematch == 1 { v = APL_V_UNKNOWN; rs = APL_R_WANT_EQ_PRE; ev = "content-hash" as *u8; done = 1 }
546 if done == 0 { if ap_streq(hA, wh) == 1 { v = APL_V_LANDED; ev = "content-hash" as *u8; done = 1 } }
547 if done == 0 { if pre_absent == 0 { if a_pre[r] != 0 { if ap_streq(hA, apl_dehex(a_pre[r] as *u8)) == 1 { v = APL_V_NOT; ev = "content-hash-equals-pre-image" as *u8; done = 1 } } } }
548 if done == 0 { v = APL_V_UNKNOWN; rs = APL_R_THIRD_STATE; ev = "content-hash" as *u8; done = 1 }
549 } }
550
551 // TIER 2 -- MARKER COUNT. The other site that can emit LANDED, and the only one that can see a
552 // double-apply: a blind retry of a non-idempotent edit shows up here and nowhere else.
553 if done == 0 { if a_mark[r] != 0 {
554 if a_hasmp[r] == 0 { v = APL_V_UNKNOWN; rs = APL_R_MARK_NO_PRE; ev = "marker-count" as *u8; done = 1 }
555 if done == 0 { if a_occA[r] != a_occB[r] { v = APL_V_UNKNOWN; rs = APL_R_UNSETTLED; ev = "marker-count-differs-across-reads" as *u8; done = 1 } }
556 if done == 0 {
557 let mp: i64 = a_markpre[r]
558 let ad: i64 = a_adds[r]
559 let oc: i64 = a_occA[r]
560 if oc < mp { v = APL_V_UNKNOWN; rs = APL_R_MARK_FELL; ev = "marker-count" as *u8; done = 1 }
561 if done == 0 { if oc == mp { v = APL_V_NOT; ev = "marker-count" as *u8; done = 1 } }
562 if done == 0 { if oc == mp + ad { v = APL_V_LANDED; ev = "marker-count" as *u8; done = 1 } }
563 if done == 0 { if oc > mp + ad { v = APL_V_DOUBLE; ev = "marker-count" as *u8; done = 1 } }
564 if done == 0 { v = APL_V_UNKNOWN; rs = APL_R_MARK_PARTIAL; ev = "marker-count" as *u8; done = 1 }
565 }
566 } }
567
568 // TIER 3 -- PRE-IMAGE ONLY. Structurally incapable of returning LANDED: a file that CHANGED is
569 // not evidence that YOUR change is the one that landed.
570 if done == 0 { if a_pre[r] != 0 {
571 if pre_absent == 1 { v = APL_V_UNKNOWN; rs = APL_R_CHANGED_UNDECLARED; ev = "pre-image-only" as *u8; done = 1 }
572 if done == 0 { if ap_streq(hA, apl_dehex(a_pre[r] as *u8)) == 1 { v = APL_V_NOT; ev = "pre-image-only" as *u8; done = 1 } }
573 if done == 0 { v = APL_V_UNKNOWN; rs = APL_R_CHANGED_UNDECLARED; ev = "pre-image-only" as *u8; done = 1 }
574 } }
575
576 // TIER 4 -- NOTHING DECIDABLE. Named separately because the remedies differ: a size-only row
577 // needs a hash recorded next time, a reply-only row needs any expectation at all.
578 if done == 0 { if a_size[r] != 0 { v = APL_V_UNKNOWN; rs = APL_R_SIZE_ONLY; ev = "none" as *u8; done = 1 } }
579 if done == 0 { if a_reply[r] != 0 { v = APL_V_UNKNOWN; rs = APL_R_REPLY_ONLY; ev = "none" as *u8; done = 1 } }
580 if done == 0 { v = APL_V_UNKNOWN; rs = APL_R_NO_EXPECTATION; ev = "none" as *u8; done = 1 }
581
582 if v == APL_V_LANDED { n_landed = n_landed + 1 }
583 if v == APL_V_NOT { n_not = n_not + 1 }
584 if v == APL_V_DOUBLE { n_double = n_double + 1 }
585 if v == APL_V_UNKNOWN { n_unknown = n_unknown + 1; rcount[rs] = rcount[rs] + 1 }
586
587 ap_puts("APL row=" as *u8); ap_puts(a_id[r] as *u8)
588 ap_puts(" verdict=" as *u8); ap_puts(apl_verdict_name(v))
589 ap_puts(" evidence=" as *u8); ap_puts(ev)
590 if v == APL_V_UNKNOWN { ap_puts(" reason=" as *u8); ap_puts(apl_reason_name(rs)) }
591 ap_puts(" path=" as *u8)
592 if a_path[r] != 0 { ap_puts(a_path[r] as *u8) } else { ap_puts("(missing)" as *u8) }
593 ap_puts(" kind=" as *u8)
594 if a_kind[r] != 0 { ap_puts(a_kind[r] as *u8) } else { ap_puts("(undeclared)" as *u8) }
595 if a_bad[r] == 0 {
596 ap_puts(" now=" as *u8)
597 if absA == 1 { ap_puts("ABSENT" as *u8) } else { ap_puts(hA) }
598 ap_puts(" bytes=" as *u8); ap_num(a_nA[r])
599 ap_puts(" settled=" as *u8)
600 if rs == APL_R_UNSETTLED { ap_puts("0" as *u8) } else { ap_puts("1" as *u8) }
601 if a_mark[r] != 0 {
602 ap_puts(" occ_now=" as *u8); ap_num(a_occA[r])
603 ap_puts(" mark_pre=" as *u8)
604 if a_hasmp[r] == 1 { ap_num(a_markpre[r]) } else { ap_puts("UNDECLARED" as *u8) }
605 ap_puts(" mark_adds=" as *u8); ap_num(a_adds[r])
606 if a_hasadds[r] == 0 { ap_puts("(default)" as *u8) }
607 }
608 }
609 ap_puts("\n" as *u8)
610 ap_puts(" advice=" as *u8); ap_puts(apl_advice(a_kind[r] as *u8)); ap_puts("\n" as *u8)
611 if a_size[r] != 0 { ap_puts(" NON-EVIDENCE-ECHO size=" as *u8); ap_puts(a_size[r] as *u8); ap_puts(" -- NOT used: a size read taken right after a dropped write can report the PRE-write size\n" as *u8) }
612 if a_reply[r] != 0 { ap_puts(" NON-EVIDENCE-ECHO reply=" as *u8); ap_puts(a_reply[r] as *u8); ap_puts(" -- NOT used: the transport reply text discriminated nothing in either direction\n" as *u8) }
613 r = r + 1
614 }
615
616 let total: i64 = n_landed + n_not + n_double + n_unknown
617 ap_puts("APL-SETTLE settle_ms=" as *u8); ap_num(settle)
618 ap_puts(" src=" as *u8); ap_puts(settle_src)
619 ap_puts(" passes=2 (one interval for the whole worklist)\n" as *u8)
620 ap_puts("APL-PARTITION rows=" as *u8); ap_num(nrows)
621 ap_puts(" landed=" as *u8); ap_num(n_landed)
622 ap_puts(" not_landed=" as *u8); ap_num(n_not)
623 ap_puts(" double_applied=" as *u8); ap_num(n_double)
624 ap_puts(" unknown=" as *u8); ap_num(n_unknown)
625 ap_puts(" sum=" as *u8); ap_num(total)
626 if total == nrows { ap_puts(" partition=RECONCILES\n" as *u8) } else { ap_puts(" partition=LEAK\n" as *u8) }
627 var ri: i64 = 0
628 while ri < APL_R_COUNT {
629 if rcount[ri] > 0 {
630 ap_puts("APL-UNKNOWN-REASON " as *u8); ap_puts(apl_reason_name(ri))
631 ap_puts(" count=" as *u8); ap_num(rcount[ri]); ap_puts("\n" as *u8)
632 }
633 ri = ri + 1
634 }
635 if n_unknown > 0 {
636 ap_puts("AP-LANDED verdict=NEEDS-ADJUDICATION unknown=" as *u8); ap_num(n_unknown)
637 ap_puts(" -- abstained rather than acquitted; each UNKNOWN row names its reason above\n" as *u8)
638 return 1
639 }
640 ap_puts("AP-LANDED verdict=DECIDED-ALL rows=" as *u8); ap_num(nrows); ap_puts(" unknown=0\n" as *u8)
641 return 0
642}
643
644func main(argc: i64, argv: *i64) -> i64 {
645 // The `landed` verb is dispatched on argv[1] and the legacy three-positional publish form is
646 // untouched: a staged source literally named "landed" is the only collision, and it is not one.
647 if argc >= 2 { if ap_streq(argv[1] as *u8, "landed" as *u8) == 1 {
648 if argc < 3 {
649 ap_puts("usage: nx_atomic_publish landed <writelist>\n" as *u8)
650 ap_puts(" Adjudicates ambiguous writes. Rows are TAB-separated key=value:\n" as *u8)
651 ap_puts(" id= path= kind=publish|promote|create|replace|insert|append want=h<sha256> pre=h<sha256>|absent\n" as *u8)
652 ap_puts(" mark=<string> mark_pre=<n> mark_adds=<n> size=<n> reply=<text>\n" as *u8)
653 ap_puts(" size and reply are ECHOED AND IGNORED on purpose -- neither is evidence of landing.\n" as *u8)
654 ap_puts(" exit: 0 all decided | 1 some UNKNOWN | 2 usage | 3 list unreadable\n" as *u8)
655 sys_exit(2)
656 return 2
657 }
658 let rc: i64 = ap_landed(argv[2] as *u8)
659 sys_exit(rc)
660 return rc
661 } }
662 if argc < 4 {
663 ap_puts("usage: nx_atomic_publish <staged-src> <target> <expect>\n" as *u8)
664 ap_puts(" expect: any | absent | <64-hex sha256 of the target's CURRENT content>\n" as *u8)
665 ap_puts(" All-or-nothing: stages to <target>.aptmp then renameat over the target.\n" as *u8)
666 ap_puts(" On CAS mismatch it REFUSES and prints the live hash -- it never re-resolves its own\n" as *u8)
667 ap_puts(" expected value, because that would make a concurrent writer's edit a silent clobber.\n" as *u8)
668 ap_puts(" nx_atomic_publish landed <writelist> -- the READ half: adjudicate ambiguous writes.\n" as *u8)
669 sys_exit(2)
670 return 2
671 }
672 let src: *u8 = argv[1] as *u8
673 let dst: *u8 = argv[2] as *u8
674 let expect: *u8 = argv[3] as *u8
675
676 let sptr: *i64 = sys_mmap(16) as *i64
677 let shex: *u8 = sys_mmap(80)
678 let sn: i64 = ap_hash_file(src, sptr, shex)
679 if sn < 0 {
680 ap_puts("NX-ATOMIC-PUBLISH verdict=RED reason=source-unreadable path=" as *u8); ap_puts(src); ap_puts("\n" as *u8)
681 sys_exit(3)
682 return 3
683 }
684 // A ZERO-BYTE SOURCE IS ALMOST ALWAYS A FAILED UPLOAD, NOT AN INTENDED EMPTY FILE, and publishing it
685 // would truncate a real file to nothing. Refuse; `any` does not override this.
686 if sn == 0 {
687 ap_puts("NX-ATOMIC-PUBLISH verdict=RED reason=source-empty (refusing to publish 0 bytes over a live file)\n" as *u8)
688 sys_exit(3)
689 return 3
690 }
691 let sbuf: *u8 = sptr[0] as *u8
692
693 let dptr: *i64 = sys_mmap(16) as *i64
694 let dhex: *u8 = sys_mmap(80)
695 let dn: i64 = ap_hash_file(dst, dptr, dhex)
696
697 var ok: i64 = 0
698 if ap_streq(expect, "any" as *u8) == 1 { ok = 1 }
699 if ap_streq(expect, "absent" as *u8) == 1 { if dn < 0 { ok = 1 } }
700 if dn >= 0 { if ap_streq(expect, dhex) == 1 { ok = 1 } }
701 if dn < 0 { if ap_streq(expect, "absent" as *u8) == 0 { if ap_streq(expect, "any" as *u8) == 0 {
702 ap_puts("NX-ATOMIC-PUBLISH verdict=REFUSED reason=target-absent-but-expect-names-a-hash (use expect=absent for a create)\n" as *u8)
703 sys_exit(1)
704 return 1
705 } } }
706
707 if ok == 0 {
708 ap_puts("NX-ATOMIC-PUBLISH verdict=REFUSED reason=stale-expect -- the target changed under you; RE-READ AND MERGE, do not force.\n" as *u8)
709 ap_puts(" live_sha256=" as *u8); ap_puts(dhex); ap_puts("\n" as *u8)
710 ap_puts(" you_expected=" as *u8); ap_puts(expect); ap_puts("\n" as *u8)
711 ap_puts(" target UNCHANGED (" as *u8); ap_num(dn); ap_puts(" bytes)\n" as *u8)
712 sys_exit(1)
713 return 1
714 }
715
716 let tmp: *u8 = sys_mmap(AP_PATHCAP)
717 ap_cat2(tmp, dst, ".aptmp" as *u8)
718 let fd: i64 = sys_openat_wr(tmp, AP_MODE)
719 if fd < 0 {
720 ap_puts("NX-ATOMIC-PUBLISH verdict=RED reason=cannot-open-temp path=" as *u8); ap_puts(tmp); ap_puts("\n" as *u8)
721 sys_exit(3)
722 return 3
723 }
724 let w: i64 = sys_write(fd, sbuf, sn)
725 sys_close(fd)
726 if w != sn {
727 ap_puts("NX-ATOMIC-PUBLISH verdict=RED reason=short-write -- target UNTOUCHED, temp left for inspection at " as *u8)
728 ap_puts(tmp); ap_puts("\n" as *u8)
729 sys_exit(3)
730 return 3
731 }
732 if sys_renameat(tmp, dst) != 0 {
733 ap_puts("NX-ATOMIC-PUBLISH verdict=RED reason=rename-failed -- target UNTOUCHED, staged content at " as *u8)
734 ap_puts(tmp); ap_puts("\n" as *u8)
735 sys_exit(3)
736 return 3
737 }
738
739 // VERIFY BY ARTEFACT, NOT BY THE WRITE RETURNING. Re-read what is actually on disk and re-hash it.
740 let vptr: *i64 = sys_mmap(16) as *i64
741 let vhex: *u8 = sys_mmap(80)
742 let vn: i64 = ap_hash_file(dst, vptr, vhex)
743 if vn < 0 {
744 ap_puts("NX-ATOMIC-PUBLISH verdict=RED reason=published-but-unreadable\n" as *u8)
745 sys_exit(3)
746 return 3
747 }
748 if ap_streq(vhex, shex) == 0 {
749 ap_puts("NX-ATOMIC-PUBLISH verdict=RED reason=verify-mismatch published_sha256=" as *u8); ap_puts(vhex)
750 ap_puts(" expected=" as *u8); ap_puts(shex); ap_puts("\n" as *u8)
751 sys_exit(3)
752 return 3
753 }
754 ap_puts("NX-ATOMIC-PUBLISH verdict=GREEN published=" as *u8); ap_puts(dst)
755 ap_puts(" bytes=" as *u8); ap_num(vn)
756 ap_puts(" sha256=" as *u8); ap_puts(vhex)
757 if dn >= 0 { ap_puts(" replaced_sha256=" as *u8); ap_puts(dhex) }
758 if dn < 0 { ap_puts(" (created)" as *u8) }
759 ap_puts("\n" as *u8)
760 sys_exit(0)
761 return 0
762}