nx_journal_log.nx source
↩ module page · 356 lines · 13384 B
1// nx_journal_log.nx -- hash-chained append-only event log (L1 of
2// the storage substrate).
3//
4// ST-3 milestone of NISHI_STORAGE_SUBSTRATE_ROADMAP.md. Composes
5// L0 nx_blob_store + nx_sha256 to ship the substrate-native
6// replacement for bash lib_fact_emit.sh.
7//
8// Each appended event:
9// 1. Payload bytes get stored as an L0 blob -> blob_hash
10// 2. Entry struct = (seq_no, ts_us, schema_id, prev_entry_hash,
11// blob_hash) is canonicalized to bytes
12// 3. entry_hash = SHA-256(canonical_entry_bytes)
13// 4. The new entry's prev_entry_hash field points to the PRIOR
14// entry's entry_hash (chain link)
15// 5. Entry-0's prev_entry_hash is zeroed (chain root)
16//
17// To detect tamper anywhere in history:
18// - Recompute entry_hash(N-1) from the live entry struct
19// - Compare against entry_N.prev_entry_hash
20// - First mismatch returns its seq_no; -1 if chain is intact
21// Tampering ANY field in entry K (prev_hash, blob_hash, ts, schema)
22// changes entry_hash(K) -> next entry's prev pointer mismatches ->
23// verify_chain catches it at K+1.
24//
25// Per Cardinal 13 (additive-only): the log NEVER deletes; updates
26// to data write new events with new payloads; "removal" semantics
27// at higher layers go via new is_current=0 events, not mutation.
28//
29// V1 scope:
30// - In-memory entries (file-backed deferred to ST-3.5)
31// - Max NX_JOURNAL_LOG_CAPACITY = 256 entries
32// - Canary-bracketed for tamper-detect on the LOG itself (in
33// addition to chain verification on its CONTENTS)
34// - schema_id is operator-supplied (caller picks the meaning)
35//
36// Deferred per roadmap:
37// - File-backed persistence (ST-3.5)
38// - Merkle Mountain Range layout for O(log n) inclusion proofs
39// (ST-3.5 or later)
40// - Multi-process append (ST-4)
41// - Threshold-signed entries (ST-6+)
42//
43// genealogy_id: git_reflog_2005 + bitcoin_block_chain_2009 +
44// mimblewimble_mmr_2016 + tuf_2009 + slsa_2021 +
45// cardinal_2026-05-20_storage_substrate
46// lineage_id: substrate_journal_log_v1
47//
48// nx_capability_manifest:
49// variant_class: journal_log
50// variant_id: journal_log_v1_in_memory_hash_chain
51// requires_isa: [rv64imac, x86_64]
52// requires_syscalls: [mmap, clock_gettime_mono]
53// requires_ram_min_b: 16384
54// tier_floor: NX_TIER_MOBILE
55// tier_ceiling: NX_TIER_HPC
56// cost_model:
57// flops_per_n: 50.0 // SHA-256 per append
58// bytes_per_n: 96.0 // entry canonical bytes
59// syscalls_per_n: 1.0 // one clock_gettime per append
60// adversary_class: THREAT_AI_ADVERSARY
61//
62// nx_safety_envelope:
63// intended_use: "Substrate-native append-only event log;
64// replaces bash lib_fact_emit for hash-verified
65// persistence"
66// sil_target: SIL2
67// evidence: [canary_bracketed, hash_chain_verified,
68// additive_only, payload_content_addressed]
69// verdict: NOT_YET_EVALUATED
70
71import "nx_syscalls.nx"
72import "nx_sha256.nx"
73import "nx_blob_store.nx"
74const NX_MAGIC_1000000: i64 = 1000000
75
76// ===== Constants =================================================
77const NX_JOURNAL_LOG_CAPACITY: i64 = 256
78
79// Verdicts.
80const NX_JOURNAL_OK: i64 = 0
81const NX_JOURNAL_BAD_INPUT: i64 = 1
82const NX_JOURNAL_FULL: i64 = 2
83const NX_JOURNAL_NOT_FOUND: i64 = 3
84const NX_JOURNAL_TAMPER: i64 = 4
85const NX_JOURNAL_CHAIN_BROKEN: i64 = 5
86
87// Canaries (distinct from blob_store / probe / calib / plan).
88const NX_JOURNAL_LOG_CANARY_PRE: i64 = 0x4E584A4F555250 // "NXJOURP"
89const NX_JOURNAL_LOG_CANARY_POST: i64 = 0x4E584A4F55524550 // "NXJOUREP"
90
91// ===== NxJournalEntry ============================================
92// Inlines both prev_entry_hash (4 i64) and blob_hash (4 i64) so
93// canonical serialization is straight little-endian field-by-field.
94
95struct NxJournalEntry {
96 seq_no: i64,
97 ts_us: i64,
98 schema_id: i64,
99 prev_w0: i64,
100 prev_w1: i64,
101 prev_w2: i64,
102 prev_w3: i64,
103 blob_w0: i64,
104 blob_w1: i64,
105 blob_w2: i64,
106 blob_w3: i64,
107}
108
109// ===== NxJournalLog ==============================================
110struct NxJournalLog {
111 canary_pre: i64,
112 n_entries: i64,
113 max_entries: i64,
114 entries: *i64, // *i64 of NxJournalEntry pointers
115 blob_store: *NxBlobStore, // back-reference to L0 store
116 canary_post: i64,
117}
118
119// ===== Helpers ===================================================
120
121// Little-endian store of i64 into buf[off..off+8].
122func _journal_store_i64_le(buf: *u8, off: i64, v: i64) -> i64 {
123 buf[off + 0] = (v & 255) as u8
124 buf[off + 1] = ((v >> 8) & 255) as u8
125 buf[off + 2] = ((v >> 16) & 255) as u8
126 buf[off + 3] = ((v >> 24) & 255) as u8
127 buf[off + 4] = ((v >> 32) & 255) as u8
128 buf[off + 5] = ((v >> 40) & 255) as u8
129 buf[off + 6] = ((v >> 48) & 255) as u8
130 buf[off + 7] = ((v >> 56) & 255) as u8
131 return off + 8
132}
133
134// Little-endian load of i64 from buf[off..off+8].
135func _journal_load_i64_le(buf: *u8, off: i64) -> i64 {
136 let b0: i64 = (buf[off + 0] as i64) & 255
137 let b1: i64 = (buf[off + 1] as i64) & 255
138 let b2: i64 = (buf[off + 2] as i64) & 255
139 let b3: i64 = (buf[off + 3] as i64) & 255
140 let b4: i64 = (buf[off + 4] as i64) & 255
141 let b5: i64 = (buf[off + 5] as i64) & 255
142 let b6: i64 = (buf[off + 6] as i64) & 255
143 let b7: i64 = (buf[off + 7] as i64) & 255
144 return b0
145 | (b1 << 8)
146 | (b2 << 16)
147 | (b3 << 24)
148 | (b4 << 32)
149 | (b5 << 40)
150 | (b6 << 48)
151 | (b7 << 56)
152}
153
154// Canonicalize entry into a buffer (11 i64 fields = 88 bytes LE).
155func _journal_canonicalize(e: *NxJournalEntry, buf: *u8) -> i64 {
156 var off: i64 = 0
157 off = _journal_store_i64_le(buf, off, e.seq_no)
158 off = _journal_store_i64_le(buf, off, e.ts_us)
159 off = _journal_store_i64_le(buf, off, e.schema_id)
160 off = _journal_store_i64_le(buf, off, e.prev_w0)
161 off = _journal_store_i64_le(buf, off, e.prev_w1)
162 off = _journal_store_i64_le(buf, off, e.prev_w2)
163 off = _journal_store_i64_le(buf, off, e.prev_w3)
164 off = _journal_store_i64_le(buf, off, e.blob_w0)
165 off = _journal_store_i64_le(buf, off, e.blob_w1)
166 off = _journal_store_i64_le(buf, off, e.blob_w2)
167 off = _journal_store_i64_le(buf, off, e.blob_w3)
168 return off
169}
170
171// Compute entry_hash from a canonical entry struct. Writes 32
172// bytes into out_digest (4 i64 LE-packed via 4 separate writes).
173func _journal_entry_hash(e: *NxJournalEntry,
174 out_w0: *i64, out_w1: *i64,
175 out_w2: *i64, out_w3: *i64) -> i64 {
176 let buf: *u8 = sys_mmap(128)
177 let n: i64 = _journal_canonicalize(e, buf)
178 let digest: *u8 = sys_mmap(64)
179 sha256_digest(buf, n, digest)
180 *out_w0 = _journal_load_i64_le(digest, 0)
181 *out_w1 = _journal_load_i64_le(digest, 8)
182 *out_w2 = _journal_load_i64_le(digest, 16)
183 *out_w3 = _journal_load_i64_le(digest, 24)
184 return 0
185}
186
187// ===== Construction ==============================================
188func nx_journal_log_new(blob_store: *NxBlobStore) -> *NxJournalLog {
189 if (blob_store as i64) == 0 { return (0 as i64) as *NxJournalLog }
190 let j: *NxJournalLog = (sys_mmap(64)) as *NxJournalLog
191 j.canary_pre = NX_JOURNAL_LOG_CANARY_PRE
192 j.canary_post = NX_JOURNAL_LOG_CANARY_POST
193 j.n_entries = 0
194 j.max_entries = NX_JOURNAL_LOG_CAPACITY
195 j.entries = (sys_mmap(NX_JOURNAL_LOG_CAPACITY * 8)) as *i64
196 var i: i64 = 0
197 while i < NX_JOURNAL_LOG_CAPACITY {
198 j.entries[i] = 0
199 i = i + 1
200 }
201 j.blob_store = blob_store
202 return j
203}
204
205// ===== Validity gate =============================================
206func nx_journal_log_is_valid(j: *NxJournalLog) -> i64 {
207 if (j as i64) == 0 { return 0 }
208 if j.canary_pre != NX_JOURNAL_LOG_CANARY_PRE { return 0 }
209 if j.canary_post != NX_JOURNAL_LOG_CANARY_POST { return 0 }
210 if j.n_entries < 0 { return 0 }
211 if j.n_entries > j.max_entries { return 0 }
212 if (j.blob_store as i64) == 0 { return 0 }
213 return 1
214}
215
216// ===== Append ====================================================
217// Stores payload as L0 blob, builds entry, chains via prev_entry_hash.
218// Returns the new seq_no on success or a verdict on failure.
219
220func nx_journal_log_append(
221 j: *NxJournalLog,
222 payload: *u8, payload_len: i64,
223 schema_id: i64
224) -> i64 {
225 if nx_journal_log_is_valid(j) != 1 { return 0 - NX_JOURNAL_TAMPER }
226 if payload_len < 0 { return 0 - NX_JOURNAL_BAD_INPUT }
227 if payload_len > 0 {
228 if (payload as i64) == 0 { return 0 - NX_JOURNAL_BAD_INPUT }
229 }
230 if j.n_entries >= j.max_entries { return 0 - NX_JOURNAL_FULL }
231
232 // 1. Store payload in L0 -> blob_hash
233 let blob_hash: *NxBlobHash = nx_blob_hash_new()
234 let rc_put: i64 = nx_blob_store_put(j.blob_store, payload, payload_len, blob_hash)
235 if rc_put != NX_BLOB_OK { return 0 - NX_JOURNAL_BAD_INPUT }
236
237 // 2. Compute prev_entry_hash (chain link)
238 var prev_w0: i64 = 0
239 var prev_w1: i64 = 0
240 var prev_w2: i64 = 0
241 var prev_w3: i64 = 0
242 if j.n_entries > 0 {
243 let prev_e_addr: i64 = j.entries[j.n_entries - 1]
244 let prev_e: *NxJournalEntry = prev_e_addr as *NxJournalEntry
245 let pw0: *i64 = (sys_mmap(8)) as *i64
246 let pw1: *i64 = (sys_mmap(8)) as *i64
247 let pw2: *i64 = (sys_mmap(8)) as *i64
248 let pw3: *i64 = (sys_mmap(8)) as *i64
249 _journal_entry_hash(prev_e, pw0, pw1, pw2, pw3)
250 prev_w0 = *pw0
251 prev_w1 = *pw1
252 prev_w2 = *pw2
253 prev_w3 = *pw3
254 }
255
256 // 3. Allocate + populate new entry
257 let e: *NxJournalEntry = (sys_mmap(96)) as *NxJournalEntry
258 e.seq_no = j.n_entries
259 let ts: *i64 = (sys_mmap(16)) as *i64
260 let rc_ts: i64 = sys_clock_gettime_mono(ts)
261 if rc_ts == 0 {
262 e.ts_us = (ts[0] * NX_MAGIC_1000000) + (ts[1] / 1000)
263 } else {
264 e.ts_us = 0
265 }
266 e.schema_id = schema_id
267 e.prev_w0 = prev_w0
268 e.prev_w1 = prev_w1
269 e.prev_w2 = prev_w2
270 e.prev_w3 = prev_w3
271 e.blob_w0 = blob_hash.w0
272 e.blob_w1 = blob_hash.w1
273 e.blob_w2 = blob_hash.w2
274 e.blob_w3 = blob_hash.w3
275
276 // 4. Commit
277 j.entries[j.n_entries] = e as i64
278 let new_seq: i64 = j.n_entries
279 j.n_entries = j.n_entries + 1
280 return new_seq
281}
282
283// ===== Read ======================================================
284// Reads payload bytes for entry `seq_no` into out_buf. Returns
285// payload length on success or -1 on miss / oversized / tamper.
286// Also writes the entry's schema_id into *out_schema_id (caller
287// can pass null to skip).
288
289func nx_journal_log_read(
290 j: *NxJournalLog,
291 seq_no: i64,
292 out_buf: *u8, max_len: i64,
293 out_schema_id: *i64
294) -> i64 {
295 if nx_journal_log_is_valid(j) != 1 { return -1 }
296 if seq_no < 0 { return -1 }
297 if seq_no >= j.n_entries { return -1 }
298 if (out_buf as i64) == 0 { return -1 }
299 if max_len < 0 { return -1 }
300
301 let e: *NxJournalEntry = (j.entries[seq_no]) as *NxJournalEntry
302 if (out_schema_id as i64) != 0 { *out_schema_id = e.schema_id }
303
304 let blob_hash: *NxBlobHash = nx_blob_hash_new()
305 blob_hash.w0 = e.blob_w0
306 blob_hash.w1 = e.blob_w1
307 blob_hash.w2 = e.blob_w2
308 blob_hash.w3 = e.blob_w3
309 return nx_blob_store_get(j.blob_store, blob_hash, out_buf, max_len)
310}
311
312// ===== Chain verification =======================================
313// Walks the log and verifies the prev_entry_hash linkage at every
314// entry past 0. Returns -1 if the chain is intact, OR the seq_no
315// of the first broken entry (entry whose prev_w* does NOT match
316// the recomputed entry_hash of seq_no-1).
317
318func nx_journal_log_verify_chain(j: *NxJournalLog) -> i64 {
319 if nx_journal_log_is_valid(j) != 1 { return 0 } // K=0 broken (tamper)
320 if j.n_entries <= 1 { return -1 } // trivially intact
321
322 let pw0: *i64 = (sys_mmap(8)) as *i64
323 let pw1: *i64 = (sys_mmap(8)) as *i64
324 let pw2: *i64 = (sys_mmap(8)) as *i64
325 let pw3: *i64 = (sys_mmap(8)) as *i64
326 var k: i64 = 1
327 while k < j.n_entries {
328 let prev: *NxJournalEntry = (j.entries[k - 1]) as *NxJournalEntry
329 let curr: *NxJournalEntry = (j.entries[k]) as *NxJournalEntry
330 _journal_entry_hash(prev, pw0, pw1, pw2, pw3)
331 if curr.prev_w0 != *pw0 { return k }
332 if curr.prev_w1 != *pw1 { return k }
333 if curr.prev_w2 != *pw2 { return k }
334 if curr.prev_w3 != *pw3 { return k }
335 k = k + 1
336 }
337 return -1
338}
339
340// ===== Count + latest-hash ======================================
341func nx_journal_log_count(j: *NxJournalLog) -> i64 {
342 if nx_journal_log_is_valid(j) != 1 { return -1 }
343 return j.n_entries
344}
345
346// Writes the entry_hash of the latest entry into out_w*. Returns
347// 0 on success, NX_JOURNAL_NOT_FOUND on empty log.
348func nx_journal_log_latest_hash(j: *NxJournalLog,
349 out_w0: *i64, out_w1: *i64,
350 out_w2: *i64, out_w3: *i64) -> i64 {
351 if nx_journal_log_is_valid(j) != 1 { return NX_JOURNAL_TAMPER }
352 if j.n_entries == 0 { return NX_JOURNAL_NOT_FOUND }
353 let e: *NxJournalEntry = (j.entries[j.n_entries - 1]) as *NxJournalEntry
354 _journal_entry_hash(e, out_w0, out_w1, out_w2, out_w3)
355 return NX_JOURNAL_OK
356}