tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definitions

⎇ be3a0e78fc894587313cb5e23631daf89ce52976 crates/modules/nakui/core/tests/schema_versioning.rs Rust · 444 lines · 15.3 KB History Who touched what raw

refactor(nakui-core): KCL → Nickel — kcl_wrapper reemplazado por evaluación in-process b05de24c2 · Sergio ·

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}