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) }