⎇ be3a0e78fc894587313cb5e23631daf89ce52976
crates/modules/nakui/core/tests/schema_versioning.rs
History
Who touched what
raw
refactor(nakui-core): KCL → Nickel — kcl_wrapper reemplazado por evaluación in-process
1//! Schema versioning: every logged morphism carries a `schema_hash` that
2//! pins it to the (kcl + manifest spec + rhai) bundle active at write
3//! time. `verify_log` rejects logs whose entries were produced under
4//! rules that no longer match the loaded executor.
5//!
6//! The tests here build a *temp copy* of the treasury module so we can
7//! mutate its files without polluting the source tree. Each test cleans
8//! its temp dir even if it panics (the helper drops via `TempModule`).
9
10use std::path::{Path, PathBuf};
11
12use nakui_core::event_log::{
13 EventLog, LogEntry, VerifyError, execute_and_log, replay, seed_and_log, verify_log,
14};
15use nakui_core::executor::Executor;
16use nakui_core::store::MemoryStore;
17use serde_json::{Value, json};
18use uuid::Uuid;
19
20fn workspace_root() -> PathBuf {
21 Path::new(env!("CARGO_MANIFEST_DIR"))
22 .parent()
23 .expect("workspace root above core/")
24 .to_path_buf()
25}
26
27fn treasury_module() -> PathBuf {
28 workspace_root().join("modules/treasury")
29}
30
31fn fresh_log_path() -> PathBuf {
32 std::env::temp_dir().join(format!("nakui_schema_{}.jsonl", Uuid::new_v4()))
33}
34
35/// Owned temp copy of a module directory. Drops the entire tree.
36struct TempModule {
37 pub path: PathBuf,
38}
39
40impl TempModule {
41 fn from(src: &Path) -> Self {
42 let dst = std::env::temp_dir().join(format!("nakui_module_{}", Uuid::new_v4()));
43 copy_dir_recursive(src, &dst).expect("copy module");
44 Self { path: dst }
45 }
46}
47
48impl Drop for TempModule {
49 fn drop(&mut self) {
50 let _ = std::fs::remove_dir_all(&self.path);
51 }
52}
53
54fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
55 std::fs::create_dir_all(dst)?;
56 for entry in std::fs::read_dir(src)? {
57 let entry = entry?;
58 let ty = entry.file_type()?;
59 let dst_path = dst.join(entry.file_name());
60 if ty.is_dir() {
61 copy_dir_recursive(&entry.path(), &dst_path)?;
62 } else {
63 std::fs::copy(entry.path(), &dst_path)?;
64 }
65 }
66 Ok(())
67}
68
69fn deposit_5k(
70 exec: &Executor,
71 store: &mut MemoryStore,
72 log: &mut EventLog,
73 caja: Uuid,
74) {
75 execute_and_log(
76 exec,
77 store,
78 log,
79 "register_cash_move",
80 &[("caja", caja)],
81 json!({
82 "monto": 5_000_i64,
83 "tipo": "in",
84 "timestamp": "2026-05-04T10:00:00Z",
85 "memo": "x",
86 "movimiento_id": Uuid::new_v4().to_string(),
87 }),
88 )
89 .expect("deposit");
90}
91
92fn seed_caja(exec: &Executor, store: &mut MemoryStore, log: &mut EventLog, id: Uuid) {
93 seed_and_log(
94 exec,
95 store,
96 log,
97 "Caja",
98 id,
99 json!({"id": id.to_string(), "name": "A", "saldo": 100_000_i64, "currency": "USD"}),
100 )
101 .unwrap();
102}
103
104#[test]
105fn executor_exposes_per_morphism_schema_hash() {
106 let exec = Executor::load_module(treasury_module()).expect("load");
107 let h_deposit = exec
108 .schema_hash("register_cash_move")
109 .expect("register_cash_move has a hash");
110 let h_transfer = exec
111 .schema_hash("transfer_between_cajas")
112 .expect("transfer_between_cajas has a hash");
113 assert_ne!(
114 h_deposit, h_transfer,
115 "different morphisms must have different hashes"
116 );
117 assert!(
118 exec.schema_hash("not_a_real_morphism").is_none(),
119 "unknown morphisms have no hash"
120 );
121
122 // Re-loading the same module yields the same hashes — the contract
123 // depends only on the bytes on disk, not load-time state.
124 let exec2 = Executor::load_module(treasury_module()).expect("reload");
125 assert_eq!(exec.schema_hash("register_cash_move"), exec2.schema_hash("register_cash_move"));
126}
127
128#[test]
129fn execute_and_log_writes_schema_hash_into_entries() {
130 let temp = TempModule::from(&treasury_module());
131 let exec = Executor::load_module(&temp.path).expect("load");
132 let log_path = fresh_log_path();
133 let mut log = EventLog::open(&log_path).unwrap();
134 let mut store = MemoryStore::new();
135
136 let a = Uuid::new_v4();
137 seed_caja(&exec, &mut store, &mut log, a);
138 deposit_5k(&exec, &mut store, &mut log, a);
139
140 let entries = log.entries().unwrap();
141 let morphism_entry = entries
142 .iter()
143 .find_map(|e| match e {
144 LogEntry::Morphism { schema_hash, .. } => Some(*schema_hash),
145 _ => None,
146 })
147 .expect("morphism entry present");
148 assert_eq!(
149 morphism_entry,
150 Some(exec.schema_hash("register_cash_move").unwrap()),
151 "logged hash must equal the executor's hash for that morphism"
152 );
153
154 let _ = std::fs::remove_file(&log_path);
155}
156
157#[test]
158fn verify_log_passes_when_module_is_unchanged() {
159 let temp = TempModule::from(&treasury_module());
160 let exec = Executor::load_module(&temp.path).expect("load");
161 let log_path = fresh_log_path();
162 let mut log = EventLog::open(&log_path).unwrap();
163 let mut store = MemoryStore::new();
164
165 let a = Uuid::new_v4();
166 seed_caja(&exec, &mut store, &mut log, a);
167 deposit_5k(&exec, &mut store, &mut log, a);
168
169 verify_log(&log, &exec).expect("clean module → verify ok");
170
171 let _ = std::fs::remove_file(&log_path);
172}
173
174#[test]
175fn verify_log_rejects_log_after_morphism_script_changes() {
176 let temp = TempModule::from(&treasury_module());
177
178 // Write a log under the original script.
179 let log_path = fresh_log_path();
180 let a = Uuid::new_v4();
181 let original_hash;
182 {
183 let exec = Executor::load_module(&temp.path).expect("load v1");
184 original_hash = exec.schema_hash("register_cash_move").unwrap();
185 let mut log = EventLog::open(&log_path).unwrap();
186 let mut store = MemoryStore::new();
187 seed_caja(&exec, &mut store, &mut log, a);
188 deposit_5k(&exec, &mut store, &mut log, a);
189 }
190
191 // Mutate the script with a real (non-cosmetic) change — prepend a
192 // new statement. The normalizer preserves this since it changes
193 // tokens, not just whitespace/comments.
194 let script_path = temp.path.join("morphisms/register_cash_move.rhai");
195 let original = std::fs::read_to_string(&script_path).expect("read script");
196 std::fs::write(
197 &script_path,
198 format!("let _audit_marker = 42;\n{}", original),
199 )
200 .expect("write script");
201
202 // Reload — the hash for register_cash_move must change.
203 let exec2 = Executor::load_module(&temp.path).expect("reload v2");
204 let new_hash = exec2.schema_hash("register_cash_move").unwrap();
205 assert_ne!(original_hash, new_hash, "real source edit must move the hash");
206
207 // verify_log must surface SchemaMismatch, not OpsMismatch — the
208 // schema check runs first because "rules changed" is more
209 // actionable than "ops differ for some reason."
210 let log = EventLog::open(&log_path).unwrap();
211 match verify_log(&log, &exec2) {
212 Err(VerifyError::SchemaMismatch {
213 morphism,
214 logged,
215 current,
216 ..
217 }) => {
218 assert_eq!(morphism, "register_cash_move");
219 assert_eq!(logged, original_hash);
220 assert_eq!(current, new_hash);
221 }
222 other => panic!("expected SchemaMismatch, got {:?}", other),
223 }
224
225 // Replay still works — it doesn't validate against the executor.
226 let replayed = replay(&log).expect("replay is schema-agnostic");
227 assert!(replayed.records().contains_key("Caja"));
228
229 let _ = std::fs::remove_file(&log_path);
230}
231
232#[test]
233fn legacy_log_without_schema_hash_still_replays_and_verifies() {
234 // Hand-craft a log entry that omits schema_hash entirely — what an
235 // older nakui-core would have written. The Option default lets it
236 // deserialize, replay walks ops the normal way, and verify_log
237 // skips the schema check because the entry predates the contract.
238 let log_path = fresh_log_path();
239 let a = Uuid::new_v4();
240 {
241 let exec = Executor::load_module(treasury_module()).expect("load");
242 let mut log = EventLog::open(&log_path).unwrap();
243 let mut store = MemoryStore::new();
244 seed_caja(&exec, &mut store, &mut log, a);
245 // Now write a Morphism entry by hand, bypassing execute_and_log,
246 // simulating a log produced by an older binary.
247 let entry: Value = json!({
248 "kind": "morphism",
249 "seq": log.next_seq(),
250 "morphism": "register_cash_move",
251 "inputs": {"caja": a.to_string()},
252 "params": {
253 "monto": 5_000,
254 "tipo": "in",
255 "timestamp": "2026-05-04T10:00:00Z",
256 "memo": "legacy",
257 "movimiento_id": Uuid::new_v4().to_string(),
258 },
259 "ops": []
260 // NOTE: no schema_hash field — that's the legacy shape.
261 });
262 // Append via raw IO to skip log.append's monotonic check (which
263 // we trivially satisfy anyway since seq is correct).
264 let line = serde_json::to_string(&entry).unwrap();
265 let mut f = std::fs::OpenOptions::new()
266 .append(true)
267 .open(&log_path)
268 .unwrap();
269 use std::io::Write;
270 f.write_all(line.as_bytes()).unwrap();
271 f.write_all(b"\n").unwrap();
272 f.sync_all().unwrap();
273 }
274
275 // Replay must succeed (no schema check).
276 let log = EventLog::open(&log_path).unwrap();
277 let entries = log.entries().expect("entries parse");
278 assert_eq!(entries.len(), 2, "seed + legacy morphism");
279 let legacy = entries
280 .iter()
281 .find_map(|e| match e {
282 LogEntry::Morphism { schema_hash, .. } => Some(*schema_hash),
283 _ => None,
284 })
285 .expect("morphism present");
286 assert!(
287 legacy.is_none(),
288 "legacy entry must deserialize with schema_hash=None"
289 );
290
291 let _ = std::fs::remove_file(&log_path);
292}
293
294#[test]
295fn executor_exposes_schema_bundle_hash() {
296 let exec1 = Executor::load_module(treasury_module()).expect("load 1");
297 let exec2 = Executor::load_module(treasury_module()).expect("load 2");
298 assert_eq!(
299 exec1.schema_bundle_hash, exec2.schema_bundle_hash,
300 "bundle hash must be stable across re-loads of the same module"
301 );
302
303 // The bundle hash and the per-morphism hash live in different
304 // tag namespaces (`nakui-bundle-v1` vs `nakui-schema-v1`), so they
305 // can't accidentally collide even when the script bytes are
306 // empty/identical.
307 let morph_hash = exec1.schema_hash("register_cash_move").unwrap();
308 assert_ne!(exec1.schema_bundle_hash, morph_hash);
309}
310
311#[test]
312fn seed_and_log_writes_bundle_hash_into_seed_entries() {
313 let exec = Executor::load_module(treasury_module()).expect("load");
314 let log_path = fresh_log_path();
315 let mut log = EventLog::open(&log_path).unwrap();
316 let mut store = MemoryStore::new();
317 let id = Uuid::new_v4();
318 seed_caja(&exec, &mut store, &mut log, id);
319
320 let entries = log.entries().unwrap();
321 let seed_hash = entries
322 .iter()
323 .find_map(|e| match e {
324 LogEntry::Seed { schema_hash, .. } => Some(*schema_hash),
325 _ => None,
326 })
327 .expect("seed entry present");
328 assert_eq!(
329 seed_hash,
330 Some(exec.schema_bundle_hash),
331 "logged seed hash must equal the executor's bundle hash"
332 );
333
334 let _ = std::fs::remove_file(&log_path);
335}
336
337#[test]
338fn verify_log_rejects_seed_after_schema_kcl_changes() {
339 let temp = TempModule::from(&treasury_module());
340 let log_path = fresh_log_path();
341 let id = Uuid::new_v4();
342 let original_hash;
343 {
344 let exec = Executor::load_module(&temp.path).expect("load v1");
345 original_hash = exec.schema_bundle_hash;
346 let mut log = EventLog::open(&log_path).unwrap();
347 let mut store = MemoryStore::new();
348 seed_caja(&exec, &mut store, &mut log, id);
349 }
350
351 // Mutate schema.ncl. Even a comment is enough — bundle hash is byte-
352 // level for the same false-positive-over-false-negative reason as
353 // morphism hashes.
354 let schema_path = temp.path.join("schema.ncl");
355 let original = std::fs::read_to_string(&schema_path).expect("read schema");
356 std::fs::write(
357 &schema_path,
358 format!("{}\n# seed-versioning-test mutation\n", original),
359 )
360 .expect("write schema");
361
362 let exec2 = Executor::load_module(&temp.path).expect("reload v2");
363 let new_hash = exec2.schema_bundle_hash;
364 assert_ne!(original_hash, new_hash, "schema.ncl byte change must move the bundle hash");
365
366 let log = EventLog::open(&log_path).unwrap();
367 match verify_log(&log, &exec2) {
368 Err(VerifyError::SeedSchemaMismatch {
369 entity,
370 id: mismatched_id,
371 logged,
372 current,
373 ..
374 }) => {
375 assert_eq!(entity, "Caja");
376 assert_eq!(mismatched_id, id);
377 assert_eq!(logged, original_hash);
378 assert_eq!(current, new_hash);
379 }
380 other => panic!("expected SeedSchemaMismatch, got {:?}", other),
381 }
382
383 let _ = std::fs::remove_file(&log_path);
384}
385
386#[test]
387fn comment_only_edits_do_not_invalidate_the_hash() {
388 // The improvement that motivated the AST-aware normalization:
389 // operators leaving TODOs or whitespace edits in scripts no longer
390 // re-stamps every log entry. Same script behaviour ⇒ same hash.
391 let temp = TempModule::from(&treasury_module());
392 let exec1 = Executor::load_module(&temp.path).expect("load v1");
393 let h1 = exec1.schema_hash("register_cash_move").unwrap();
394
395 let script_path = temp.path.join("morphisms/register_cash_move.rhai");
396 let original = std::fs::read_to_string(&script_path).expect("read");
397 std::fs::write(
398 &script_path,
399 format!(
400 "// new top-level comment\n\n\n{}\n\n// trailing TODO\n/*\n block\n comment\n*/\n",
401 original.replace("// states.caja:", "// states.caja: EDITED COMMENT"),
402 ),
403 )
404 .expect("write");
405
406 let exec2 = Executor::load_module(&temp.path).expect("reload v2");
407 let h2 = exec2.schema_hash("register_cash_move").unwrap();
408 assert_eq!(
409 h1, h2,
410 "comment-only and whitespace-only edits must not move the hash"
411 );
412
413 // Sanity: the bundle hash also stays intact (we didn't touch schema.ncl).
414 assert_eq!(exec1.schema_bundle_hash, exec2.schema_bundle_hash);
415}
416
417#[test]
418fn morphism_script_change_does_not_flag_unrelated_seeds() {
419 // Bundle hash covers schema.ncl only — a .rhai edit moves the
420 // morphism hash but leaves the bundle hash alone. So existing
421 // seeds verify cleanly even when a morphism's behaviour changed.
422 let temp = TempModule::from(&treasury_module());
423 let log_path = fresh_log_path();
424 let id = Uuid::new_v4();
425 {
426 let exec = Executor::load_module(&temp.path).expect("load v1");
427 let mut log = EventLog::open(&log_path).unwrap();
428 let mut store = MemoryStore::new();
429 seed_caja(&exec, &mut store, &mut log, id);
430 // No morphism executed — only the seed is in the log.
431 }
432
433 // Modify a Rhai script. Bundle stays the same.
434 let script_path = temp.path.join("morphisms/register_cash_move.rhai");
435 let original = std::fs::read_to_string(&script_path).expect("read");
436 std::fs::write(&script_path, format!("{}\n// rhai-only mutation\n", original)).unwrap();
437
438 let exec2 = Executor::load_module(&temp.path).expect("reload");
439 let log = EventLog::open(&log_path).unwrap();
440 verify_log(&log, &exec2)
441 .expect("seed-only log should pass verify after a morphism-only change");
442
443 let _ = std::fs::remove_file(&log_path);
444}