code wiki / _hdl_build / nx_atomic_publish.nx
nx_atomic_publish.nx
buildroot/runtime/_hdl_build/nx_atomic_publish.nx
about
nx_atomic_publish.nx -- ALL-OR-NOTHING FILE PUBLISH WITH COMPARE-AND-SWAP, IN NISHILANG.
WHY IT EXISTS. A multi-part source change applied as N sequential anchored edits is N chances to
half-land. Measured twice on 2026-08-07: the MCP daemon wedged mid-sequence, edit 1 landed and edits
2-3 did not, leaving a live source with an UNCLOSED BRACE -- a broken file that blocks every build of
that target, found only because the artefact was verified rather than the response believed.
★★★★★★A MULTI-PART EDIT OVER A FLAKY TRANSPORT IS AN ATOMICITY PROBLEM, NOT A RETRY PROBLEM:
retrying makes a half-applied file MORE likely, not less. Stage the whole file, publish once.
WHY IT IS AN ORGAN AND NOT A SHELL HELPER. The first version of this was a .ps1, and it JUDGED:
it parsed a refusal to recover a token, classified transient-vs-deterministic, and decided
refuse-vs-retry. That is judgement, and judgement belongs in NishiLang -- shell is the wire and
nothing else. Here the only laptop-side act left is moving bytes to a scratch path; every decision
happens on this side.
usage: nx_atomic_publish <staged-src> <target> <expect>
expect = "any" -- publish regardless (NAME IT OUT LOUD when you use it)
| "absent" -- publish only if the target does not exist yet
| <64-hex sha256> -- publish only if the target's CURRENT content hashes to exactly this
nx_atomic_publish landed <writelist> -- the READ half: adjudicate ambiguous writes (ap_landed)
CAS SEMANTICS, and the direction of failure is the point: on mismatch it REFUSES and PRINTS the live
hash, so the caller can re-read, merge, and retry with the right token. It never re-resolves the token
itself -- that would turn a concurrent writer's edit into a silent clobber, which is the exact defect
compare-and-swap exists to prevent.
★A CAS THAT RESOLVES ITS OWN EXPECTED VALUE IS NOT A CAS, IT IS AN OVERWRITE WITH EXTRA STEPS.
ATOMIC BY renameat, NOT BY WRITE ORDER: content goes to <target>.aptmp then renames over the target,
so a reader never sees a half-written file and a crash mid-publish leaves the original intact.
VERIFY AFTER: the published bytes are re-read and re-hashed before GREEN is printed.
license_tier: ORIGINAL expect_exit: 0
dependencies 2 imports · 0 importers
imports: nx_syscalls.nxnx_sha256.nx
imported by: nobody (leaf or entry point)
call flow from main pre-order; caps 40 nodes / depth 6 declared; ↻ = already shown
structs
| none |
consts
| 35 | const AP_STDOUT: i64 = 1 |
| 36 | const AP_PATHCAP: i64 = 1024 |
| 37 | const AP_MODE: i64 = 420 // 0644 |
| 170 | const APL_TAB: i64 = 9 |
| 171 | const APL_NL: i64 = 10 |
| 172 | const APL_CR: i64 = 13 |
| 173 | const APL_EQ: i64 = 61 |
| 174 | const APL_HCHAR: i64 = 104 // the 'h' prefix of an h<hex> compare-and-swap token |
| 175 | const APL_SLASH: i64 = 47 // '/' -- a doubled slash starts a comment row in the grammar |
| 176 | const APL_D0: i64 = 48 // ASCII '0' -- the base of decimal parsing, structural |
| 177 | const APL_D9: i64 = 57 |
| 178 | const APL_DEC_BASE: i64 = 10 // every count in the row grammar is written in decimal |
| 179 | const APL_NUL_SLACK: i64 = 2 // room to NUL-terminate the working copy AND its final line |
| 180 | const APL_RC_LIST_UNREADABLE: i64 = 3 // the writelist itself could not be read: not a verdict about any row |
| 181 | const APL_HEXSLOT: i64 = 80 // 64 hex + NUL, rounded up to a comfortable per-row slot |
| 182 | const APL_PTRW: i64 = 8 // one i64 per parallel-array element |
| 183 | const APL_DEFAULT_MARK_ADDS: i64 = 1 // one anchored apply introduces its marker once; stated, not assumed |
| 187 | const APL_SETTLE_MS_DEFAULT: i64 = 1200 |
| 188 | const APL_CONF: *u8 = "knowledge/status/atomic_publish.conf" |
| 190 | const APL_V_LANDED: i64 = 0 |
| 191 | const APL_V_NOT: i64 = 1 |
| 192 | const APL_V_DOUBLE: i64 = 2 |
| 193 | const APL_V_UNKNOWN: i64 = 3 |
| 196 | const APL_R_NONE: i64 = 0 |
| 197 | const APL_R_UNSETTLED: i64 = 1 |
| 198 | const APL_R_UNREADABLE: i64 = 2 |
| 199 | const APL_R_WANT_EQ_PRE: i64 = 3 |
| 200 | const APL_R_THIRD_STATE: i64 = 4 |
| 201 | const APL_R_MARK_FELL: i64 = 5 |
| 202 | const APL_R_MARK_PARTIAL: i64 = 6 |
| 203 | const APL_R_CHANGED_UNDECLARED: i64 = 7 |
| 204 | const APL_R_SIZE_ONLY: i64 = 8 |
| 205 | const APL_R_REPLY_ONLY: i64 = 9 |
| 206 | const APL_R_NO_EXPECTATION: i64 = 10 |
| 207 | const APL_R_MALFORMED: i64 = 11 |
| 208 | const APL_R_MARK_NO_PRE: i64 = 12 |
| 209 | const APL_R_COUNT: i64 = 13 // one past the highest reason code; the counter table size |
functions
| 39 | func 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 } |
| 41 | func ap_len(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n } |
| 43 | func ap_num(v: i64) -> i64 |
| 58 | func ap_hex(dig: *u8, out: *u8) -> i64 |
| 71 | func ap_streq(a: *u8, b: *u8) -> i64 |
| 78 | func ap_cat2(out: *u8, a: *u8, b: *u8) -> i64 called by 1: main |
| 95 | func ap_hash_file(path: *u8, outp: *i64, hex: *u8) -> i64 |
| 211 | func apl_reason_name(r: i64) -> *u8 called by 1: ap_landed |
| 235 | func apl_advice(kind: *u8) -> *u8 |
| 248 | func apl_verdict_name(v: i64) -> *u8 called by 1: ap_landed |
| 255 | func apl_slot(base: *u8, i: i64, w: i64) -> *u8 { return ((base as i64) + i * w) as *u8 } called by 1: ap_landed |
| 259 | func apl_kv(tok: *u8, key: *u8) -> *u8 called by 1: ap_landed |
| 268 | func apl_dehex(s: *u8) -> *u8 called by 1: ap_landed |
| 273 | func apl_atoi(s: *u8) -> i64 called by 1: ap_landed |
| 289 | func apl_count_occ(buf: *u8, n: i64, needle: *u8) -> i64 |
| 310 | func apl_scan(path: *u8, hexout: *u8, needle: *u8, occ_out: *i64) -> i64 |
| 324 | func apl_conf_settle_ms() -> i64 |
| 371 | func ap_landed(listpath: *u8) -> i64 |
| 644 | func main(argc: i64, argv: *i64) -> i64 |