code wiki / (root) / nx_toolcall_idem_lib.nx

nx_toolcall_idem_lib.nx source

↩ module page · 231 lines · 11410 B

1// nx_toolcall_idem_lib.nx -- IDEMPOTENCY KEYS ON tools/call (/compare/dataio DI4 `ta_idempotency_key`, 2026-09-05). 2// 3// WHY: every seat's write to the estate crosses ONE transport, and that transport answers "Outcome Unknown" whenever 4// the backend accepted a request and produced no reply inside the edge window. MEASURED the day this landed: nine 5// such replies in one seat session, each adjudicated by hand by re-reading the artifact, and two verb shapes with NO 6// safe retry at all (a self-anchored insert applies twice; an append has no anchor to lose). The field's answer is the 7// IETF Idempotency-Key: the caller names the request, the server records the FIRST outcome under that name, and a 8// retry carrying the same name is REPLAYED, never re-executed. Done sovereign, on the lane every seat already uses: 9// params._idem = "<token>" (a sibling of _cap and _async; absent = the call behaves exactly as before) 10// THE OUTCOME IS RESERVED BEFORE IT IS EXECUTED. A key is claimed with O_EXCL on <dir>/idem_<digest>.claim -- the 11// same primitive the job lane reserves ids with -- so two racing calls under one key cannot both execute: the loser 12// reads IN-FLIGHT. The first APPLIED outcome is then recorded in the estate's append-only ledger 13// (nx_apistack_idempotency: first-write-wins, read whole) as ONE line: 14// lane=async|promote tool=<t> job=<id> the job id is the outcome; the caller polls it like any JOB-STARTED 15// lane=sync|http tool=<t> exit=<ec> bytes=<n> out=<dir>/idem_<digest>.out the bytes are kept so the replay is the same text 16// HONESTY LIMITS, stated: a claim whose holder died before recording (the daemon killed between reserve and record) 17// reads IN-FLIGHT for as long as the claim exists; the reply names the claim's age so the caller can adjudicate the 18// artifact -- today's situation, but confined to that one window instead of every call. A refused or harness-failed 19// run RELEASES its claim: nothing was applied, so a retry must be judged afresh (the same rule nx_fsops_write keeps). 20// Every path is parameterised by (ledger, dir) so the gate drives it on /tmp fixtures; the API passes the consts. 21// license_tier: ORIGINAL No hw writes (Rule 26). 22import "nx_syscalls.nx" 23import "nx_apistack_idempotency.nx" 24import "nx_sha256.nx" 25 26const TI_LEDGER: *u8 = "knowledge/status/toolcall_idem.jrnl" as *u8 27const TI_DIR: *u8 = "_jobs" as *u8 28const TI_KEY_MIN_CH: i64 = 33 // printable ASCII, no whitespace: a TAB or newline would corrupt the ledger row 29const TI_KEY_MAX_CH: i64 = 126 30const TI_KEY_MAX_LEN: i64 = 200 // a key is a token, not a payload; the ledger row must stay one line 31const TI_HEX_CH: i64 = 16 // 64 bits of the key's SHA-256 name the claim and the replay artifact 32const TI_HEX_BYTES: i64 = 8 33const TI_ROW_CAP: i64 = 1024 34const TI_PATH_CAP: i64 = 512 35const TI_DIGEST_B: i64 = 32 36const TI_SYS_OPENAT: i64 = 257 37const TI_SYS_UNLINKAT: i64 = 263 38const TI_AT_FDCWD: i64 = 0 - 100 39const TI_OEXCL: i64 = 193 // O_CREAT|O_EXCL|O_WRONLY 40const TI_CLAIMMODE: i64 = 420 41 42// the state of a keyed call, decided BEFORE any execution 43const TI_NOKEY: i64 = 0 // no _idem present: the call behaves exactly as before 44const TI_BADKEY: i64 = 1 // present but not a token: REFUSE before executing 45const TI_NEW: i64 = 2 // reserved by this call: execute, then record 46const TI_REPLAY: i64 = 3 // recorded: emit the first outcome, do NOT execute 47const TI_EVIDENCE_ERROR:i64=5 // retained ledger is unreadable, malformed, missing or result exceeds caller extent 48const TI_INFLIGHT: i64 = 4 // reserved by another call that has not recorded yet: do NOT execute, say so 49 50func ti_slen(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n } 51func ti_cat(d: *u8, o: i64, s: *u8) -> i64 { var i: i64 = 0; var oo: i64 = o; while s[i] != (0 as u8) { d[oo] = s[i]; oo = oo + 1; i = i + 1 } d[oo] = 0 as u8; return oo } 52func ti_catb(d: *u8, o: i64, s: *u8, n: i64) -> i64 { var i: i64 = 0; while i < n { d[o + i] = s[i]; i = i + 1 } d[o + n] = 0 as u8; return o + n } 53func ti_catn(d: *u8, o: i64, v: i64) -> i64 { 54 var m: i64 = v 55 var oo: i64 = o 56 if m < 0 { d[oo] = 45 as u8; oo = oo + 1; m = 0 - m } 57 let t: *u8 = sys_mmap(32) 58 var k: i64 = 0 59 if m == 0 { t[0] = 48 as u8; k = 1 } 60 while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 } 61 while k > 0 { k = k - 1; d[oo] = t[k]; oo = oo + 1 } 62 d[oo] = 0 as u8 63 return oo 64} 65func ti_hexd(v: i64) -> i64 { if v < 10 { return 48 + v } return 87 + v } 66func ti_is_digit(c: i64) -> i64 { if c >= 48 { if c <= 57 { return 1 } } return 0 } 67func ti_state_name(st: i64) -> *u8 { 68 if st == TI_NOKEY { return "NOKEY" as *u8 } 69 if st == TI_BADKEY { return "BADKEY" as *u8 } 70 if st == TI_NEW { return "NEW" as *u8 } 71 if st == TI_REPLAY { return "REPLAY" as *u8 } 72 if st == TI_EVIDENCE_ERROR { return "EVIDENCE_UNAVAILABLE" as *u8 } 73 return "INFLIGHT" as *u8 74} 75 76// a key is a token: 1..200 printable ASCII, no whitespace 77func ti_key_ok(key: *u8, n: i64) -> i64 { 78 if n <= 0 { return 0 } 79 if n > TI_KEY_MAX_LEN { return 0 } 80 var i: i64 = 0 81 while i < n { 82 let c: i64 = key[i] as i64 83 if c < TI_KEY_MIN_CH { return 0 } 84 if c > TI_KEY_MAX_CH { return 0 } 85 i = i + 1 86 } 87 return 1 88} 89// the first 64 bits of SHA-256(key) as 16 lowercase hex chars (NUL-terminated); deterministic, filename-safe 90func ti_key_hex(key: *u8, n: i64, out: *u8) -> i64 { 91 let dg: *u8 = sys_mmap(TI_DIGEST_B) 92 sha256_digest(key, n, dg) 93 var i: i64 = 0 94 while i < TI_HEX_BYTES { 95 let v: i64 = dg[i] as i64 96 out[i + i] = ti_hexd(v / 16) as u8 97 out[i + i + 1] = ti_hexd(v % 16) as u8 98 i = i + 1 99 } 100 out[TI_HEX_CH] = 0 as u8 101 return TI_HEX_CH 102} 103func ti_claim_path(dir: *u8, hex: *u8, out: *u8) -> i64 { var o: i64 = ti_cat(out, 0, dir); o = ti_cat(out, o, "/idem_" as *u8); o = ti_cat(out, o, hex); o = ti_cat(out, o, ".claim" as *u8); return o } 104func ti_out_path(dir: *u8, hex: *u8, out: *u8) -> i64 { var o: i64 = ti_cat(out, 0, dir); o = ti_cat(out, o, "/idem_" as *u8); o = ti_cat(out, o, hex); o = ti_cat(out, o, ".out" as *u8); return o } 105 106// RESERVE the key: O_EXCL create of the claim, the state written INSIDE the reservation (an empty claim is 107// impossible by construction). 1 = this call holds the key; 0 = someone else does. 108func ti_reserve(dir: *u8, hex: *u8, now: i64) -> i64 { 109 let p: *u8 = sys_mmap(TI_PATH_CAP) 110 ti_claim_path(dir, hex, p) 111 let fd: i64 = __syscall(TI_SYS_OPENAT, TI_AT_FDCWD, p as i64, TI_OEXCL, TI_CLAIMMODE, 0, 0) 112 if fd < 0 { return 0 } 113 let cb: *u8 = sys_mmap(64) 114 var o: i64 = ti_cat(cb, 0, "state=CLAIMED ts=" as *u8) 115 o = ti_catn(cb, o, now) 116 cb[o] = 10 as u8 117 o = o + 1 118 sys_write(fd, cb, o) 119 sys_close(fd) 120 return 1 121} 122// RELEASE the key: nothing was applied, so a retry must execute afresh. 1 = removed, 0 = nothing to remove. 123func ti_release(dir: *u8, hex: *u8) -> i64 { 124 let p: *u8 = sys_mmap(TI_PATH_CAP) 125 ti_claim_path(dir, hex, p) 126 let r: i64 = __syscall(TI_SYS_UNLINKAT, TI_AT_FDCWD, p as i64, 0, 0, 0, 0) 127 if r < 0 { return 0 } 128 return 1 129} 130// the decimal after `needle` in a NUL-terminated row, or -1 131func ti_row_int(row: *u8, needle: *u8) -> i64 { 132 let n: i64 = ti_slen(row) 133 let nl: i64 = ti_slen(needle) 134 var i: i64 = 0 135 var at: i64 = 0 - 1 136 var go: i64 = 1 137 while go == 1 { 138 if i + nl > n { go = 0 } else { 139 var j: i64 = 0 140 var ok: i64 = 1 141 while j < nl { if row[i + j] != needle[j] { ok = 0; j = nl } else { j = j + 1 } } 142 if ok == 1 { at = i + nl; go = 0 } else { i = i + 1 } 143 } 144 } 145 if at < 0 { return 0 - 1 } 146 var v: i64 = 0 147 var nd: i64 = 0 148 var p: i64 = at 149 var neg: i64 = 0 150 if p < n { if row[p] == (45 as u8) { neg = 1; p = p + 1 } } 151 var d: i64 = 1 152 while d == 1 { if p >= n { d = 0 } else { let c: i64 = row[p] as i64; if ti_is_digit(c) == 1 { v = v * 10 + (c - 48); nd = nd + 1; p = p + 1 } else { d = 0 } } } 153 if nd == 0 { return 0 - 1 } 154 if neg == 1 { return 0 - v } 155 return v 156} 157// substring presence in a NUL-terminated row 158func ti_row_has(row: *u8, needle: *u8) -> i64 { 159 let n: i64 = ti_slen(row) 160 let nl: i64 = ti_slen(needle) 161 if nl == 0 { return 0 } 162 var i: i64 = 0 163 while i + nl <= n { 164 var j: i64 = 0 165 var ok: i64 = 1 166 while j < nl { if row[i + j] != needle[j] { ok = 0; j = nl } else { j = j + 1 } } 167 if ok == 1 { return 1 } 168 i = i + 1 169 } 170 return 0 171} 172// age of the claim in seconds (from its ts= field), or -1 when it cannot be read 173func ti_claim_age(dir: *u8, hex: *u8, now: i64) -> i64 { 174 let p: *u8 = sys_mmap(TI_PATH_CAP) 175 ti_claim_path(dir, hex, p) 176 let lb: *i64 = sys_mmap(16) as *i64 177 let cb: *u8 = sys_read_file(p, lb) 178 if (cb as i64) == 0 { return 0 - 1 } 179 let z: *u8 = sys_mmap(lb[0] + 1) 180 ti_catb(z, 0, cb, lb[0]) 181 let ts: i64 = ti_row_int(z, "ts=" as *u8) 182 if ts < 0 { return 0 - 1 } 183 return now - ts 184} 185 186// THE DECISION, taken before any execution. key==0 -> NOKEY (absent). hex_out receives the digest name, row_out 187// the recorded outcome on REPLAY (NUL-terminated, empty otherwise). 188func ti_classify(ledger: *u8, dir: *u8, key: *u8, n: i64, hex_out: *u8, row_out: *u8, rowcap: i64, now: i64) -> i64 { 189 if rowcap<=0||(hex_out as i64)<=0||(row_out as i64)<=0{return TI_EVIDENCE_ERROR} 190 hex_out[0] = 0 as u8 191 row_out[0] = 0 as u8 192 if (key as i64) == 0 { return TI_NOKEY } 193 if ti_key_ok(key, n) == 0 { return TI_BADKEY } 194 ti_key_hex(key, n, hex_out) 195 let first:i64=id_lookup(ledger,key,n,row_out,rowcap) 196 if first>=0{return TI_REPLAY} 197 if first!=ID_NOT_FOUND{return TI_EVIDENCE_ERROR} 198 if ti_reserve(dir, hex_out, now) == 1 { return TI_NEW } 199 // reserved by someone else: it may have recorded between our lookup and our reserve -- ask the ledger again 200 let second:i64=id_lookup(ledger,key,n,row_out,rowcap) 201 if second>=0{return TI_REPLAY} 202 if second!=ID_NOT_FOUND{return TI_EVIDENCE_ERROR} 203 return TI_INFLIGHT 204} 205// the outcome rows (ONE line, no tabs: the ledger's grammar) 206func ti_row_job(row: *u8, cap: i64, lane: *u8, tool: *u8, tl: i64, jid: i64) -> i64 { 207 var o: i64 = ti_cat(row, 0, "lane=" as *u8) 208 o = ti_cat(row, o, lane) 209 o = ti_cat(row, o, " tool=" as *u8) 210 if tl + o + 64 < cap { o = ti_catb(row, o, tool, tl) } 211 o = ti_cat(row, o, " job=" as *u8) 212 o = ti_catn(row, o, jid) 213 return o 214} 215func ti_row_sync(row: *u8, cap: i64, lane: *u8, tool: *u8, tl: i64, ec: i64, bytes: i64, dir: *u8, hex: *u8) -> i64 { 216 var o: i64 = ti_cat(row, 0, "lane=" as *u8) 217 o = ti_cat(row, o, lane) 218 o = ti_cat(row, o, " tool=" as *u8) 219 if tl + o + 128 < cap { o = ti_catb(row, o, tool, tl) } 220 o = ti_cat(row, o, " exit=" as *u8) 221 o = ti_catn(row, o, ec) 222 o = ti_cat(row, o, " bytes=" as *u8) 223 o = ti_catn(row, o, bytes) 224 o = ti_cat(row, o, " out=" as *u8) 225 let p: *u8 = sys_mmap(TI_PATH_CAP) 226 ti_out_path(dir, hex, p) 227 o = ti_cat(row, o, p) 228 return o 229} 230// RECORD the first applied outcome (first-write-wins in the ledger; a later record for the same key is inert) 231func ti_record(ledger: *u8, key: *u8, n: i64, row: *u8, rl: i64) -> i64 { return id_record(ledger, key, n, row, rl) }