nx_wiki_version.nx source
↩ module page · 458 lines · 19025 B
1// nx_wiki_version.nx -- wiki R2: page VERSIONING (history / diff / rollback).
2//
3// Builds the version-control surface ON TOP of the R1 save backbone
4// (wiki/nx_wiki_page_save). R1 already writes, on EVERY authed save, two rows
5// into the content-addressed append-only seg_store under the
6// "knowledge/store/wikipage-" prefix:
7// wikicur:<slug> = the current markdown bytes
8// wikirev:<slug>:<epoch> = "author=<a>\nepoch=<e>\n" + the content
9// The immutable wikirev:* rows ARE the history substrate this module walks. No
10// new persistence model is invented: history is read from those rows, diff is
11// computed over two of them, and rollback REUSES the R1 save path so it lands as
12// a NEW revision (additive-data law / cardinal 13 -- a restore never deletes or
13// mutates a prior revision; it appends one equal to the old content).
14//
15// ENUMERATION (schema-first, verified against nx_seg_store.nx):
16// The seg_store exposes ss_get / ss_scan / ss_hget for ONE EXACT key, but no
17// prefix-key enumerator. The on-disk .docs entry format is public and stable
18// (kind:u8 | klen:u32be | key | vlen:u32be | val -- see ss_scan_seglist), and
19// ss_manifest + ss_readall + ss_r32 are public top-level helpers. So this
20// module walks the live manifest's segments and collects every entry whose
21// key begins with "wikirev:<slug>:", parsing the epoch suffix for ordering.
22// Each revision key is written exactly ONCE (additive), so it appears once.
23//
24// DIFF: no nx_diff organ exists in the wiki tree, so a small, correct
25// line-level diff lives here: hash each line (FNV-1a), compute the LCS over
26// the line-hash sequences (classic O(a*b) DP), then emit unified-ish
27// "- removed" / "+ added" lines for the non-matching lines. Identical
28// revisions => empty diff.
29//
30// COMPOSES (avoid-duplicate-primitives cardinal; each imported ONCE):
31// hub/nx_modern_auth_flow nx_modern_auth_validate_session (rollback gate)
32// wiki/nx_wiki_page_save nx_wiki_page_save (R1 save = additive rollback)
33// + nx_wps_prefix / nx_wps_rev_key (key format)
34// nx_seg_store ss_manifest / ss_readall / ss_r32 (enumerate)
35// + ss_open / ss_hget (read one revision)
36//
37// COMPOSED BY:
38// runtime/_hdl_build/_wiki_version_gate unit gate (history/diff/rollback +
39// additive proof + unauth liar-kill)
40//
41// Status: R2. 2026-06-14. license_tier: ORIGINAL
42
43import "nx_syscalls.nx"
44import "hub/nx_modern_auth_flow.nx"
45import "wiki/nx_wiki_page_save.nx"
46import "nx_seg_store.nx"
47
48// ===== Sealed verdict surface (codes 2920-2939) =================================================
49const NX_WV_OK: i64 = 0
50const NX_WV_REFUSED: i64 = 2920 // rollback auth gate refused
51const NX_WV_BAD_INPUT: i64 = 2921
52const NX_WV_NOT_FOUND: i64 = 2922 // target revision absent
53const NX_WV_OVERFLOW: i64 = 2923
54const NX_WV_SAVE_FAILED: i64 = 2924 // R1 save path returned an error
55
56// ===== Named constants (M7 / no magic numbers) =============================
57const NX_WV_MAX_REVS: i64 = 4096 // versions surfaced per slug per call
58const NX_WV_KEY_CAP: i64 = 320 // "wikirev:" + slug + ":" + epoch + NUL
59const NX_WV_SEG_CAP: i64 = 4096 // live-manifest segment slots scanned
60
61// ===== small local helpers (counted, NUL-free internals) ===================
62
63// counted-prefix test: does key[0..klen) start with pfx[0..pn)?
64func _wv_has_prefix(key: *u8, klen: i64, pfx: *u8, pn: i64) -> i64 {
65 if klen < pn { return 0 }
66 var i: i64 = 0
67 while i < pn { if key[i] != pfx[i] { return 0 } i = i + 1 }
68 return 1
69}
70
71// parse a non-negative decimal from b[off..end); returns the value (>=0) or -1
72// if any byte is a non-digit (used to read the <epoch> suffix of a rev key).
73func _wv_parse_dec(b: *u8, off: i64, end: i64) -> i64 {
74 if off >= end { return 0 - 1 }
75 var v: i64 = 0
76 var i: i64 = off
77 while i < end {
78 let c: i64 = b[i]
79 if c < 48 { return 0 - 1 }
80 if c > 57 { return 0 - 1 }
81 v = v * 10 + (c - 48)
82 i = i + 1
83 }
84 return v
85}
86
87// strip the "author=..\nepoch=..\n" header off a wikirev value, copying the
88// content tail into out (cap-bounded). Returns content length, or -1 on
89// overflow / malformed (no header => treat the whole value as content).
90// The header ends at the SECOND newline (after "epoch=<e>"); R1 always writes
91// exactly two header lines, so the content begins right after the 2nd '\n'.
92func _wv_strip_header(val: *u8, vn: i64, out: *u8, cap: i64) -> i64 {
93 var nl: i64 = 0
94 var start: i64 = 0
95 var i: i64 = 0
96 while i < vn {
97 if val[i] == (10 as u8) {
98 nl = nl + 1
99 if nl == 2 { start = i + 1; i = vn }
100 }
101 i = i + 1
102 }
103 // no/short header -> emit whole value (defensive; cardinal 12 at boundary)
104 if nl < 2 { start = 0 }
105 let n: i64 = vn - start
106 if n < 0 { return 0 - 1 }
107 if n > cap { return 0 - 1 }
108 var j: i64 = 0
109 while j < n { out[j] = val[start + j]; j = j + 1 }
110 return n
111}
112
113// ===== REVISION ENUMERATION (the history scan) =============================
114//
115// Walk the live manifest's segments, reading each seg-<id>.docs and collecting
116// every entry whose key starts with "wikirev:<slug>:". For each hit we record
117// the epoch (parsed from the key suffix) and the value's content length. The
118// keys arrive in segment (chronological) order; we sort by epoch ascending so
119// the caller always sees oldest -> newest regardless of compaction.
120//
121// epochs[i] / clens[i] are filled for i in [0, count). Returns the count
122// (capped at NX_WV_MAX_REVS; a fuller history is the compaction/paging rung).
123// Pure read -- mutates nothing in the store.
124func nx_wiki_revscan(slug: *u8, slug_n: i64,
125 epochs: *i64, clens: *i64, max: i64) -> i64 {
126 if (slug as i64) == 0 { return 0 }
127 if slug_n < 1 { return 0 }
128
129 // build the per-slug rev key PREFIX "wikirev:<slug>:" once. Use an INLINE
130 // string literal copied through ss_cat (the exact idiom nx_wps_rev_key uses)
131 // -- indexing a `const *u8` byte-by-byte does NOT yield the literal bytes.
132 let pfx: *u8 = sys_mmap(NX_WV_KEY_CAP)
133 var po: i64 = 0
134 po = ss_cat(pfx, po, "wikirev:" as *u8)
135 var si: i64 = 0
136 while si < slug_n { pfx[po + si] = slug[si]; si = si + 1 }
137 po = po + slug_n
138 pfx[po] = 58 as u8 // ':'
139 po = po + 1
140 let pfx_n: i64 = po // length of "wikirev:<slug>:"
141
142 // list the live segments under the wikipage prefix
143 let segs: *i64 = sys_mmap(8 * NX_WV_SEG_CAP) as *i64
144 let ns: i64 = ss_manifest_cap(nx_wps_prefix(), segs, NX_WV_SEG_CAP)
145
146 var cnt: i64 = 0
147 var s: i64 = 0
148 while s < ns {
149 // path = "<prefix><seg-name>.docs"
150 let path: *u8 = sys_mmap(512)
151 var o: i64 = 0
152 o = ss_cat(path, o, nx_wps_prefix())
153 o = ss_cat(path, o, segs[s] as *u8)
154 o = ss_cat(path, o, ".docs" as *u8)
155 path[o] = 0 as u8
156 let szp: *i64 = sys_mmap(16) as *i64
157 let b: *u8 = ss_readall(path, szp)
158 let sz: i64 = szp[0]
159 if sz > 0 {
160 var i: i64 = 0
161 while i + 9 <= sz {
162 let kind: i64 = b[i]
163 let kl: i64 = ss_r32(b, i + 1)
164 let koff: i64 = i + 5
165 let vl: i64 = ss_r32(b, koff + kl)
166 let voff: i64 = koff + kl + 4
167 // only PUT entries (kind 1) whose key carries our rev prefix.
168 // R1 never tombstones a wikirev, so this is exact.
169 if kind == 1 {
170 if _wv_has_prefix((b as i64 + koff) as *u8, kl, pfx, pfx_n) == 1 {
171 let ep: i64 = _wv_parse_dec(b, koff + pfx_n, koff + kl)
172 if ep >= 0 {
173 if cnt < max {
174 epochs[cnt] = ep
175 clens[cnt] = vl // value len incl. header
176 cnt = cnt + 1
177 }
178 }
179 }
180 }
181 i = voff + vl
182 }
183 }
184 s = s + 1
185 }
186
187 // sort (epochs, clens) ascending by epoch (insertion sort; rev counts are
188 // tiny per page). Stable enough -- epochs are unique per save.
189 var a: i64 = 1
190 while a < cnt {
191 let ke: i64 = epochs[a]
192 let kc: i64 = clens[a]
193 var b2: i64 = a - 1
194 var go: i64 = 1
195 while go == 1 {
196 if b2 < 0 { go = 0 } else {
197 if epochs[b2] > ke {
198 epochs[b2 + 1] = epochs[b2]
199 clens[b2 + 1] = clens[b2]
200 b2 = b2 - 1
201 } else { go = 0 }
202 }
203 }
204 epochs[b2 + 1] = ke
205 clens[b2 + 1] = kc
206 a = a + 1
207 }
208 return cnt
209}
210
211// ===== 1. HISTORY ==========================================================
212//
213// nx_wiki_history(slug): enumerate the revisions of a slug in epoch order and
214// write them into the caller's epochs[]/clens[] buffers (content-length per
215// revision; the cid surrogate -- a richer cid is the seg_store record id). Also
216// PRINTS one "WIKIHIST" line with the count to stdout. Returns the count.
217func nx_wiki_history(slug: *u8, slug_n: i64,
218 epochs: *i64, clens: *i64, max: i64) -> i64 {
219 let n: i64 = nx_wiki_revscan(slug, slug_n, epochs, clens, max)
220 // observable line (WHAT happened + entity + count; cardinal 18)
221 sys_write(1, "WIKIHIST slug=" as *u8, 13)
222 sys_write(1, slug, slug_n)
223 sys_write(1, " revs=" as *u8, 6)
224 let nb: *u8 = sys_mmap(28)
225 var m: i64 = n
226 let t: *u8 = sys_mmap(28); var k: i64 = 0
227 if m == 0 { t[0] = 48 as u8; k = 1 }
228 while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 }
229 var x: i64 = 0
230 while x < k { nb[x] = t[k - 1 - x]; x = x + 1 }
231 sys_write(1, nb, k)
232 sys_write(1, "\n" as *u8, 1)
233 return n
234}
235
236// ===== 2. GET ONE REVISION'S CONTENT =======================================
237//
238// nx_wiki_rev_get(slug, epoch): read wikirev:<slug>:<epoch> via the seg_store
239// handle, strip the "author=..\nepoch=..\n" header, copy the content into out.
240// Returns the content byte length (>=0), or -1 if the revision is absent /
241// overflows the caller's cap. Pure read.
242func nx_wiki_rev_get(slug: *u8, slug_n: i64, epoch: i64,
243 out: *u8, cap: i64) -> i64 {
244 if (slug as i64) == 0 { return 0 - 1 }
245 if slug_n < 1 { return 0 - 1 }
246 let revk: *u8 = sys_mmap(NX_WV_KEY_CAP)
247 nx_wps_rev_key(slug, slug_n, epoch, revk)
248 let h: *i64 = ss_open(nx_wps_prefix())
249 if (h as i64) == 0 { return 0 - 1 }
250 let pp: *i64 = sys_mmap(16) as *i64
251 let pl: *i64 = sys_mmap(16) as *i64
252 if ss_hget(h, revk, pp, pl) != 1 { return 0 - 1 }
253 return _wv_strip_header(pp[0] as *u8, pl[0], out, cap)
254}
255
256// ===== line-level diff primitives ==========================================
257
258// FNV-1a over a line b[off..off+n)
259func _wv_line_hash(b: *u8, off: i64, n: i64) -> i64 {
260 var h: i64 = 1469598103934665603
261 var i: i64 = 0
262 while i < n {
263 h = h ^ (b[off + i] as i64)
264 h = h * 1099511628211
265 i = i + 1
266 }
267 return h
268}
269
270// split buf[0..n) into lines: starts[i]/lens[i] (line WITHOUT the trailing
271// '\n'); hashes[i] = FNV of the line. A trailing partial line (no final '\n')
272// counts as a line. Returns the line count (capped at `cap`).
273func _wv_split_lines(buf: *u8, n: i64,
274 starts: *i64, lens: *i64, hashes: *i64, cap: i64) -> i64 {
275 var cnt: i64 = 0
276 var ls: i64 = 0
277 var i: i64 = 0
278 while i < n {
279 if buf[i] == (10 as u8) {
280 if cnt < cap {
281 starts[cnt] = ls
282 lens[cnt] = i - ls
283 hashes[cnt] = _wv_line_hash(buf, ls, i - ls)
284 cnt = cnt + 1
285 }
286 ls = i + 1
287 }
288 i = i + 1
289 }
290 if ls < n {
291 if cnt < cap {
292 starts[cnt] = ls
293 lens[cnt] = n - ls
294 hashes[cnt] = _wv_line_hash(buf, ls, n - ls)
295 cnt = cnt + 1
296 }
297 }
298 return cnt
299}
300
301// emit one diff line: prefix char (45 '-' / 43 '+') then the source line bytes
302// then '\n', into out at off. Returns new off (cap-bounded; truncates a line
303// that would overflow rather than smash memory).
304func _wv_emit(out: *u8, off: i64, cap: i64, pfx: i64,
305 src: *u8, lstart: i64, llen: i64) -> i64 {
306 var o: i64 = off
307 if o + 1 > cap { return o }
308 out[o] = pfx as u8
309 o = o + 1
310 var j: i64 = 0
311 while j < llen {
312 if o >= cap { return o }
313 out[o] = src[lstart + j]
314 o = o + 1
315 j = j + 1
316 }
317 if o < cap { out[o] = 10 as u8; o = o + 1 }
318 return o
319}
320
321// ===== 3. DIFF =============================================================
322//
323// nx_wiki_diff(slug, epoch_a, epoch_b): line-level diff of revision A vs B.
324// Reads both revisions' content, splits into lines, computes the LCS over the
325// line-hash sequences (classic DP), then walks the two sequences emitting:
326// "- <line>" for lines present in A but not on the LCS (removed)
327// "+ <line>" for lines present in B but not on the LCS (added)
328// Common lines (on the LCS) are not emitted (unified-ish, context-free). Two
329// IDENTICAL revisions => zero emitted bytes (empty diff). Returns the diff
330// byte length written into out (>=0), or -1 if a revision is missing.
331//
332// The "- " / "+ " markers carry a SPACE after the sign for readability, so an
333// emitted line is "-" + " removed" = "- removed".
334func nx_wiki_diff(slug: *u8, slug_n: i64, epoch_a: i64, epoch_b: i64,
335 out: *u8, cap: i64) -> i64 {
336 // bounded scratch for both revisions' content
337 let abuf: *u8 = sys_mmap(NX_WPS_MAX_CONTENT_LEN + 16)
338 let bbuf: *u8 = sys_mmap(NX_WPS_MAX_CONTENT_LEN + 16)
339 let an: i64 = nx_wiki_rev_get(slug, slug_n, epoch_a, abuf, NX_WPS_MAX_CONTENT_LEN)
340 if an < 0 { return 0 - 1 }
341 let bn: i64 = nx_wiki_rev_get(slug, slug_n, epoch_b, bbuf, NX_WPS_MAX_CONTENT_LEN)
342 if bn < 0 { return 0 - 1 }
343
344 let maxl: i64 = 4096 // lines per side (data-driven cap)
345 let as_: *i64 = sys_mmap(8 * maxl) as *i64
346 let al_: *i64 = sys_mmap(8 * maxl) as *i64
347 let ah_: *i64 = sys_mmap(8 * maxl) as *i64
348 let bs_: *i64 = sys_mmap(8 * maxl) as *i64
349 let bl_: *i64 = sys_mmap(8 * maxl) as *i64
350 let bh_: *i64 = sys_mmap(8 * maxl) as *i64
351 let na: i64 = _wv_split_lines(abuf, an, as_, al_, ah_, maxl)
352 let nb: i64 = _wv_split_lines(bbuf, bn, bs_, bl_, bh_, maxl)
353
354 // LCS DP over line hashes. dp[(i)*(nb+1)+j] = LCS length of A[i..] , B[j..].
355 let w: i64 = nb + 1
356 let dp: *i64 = sys_mmap(8 * (na + 1) * (nb + 1)) as *i64
357 var i: i64 = na - 1
358 while i >= 0 {
359 var j: i64 = nb - 1
360 while j >= 0 {
361 var best: i64 = 0
362 if ah_[i] == bh_[j] {
363 best = dp[(i + 1) * w + (j + 1)] + 1
364 } else {
365 let d1: i64 = dp[(i + 1) * w + j]
366 let d2: i64 = dp[i * w + (j + 1)]
367 if d1 >= d2 { best = d1 } else { best = d2 }
368 }
369 dp[i * w + j] = best
370 j = j - 1
371 }
372 i = i - 1
373 }
374
375 // walk the DP diagonally, emitting removed/added lines off the LCS path
376 var o: i64 = 0
377 var ia: i64 = 0
378 var jb: i64 = 0
379 while ia < na {
380 if jb < nb {
381 if ah_[ia] == bh_[jb] {
382 // common line -> on the LCS, skip both (context-free)
383 ia = ia + 1
384 jb = jb + 1
385 } else {
386 if dp[(ia + 1) * w + jb] >= dp[ia * w + (jb + 1)] {
387 o = _wv_emit(out, o, cap, 45, abuf, as_[ia], al_[ia]) // '-'
388 ia = ia + 1
389 } else {
390 o = _wv_emit(out, o, cap, 43, bbuf, bs_[jb], bl_[jb]) // '+'
391 jb = jb + 1
392 }
393 }
394 } else {
395 o = _wv_emit(out, o, cap, 45, abuf, as_[ia], al_[ia]) // '-'
396 ia = ia + 1
397 }
398 }
399 while jb < nb {
400 o = _wv_emit(out, o, cap, 43, bbuf, bs_[jb], bl_[jb]) // '+'
401 jb = jb + 1
402 }
403 return o
404}
405
406// ===== 4. ROLLBACK (auth-gated, ADDITIVE) ==================================
407//
408// nx_wiki_rollback(slug, target_epoch, session_token): restore the page to the
409// content of revision <target_epoch>.
410// 1. AUTH GATE (security-critical; REUSES the proven validator -- the EXACT
411// check R1 save uses): a null / forged / expired token => NX_WV_REFUSED
412// and NOTHING is written (the store is left untouched -- liar-kill).
413// 2. Read the target revision's content (header stripped).
414// 3. Call the R1 save path (nx_wiki_page_save) with that content + the SAME
415// valid token, so it persists as a NEW wikirev:<slug>:<now> AND updates
416// wikicur:<slug>. The old revisions are NEVER deleted or mutated -- the
417// rollback is purely additive (cardinal 13): the new head equals the old
418// content while every prior revision still stands as history.
419//
420// `store` may be 0 (persist-only / headless). `now_unix_s` stamps the new rev.
421// Returns NX_WV_OK on success; NX_WV_REFUSED if auth fails; other -verdicts on
422// missing target / save error.
423func nx_wiki_rollback(
424 store: *NxWikiDocStore,
425 ctx: *NxAuthContext,
426 slug: *u8, slug_n: i64,
427 target_epoch: i64,
428 session_token: *u8, token_n: i64,
429 now_unix_s: i64
430) -> i64 {
431 if (slug as i64) == 0 { return 0 - NX_WV_BAD_INPUT }
432 if slug_n < 1 { return 0 - NX_WV_BAD_INPUT }
433
434 // ---- 1. AUTH GATE (reused validator; same as R1 save) ----
435 if (ctx as i64) == 0 { return 0 - NX_WV_REFUSED }
436 let auth_rc: i64 = nx_modern_auth_validate_session(
437 ctx, session_token, token_n, now_unix_s,
438 (0 as i64) as *u8, 0, (0 as i64) as *i64)
439 if auth_rc != NX_MAUTH_OK { return 0 - NX_WV_REFUSED }
440
441 // ---- 2. read the target revision's content ----
442 let content: *u8 = sys_mmap(NX_WPS_MAX_CONTENT_LEN + 16)
443 let cn: i64 = nx_wiki_rev_get(slug, slug_n, target_epoch,
444 content, NX_WPS_MAX_CONTENT_LEN)
445 if cn < 0 { return 0 - NX_WV_NOT_FOUND }
446
447 // ---- 3. R1 save = a NEW revision equal to the old content (additive) ----
448 // nx_wiki_page_save re-validates the SAME token (defence in depth at its own
449 // boundary) and appends wikirev:<slug>:<now> + updates wikicur:<slug>.
450 let save_rc: i64 = nx_wiki_page_save(store, ctx, slug, slug_n,
451 content, cn,
452 session_token, token_n, now_unix_s)
453 if save_rc != NX_WPS_OK {
454 if save_rc == 0 - NX_WPS_REFUSED { return 0 - NX_WV_REFUSED }
455 return 0 - NX_WV_SAVE_FAILED
456 }
457 return NX_WV_OK
458}