code wiki / _hdl_build / nx_wiki_versioned_publish.nx
nx_wiki_versioned_publish.nx source
↩ module page · 268 lines · 13048 B
1// nx_wiki_versioned_publish.nx -- A4a: VERSIONED-PUBLISH + the multi-point rollback INDEX.
2//
3// THE OPERATOR PRECONDITION (verbatim intent): "ability to revert via versions held so we can roll back
4// not just to another broken one but have FULL RESTORE via MULTIPLE rollback points." This is the safety
5// net that must exist BEFORE any full-auto re-publishing: every time a page goes live we FIRST capture the
6// exact bytes as a RETAINED, content-addressed version record, so a later restore can land the LIVE page
7// byte-exact on ANY prior version -- not just the last one, not just a different broken one.
8//
9// COMPOSITION (no storage/crypto reinvented -- every line below routes through a proven organ):
10// nx_wiki_archive war_archive_page / war_get_by_cid / war_cid_of_body (content-addressed blob = the
11// no-rot anchor; the SAME WAR_PREFIX store A2's guard resolves citations from, so a
12// version blob survives across runs / is durable by construction).
13// nx_seg_store ss_begin / ss_add / ss_commit / ss_get_cap (append-only durable KV: a new
14// record is a NEW segment, never an in-place overwrite -- the additive-data law #13).
15// nx_canon_cid cid_of (via war_cid_of_body) (CID = nxc1-+sha256(bytes)).
16// nx_wiki_publish pub_publish_ex (A3 GUARDED publish -- the SOLE
17// enforcement point; EVERY live write here flows through the A2 guard, fail-closed).
18//
19// THE VERSION INDEX (a SEPARATE key family in the SAME durable store; never collides with wikiblob:/wikicid:
20// which the archive owns):
21// wpv:cnt:<slug> -> decimal version COUNT for the slug (a latest-wins pointer; seg_store keeps
22// every prior value in history too, so even this is additive, never destroyed)
23// wpv:ver:<slug>:<v#> -> the content CID of version v# (written EXACTLY ONCE per (slug,v#); a new
24// publish appends a NEW v#, it NEVER rewrites an existing wpv:ver key -- this
25// is the append-only, multi-rollback-point guarantee, mechanical not promised)
26// wpv:ep:<slug>:<v#> -> the fixed epoch stamped for v# (written once; NO wall-clock call -- the
27// caller passes a fixed epoch so the record is deterministic/reproducible)
28// Because v# is monotonic and each wpv:ver:<slug>:<v#> key is unique, publishing v2/v3 can NEVER mutate or
29// delete the v1 record. The blob itself is content-addressed, so even dropping a pointer cannot lose bytes.
30//
31// Pure NishiLang, NO sql/.sh/.py/.js, no new .tsv/.conf. license_tier: ORIGINAL
32import "nx_wiki_publish.nx"
33import "nx_wiki_archive.nx"
34import "nx_seg_store.nx"
35import "nx_syscalls.nx"
36
37// ===== sealed verdict surface (codes 4940-4949; distinct from PUB_*/PG_*/NX_WV_*) =================
38const VP_OK: i64 = 0
39const VP_BAD_INPUT: i64 = 0 - 4940 // null/empty slug or path
40const VP_READ_FAILED: i64 = 0 - 4941 // could not read the new local page bytes
41const VP_ARCHIVE_FAIL: i64 = 0 - 4942 // archive add/commit returned an error
42const VP_NOT_FOUND: i64 = 0 - 4943 // requested version# has no retained record
43const VP_PUSH_REJECT: i64 = 0 - 4944 // A3 guard REJECTED the page (fail-closed; nothing pushed)
44
45// scan cap handed to ss_get_cap (>= live segment count for the archive store).
46const VP_SCAN_CAP: i64 = 256
47// a generous bound on retained versions enumerated by vp_list per call.
48const VP_MAX_VERS: i64 = 4096
49// file mode 0644 for the restore staging file.
50const VP_MODE_0644: i64 = 0x1a4
51
52// ===== tiny local helpers (vp_ namespace; no clash with ss_/war_/pub_) ============================
53func vp_len(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n }
54
55// concat NUL-terminated s into dst at off; returns new off (no terminator written).
56func vp_cat(dst: *u8, off: i64, s: *u8) -> i64 {
57 var o: i64 = off; var k: i64 = 0
58 while s[k] != (0 as u8) { dst[o] = s[k]; o = o + 1; k = k + 1 }
59 return o
60}
61
62// append decimal of v (>=0) into dst at off; returns new off.
63func vp_catn(dst: *u8, off: i64, v: i64) -> i64 {
64 var o: i64 = off; let t: *u8 = sys_mmap(28); var m: i64 = v
65 var k: i64 = 0
66 if m == 0 { t[0] = 48 as u8; k = 1 }
67 while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 }
68 var i: i64 = 0
69 while i < k { dst[o] = t[k - 1 - i]; o = o + 1; i = i + 1 }
70 return o
71}
72
73// parse a non-negative decimal from a NUL-terminated buffer; -1 if any non-digit. (reads cnt values back.)
74func vp_parse_dec(b: *u8, n: i64) -> i64 {
75 if n < 1 { return 0 - 1 }
76 var v: i64 = 0; var i: i64 = 0
77 while i < n {
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// ---- version-index key builders (all NUL-terminated) ----
88// wpv:cnt:<slug>
89func vp_cnt_key(slug: *u8, out: *u8) -> i64 {
90 var o: i64 = 0
91 o = vp_cat(out, o, "wpv:cnt:" as *u8)
92 o = vp_cat(out, o, slug)
93 out[o] = 0 as u8
94 return o
95}
96// wpv:ver:<slug>:<v#>
97func vp_ver_key(slug: *u8, v: i64, out: *u8) -> i64 {
98 var o: i64 = 0
99 o = vp_cat(out, o, "wpv:ver:" as *u8)
100 o = vp_cat(out, o, slug)
101 out[o] = 58 as u8; o = o + 1 // ':'
102 o = vp_catn(out, o, v)
103 out[o] = 0 as u8
104 return o
105}
106// wpv:ep:<slug>:<v#>
107func vp_ep_key(slug: *u8, v: i64, out: *u8) -> i64 {
108 var o: i64 = 0
109 o = vp_cat(out, o, "wpv:ep:" as *u8)
110 o = vp_cat(out, o, slug)
111 out[o] = 58 as u8; o = o + 1 // ':'
112 o = vp_catn(out, o, v)
113 out[o] = 0 as u8
114 return o
115}
116
117// ---- read the current retained version COUNT for a slug from the committed store ----
118// Returns the count (>=0; 0 if the slug has never been versioned). prefix = archive store prefix.
119func vp_get_count(prefix: *u8, slug: *u8) -> i64 {
120 let ck: *u8 = sys_mmap(512)
121 vp_cnt_key(slug, ck)
122 let pp: *i64 = sys_mmap(16) as *i64
123 let ll: *i64 = sys_mmap(16) as *i64
124 let r: i64 = ss_get_cap(prefix, ck, pp, ll, VP_SCAN_CAP)
125 if r != 1 { return 0 }
126 let src: *u8 = pp[0] as *u8
127 let n: i64 = ll[0]
128 let tmp: *u8 = sys_mmap(n + 1)
129 var i: i64 = 0
130 while i < n { tmp[i] = src[i]; i = i + 1 }
131 tmp[n] = 0 as u8
132 let v: i64 = vp_parse_dec(tmp, n)
133 if v < 0 { return 0 }
134 return v
135}
136
137// ---- look up the CID retained for (slug, v#) ----
138// Copies the NUL-terminated CID into cidout (>=72) and returns its length, or -1 if v# is not retained.
139func vp_cid_of_version(prefix: *u8, slug: *u8, v: i64, cidout: *u8) -> i64 {
140 let vk: *u8 = sys_mmap(512)
141 vp_ver_key(slug, v, vk)
142 let pp: *i64 = sys_mmap(16) as *i64
143 let ll: *i64 = sys_mmap(16) as *i64
144 let r: i64 = ss_get_cap(prefix, vk, pp, ll, VP_SCAN_CAP)
145 if r != 1 { return 0 - 1 }
146 let src: *u8 = pp[0] as *u8
147 let n: i64 = ll[0]
148 var i: i64 = 0
149 while i < n { cidout[i] = src[i]; i = i + 1 }
150 cidout[n] = 0 as u8
151 return n
152}
153
154// ===== vp_list: enumerate the FULL retained rollback-point list (the multi-point proof) ============
155// Fills vers[i] (version#) and copies each version's CID (NUL-terminated, 70 bytes incl NUL) into the
156// flat cidbuf at offset i*72 (so cidbuf must be >= count*72). Returns the count of retained points (>=0).
157// This is what proves "MULTIPLE rollback points" exist -- not merely the latest.
158func vp_list_ex(prefix: *u8, slug: *u8, vers: *i64, cidbuf: *u8, max: i64) -> i64 {
159 let cnt: i64 = vp_get_count(prefix, slug)
160 var out: i64 = 0
161 var v: i64 = 1
162 while v <= cnt {
163 if out < max {
164 let cidp: *u8 = (cidbuf as i64 + out * 72) as *u8
165 let cl: i64 = vp_cid_of_version(prefix, slug, v, cidp)
166 if cl > 0 {
167 vers[out] = v
168 out = out + 1
169 }
170 }
171 v = v + 1
172 }
173 return out
174}
175
176// production convenience: prefix defaults to WAR_PREFIX (the live durable archive store).
177func vp_list(slug: *u8, vers: *i64, cidbuf: *u8, max: i64) -> i64 {
178 return vp_list_ex(WAR_PREFIX, slug, vers, cidbuf, max)
179}
180
181// ===== vpub: capture-then-publish (the versioned guarded publish) ==================================
182// 1. read the bytes that are about to go live (new_local_html_path).
183// 2. ARCHIVE those bytes as a content-addressed blob (war_archive_page -> wikiblob:<cid>, the no-rot
184// anchor) AND append the version-index records for the NEXT monotonic v# (wpv:ver / wpv:ep) plus the
185// bumped wpv:cnt -- all in ONE seg_store commit (atomic: readers see the new version only after the
186// manifest rename). This is captured BEFORE the live overwrite, so the version is retained even if the
187// push later fails. A new v# never rewrites an older version record (append-only multi-point).
188// 3. PUBLISH the bytes through the A3 GUARD (pub_publish_ex). If do_push_flag==0 the guard verdict is still
189// enforced (ALLOW/REJECT) but nothing is pushed -- lets the gate exercise the full archive+index path
190// hermetically. A guard REJECT returns VP_PUSH_REJECT and the live page is untouched (fail-closed); the
191// version we just retained stays in the store (additive -- a rejected publish still left a recoverable
192// snapshot, which is exactly the safety net we want).
193//
194// prefix = archive/index store prefix (production: WAR_PREFIX). epoch = a FIXED stamp (NO wall-clock here).
195// out (PubResult) receives the A3 push verdict. vout[0] receives the version# assigned. Returns VP_OK, or a
196// negative VP_* code on failure.
197func vpub_ex(prefix: *u8, slug: *u8, new_local_html_path: *u8,
198 cs_ptr: *i64, cs_len: *i64, ncorpus: i64,
199 epoch: i64, do_push_flag: i64,
200 out: *PubResult, vout: *i64) -> i64 {
201 vout[0] = 0
202 if (slug as i64) == 0 { return VP_BAD_INPUT }
203 if (new_local_html_path as i64) == 0 { return VP_BAD_INPUT }
204 if vp_len(slug) < 1 { return VP_BAD_INPUT }
205
206 // 1. read the page bytes that are about to be published.
207 let nbox: *i64 = sys_mmap(16) as *i64
208 let body: *u8 = sys_read_file(new_local_html_path, nbox)
209 if (body as i64) == 0 { return VP_READ_FAILED }
210 let n: i64 = nbox[0]
211 if n <= 0 { return VP_READ_FAILED }
212
213 // 2. capture as a RETAINED version: blob + index records in ONE commit.
214 let next_v: i64 = vp_get_count(prefix, slug) + 1
215 let w: *i64 = ss_begin()
216 let cid: *u8 = sys_mmap(80)
217 // content-addressed blob (REUSED: also stamps wikicid:<slug> = current pointer; the immutable blob is
218 // the durable anchor a CID resolves to forever).
219 let ar: i64 = war_archive_page(w, slug, body, n, cid)
220 if ar != 0 { return VP_ARCHIVE_FAIL }
221 let cidn: i64 = vp_len(cid)
222 // version-index records (each key unique => append-only; never rewrites an older v#).
223 let vk: *u8 = sys_mmap(512)
224 vp_ver_key(slug, next_v, vk)
225 if ss_add(w, 1, vk, cid, cidn) < 0 { return VP_ARCHIVE_FAIL }
226 let ek: *u8 = sys_mmap(512)
227 vp_ep_key(slug, next_v, ek)
228 let epbuf: *u8 = sys_mmap(28)
229 let epn: i64 = vp_catn(epbuf, 0, epoch)
230 if ss_add(w, 1, ek, epbuf, epn) < 0 { return VP_ARCHIVE_FAIL }
231 // bump the count pointer (latest-wins; prior count values stay in history = additive).
232 let ck: *u8 = sys_mmap(512)
233 vp_cnt_key(slug, ck)
234 let cntbuf: *u8 = sys_mmap(28)
235 let cntn: i64 = vp_catn(cntbuf, 0, next_v)
236 if ss_add(w, 1, ck, cntbuf, cntn) < 0 { return VP_ARCHIVE_FAIL }
237 // ONE atomic commit (segment temp -> rename, manifest temp -> rename; the manifest rename is the
238 // commit point -- a crash before it leaves the prior versions intact and this one invisible).
239 if ss_commit(prefix, w, next_v) != 0 { return VP_ARCHIVE_FAIL }
240
241 vout[0] = next_v
242
243 // 3. publish through the A3 GUARD (the sole enforcement point; fail-closed on REJECT).
244 let pubrc: i64 = pub_publish_ex(prefix, new_local_html_path, slug,
245 cs_ptr, cs_len, ncorpus, do_push_flag, out)
246 if pubrc == PUB_REJECTED { return VP_PUSH_REJECT }
247 return VP_OK
248}
249
250// production convenience: prefix = WAR_PREFIX (live durable store).
251func vpub(slug: *u8, new_local_html_path: *u8,
252 cs_ptr: *i64, cs_len: *i64, ncorpus: i64,
253 epoch: i64, do_push_flag: i64,
254 out: *PubResult, vout: *i64) -> i64 {
255 return vpub_ex(WAR_PREFIX, slug, new_local_html_path, cs_ptr, cs_len, ncorpus,
256 epoch, do_push_flag, out, vout)
257}
258
259// human-readable verdict name (for the live-proof runner / logs).
260func vp_status_name(s: i64) -> *u8 {
261 if s == VP_OK { return "VP_OK" as *u8 }
262 if s == VP_BAD_INPUT { return "VP_BAD_INPUT" as *u8 }
263 if s == VP_READ_FAILED { return "VP_READ_FAILED" as *u8 }
264 if s == VP_ARCHIVE_FAIL { return "VP_ARCHIVE_FAIL" as *u8 }
265 if s == VP_NOT_FOUND { return "VP_NOT_FOUND" as *u8 }
266 if s == VP_PUSH_REJECT { return "VP_PUSH_REJECT" as *u8 }
267 return "VP_UNKNOWN" as *u8
268}