code wiki / _hdl_build / nx_wiki_versioned_publish.nx
nx_wiki_versioned_publish.nx source
↩ module page · 267 lines · 13012 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.
50
51// ===== tiny local helpers (vp_ namespace; no clash with ss_/war_/pub_) ============================
52func vp_len(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n }
53
54// concat NUL-terminated s into dst at off; returns new off (no terminator written).
55func vp_cat(dst: *u8, off: i64, s: *u8) -> i64 {
56 var o: i64 = off; var k: i64 = 0
57 while s[k] != (0 as u8) { dst[o] = s[k]; o = o + 1; k = k + 1 }
58 return o
59}
60
61// append decimal of v (>=0) into dst at off; returns new off.
62func vp_catn(dst: *u8, off: i64, v: i64) -> i64 {
63 var o: i64 = off; let t: *u8 = sys_mmap(28); var m: i64 = v
64 var k: i64 = 0
65 if m == 0 { t[0] = 48 as u8; k = 1 }
66 while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 }
67 var i: i64 = 0
68 while i < k { dst[o] = t[k - 1 - i]; o = o + 1; i = i + 1 }
69 return o
70}
71
72// parse a non-negative decimal from a NUL-terminated buffer; -1 if any non-digit. (reads cnt values back.)
73func vp_parse_dec(b: *u8, n: i64) -> i64 {
74 if n < 1 { return 0 - 1 }
75 var v: i64 = 0; var i: i64 = 0
76 while i < n {
77 let c: i64 = b[i]
78 if c < 48 { return 0 - 1 }
79 if c > 57 { return 0 - 1 }
80 v = v * 10 + (c - 48)
81 i = i + 1
82 }
83 return v
84}
85
86// ---- version-index key builders (all NUL-terminated) ----
87// wpv:cnt:<slug>
88func vp_cnt_key(slug: *u8, out: *u8) -> i64 {
89 var o: i64 = 0
90 o = vp_cat(out, o, "wpv:cnt:" as *u8)
91 o = vp_cat(out, o, slug)
92 out[o] = 0 as u8
93 return o
94}
95// wpv:ver:<slug>:<v#>
96func vp_ver_key(slug: *u8, v: i64, out: *u8) -> i64 {
97 var o: i64 = 0
98 o = vp_cat(out, o, "wpv:ver:" as *u8)
99 o = vp_cat(out, o, slug)
100 out[o] = 58 as u8; o = o + 1 // ':'
101 o = vp_catn(out, o, v)
102 out[o] = 0 as u8
103 return o
104}
105// wpv:ep:<slug>:<v#>
106func vp_ep_key(slug: *u8, v: i64, out: *u8) -> i64 {
107 var o: i64 = 0
108 o = vp_cat(out, o, "wpv:ep:" as *u8)
109 o = vp_cat(out, o, slug)
110 out[o] = 58 as u8; o = o + 1 // ':'
111 o = vp_catn(out, o, v)
112 out[o] = 0 as u8
113 return o
114}
115
116// ---- read the current retained version COUNT for a slug from the committed store ----
117// Returns the count (>=0; 0 if the slug has never been versioned). prefix = archive store prefix.
118func vp_get_count(prefix: *u8, slug: *u8) -> i64 {
119 let ck: *u8 = sys_mmap(512)
120 vp_cnt_key(slug, ck)
121 let pp: *i64 = sys_mmap(16) as *i64
122 let ll: *i64 = sys_mmap(16) as *i64
123 let r: i64 = ss_get_cap(prefix, ck, pp, ll, VP_SCAN_CAP)
124 if r != 1 { return 0 }
125 let src: *u8 = pp[0] as *u8
126 let n: i64 = ll[0]
127 let tmp: *u8 = sys_mmap(n + 1)
128 var i: i64 = 0
129 while i < n { tmp[i] = src[i]; i = i + 1 }
130 tmp[n] = 0 as u8
131 let v: i64 = vp_parse_dec(tmp, n)
132 if v < 0 { return 0 }
133 return v
134}
135
136// ---- look up the CID retained for (slug, v#) ----
137// Copies the NUL-terminated CID into cidout (>=72) and returns its length, or -1 if v# is not retained.
138func vp_cid_of_version(prefix: *u8, slug: *u8, v: i64, cidout: *u8) -> i64 {
139 let vk: *u8 = sys_mmap(512)
140 vp_ver_key(slug, v, vk)
141 let pp: *i64 = sys_mmap(16) as *i64
142 let ll: *i64 = sys_mmap(16) as *i64
143 let r: i64 = ss_get_cap(prefix, vk, pp, ll, VP_SCAN_CAP)
144 if r != 1 { return 0 - 1 }
145 let src: *u8 = pp[0] as *u8
146 let n: i64 = ll[0]
147 var i: i64 = 0
148 while i < n { cidout[i] = src[i]; i = i + 1 }
149 cidout[n] = 0 as u8
150 return n
151}
152
153// ===== vp_list: enumerate the FULL retained rollback-point list (the multi-point proof) ============
154// Fills vers[i] (version#) and copies each version's CID (NUL-terminated, 70 bytes incl NUL) into the
155// flat cidbuf at offset i*72 (so cidbuf must be >= count*72). Returns the count of retained points (>=0).
156// This is what proves "MULTIPLE rollback points" exist -- not merely the latest.
157func vp_list_ex(prefix: *u8, slug: *u8, vers: *i64, cidbuf: *u8, max: i64) -> i64 {
158 let cnt: i64 = vp_get_count(prefix, slug)
159 var out: i64 = 0
160 var v: i64 = 1
161 while v <= cnt {
162 if out < max {
163 let cidp: *u8 = (cidbuf as i64 + out * 72) as *u8
164 let cl: i64 = vp_cid_of_version(prefix, slug, v, cidp)
165 if cl > 0 {
166 vers[out] = v
167 out = out + 1
168 }
169 }
170 v = v + 1
171 }
172 return out
173}
174
175// production convenience: prefix defaults to WAR_PREFIX (the live durable archive store).
176func vp_list(slug: *u8, vers: *i64, cidbuf: *u8, max: i64) -> i64 {
177 return vp_list_ex(WAR_PREFIX, slug, vers, cidbuf, max)
178}
179
180// ===== vpub: capture-then-publish (the versioned guarded publish) ==================================
181// 1. read the bytes that are about to go live (new_local_html_path).
182// 2. ARCHIVE those bytes as a content-addressed blob (war_archive_page -> wikiblob:<cid>, the no-rot
183// anchor) AND append the version-index records for the NEXT monotonic v# (wpv:ver / wpv:ep) plus the
184// bumped wpv:cnt -- all in ONE seg_store commit (atomic: readers see the new version only after the
185// manifest rename). This is captured BEFORE the live overwrite, so the version is retained even if the
186// push later fails. A new v# never rewrites an older version record (append-only multi-point).
187// 3. PUBLISH the bytes through the A3 GUARD (pub_publish_ex). If do_push_flag==0 the guard verdict is still
188// enforced (ALLOW/REJECT) but nothing is pushed -- lets the gate exercise the full archive+index path
189// hermetically. A guard REJECT returns VP_PUSH_REJECT and the live page is untouched (fail-closed); the
190// version we just retained stays in the store (additive -- a rejected publish still left a recoverable
191// snapshot, which is exactly the safety net we want).
192//
193// prefix = archive/index store prefix (production: WAR_PREFIX). epoch = a FIXED stamp (NO wall-clock here).
194// out (PubResult) receives the A3 push verdict. vout[0] receives the version# assigned. Returns VP_OK, or a
195// negative VP_* code on failure.
196func vpub_ex(prefix: *u8, slug: *u8, new_local_html_path: *u8,
197 cs_ptr: *i64, cs_len: *i64, ncorpus: i64,
198 epoch: i64, do_push_flag: i64,
199 out: *PubResult, vout: *i64) -> i64 {
200 vout[0] = 0
201 if (slug as i64) == 0 { return VP_BAD_INPUT }
202 if (new_local_html_path as i64) == 0 { return VP_BAD_INPUT }
203 if vp_len(slug) < 1 { return VP_BAD_INPUT }
204
205 // 1. read the page bytes that are about to be published.
206 let nbox: *i64 = sys_mmap(16) as *i64
207 let body: *u8 = sys_read_file(new_local_html_path, nbox)
208 if (body as i64) == 0 { return VP_READ_FAILED }
209 let n: i64 = nbox[0]
210 if n <= 0 { return VP_READ_FAILED }
211
212 // 2. capture as a RETAINED version: blob + index records in ONE commit.
213 let next_v: i64 = vp_get_count(prefix, slug) + 1
214 let w: *i64 = ss_begin()
215 let cid: *u8 = sys_mmap(80)
216 // content-addressed blob (REUSED: also stamps wikicid:<slug> = current pointer; the immutable blob is
217 // the durable anchor a CID resolves to forever).
218 let ar: i64 = war_archive_page(w, slug, body, n, cid)
219 if ar != 0 { return VP_ARCHIVE_FAIL }
220 let cidn: i64 = vp_len(cid)
221 // version-index records (each key unique => append-only; never rewrites an older v#).
222 let vk: *u8 = sys_mmap(512)
223 vp_ver_key(slug, next_v, vk)
224 if ss_add(w, 1, vk, cid, cidn) < 0 { return VP_ARCHIVE_FAIL }
225 let ek: *u8 = sys_mmap(512)
226 vp_ep_key(slug, next_v, ek)
227 let epbuf: *u8 = sys_mmap(28)
228 let epn: i64 = vp_catn(epbuf, 0, epoch)
229 if ss_add(w, 1, ek, epbuf, epn) < 0 { return VP_ARCHIVE_FAIL }
230 // bump the count pointer (latest-wins; prior count values stay in history = additive).
231 let ck: *u8 = sys_mmap(512)
232 vp_cnt_key(slug, ck)
233 let cntbuf: *u8 = sys_mmap(28)
234 let cntn: i64 = vp_catn(cntbuf, 0, next_v)
235 if ss_add(w, 1, ck, cntbuf, cntn) < 0 { return VP_ARCHIVE_FAIL }
236 // ONE atomic commit (segment temp -> rename, manifest temp -> rename; the manifest rename is the
237 // commit point -- a crash before it leaves the prior versions intact and this one invisible).
238 if ss_commit(prefix, w, next_v) != 0 { return VP_ARCHIVE_FAIL }
239
240 vout[0] = next_v
241
242 // 3. publish through the A3 GUARD (the sole enforcement point; fail-closed on REJECT).
243 let pubrc: i64 = pub_publish_ex(prefix, new_local_html_path, slug,
244 cs_ptr, cs_len, ncorpus, do_push_flag, out)
245 if pubrc == PUB_REJECTED { return VP_PUSH_REJECT }
246 return VP_OK
247}
248
249// production convenience: prefix = WAR_PREFIX (live durable store).
250func vpub(slug: *u8, new_local_html_path: *u8,
251 cs_ptr: *i64, cs_len: *i64, ncorpus: i64,
252 epoch: i64, do_push_flag: i64,
253 out: *PubResult, vout: *i64) -> i64 {
254 return vpub_ex(WAR_PREFIX, slug, new_local_html_path, cs_ptr, cs_len, ncorpus,
255 epoch, do_push_flag, out, vout)
256}
257
258// human-readable verdict name (for the live-proof runner / logs).
259func vp_status_name(s: i64) -> *u8 {
260 if s == VP_OK { return "VP_OK" as *u8 }
261 if s == VP_BAD_INPUT { return "VP_BAD_INPUT" as *u8 }
262 if s == VP_READ_FAILED { return "VP_READ_FAILED" as *u8 }
263 if s == VP_ARCHIVE_FAIL { return "VP_ARCHIVE_FAIL" as *u8 }
264 if s == VP_NOT_FOUND { return "VP_NOT_FOUND" as *u8 }
265 if s == VP_PUSH_REJECT { return "VP_PUSH_REJECT" as *u8 }
266 return "VP_UNKNOWN" as *u8
267}