chore: absorbe nakui (ERP matemático) en modules/nakui

- crates/modules/nakui/core/: el crate nakui-core (4 bins, tests).
  Deps directas (serde, rhai, surrealdb, petgraph, sha2, uuid, tokio,
  thiserror v1) — no convertidas a workspace = true en esta pasada.
- crates/modules/nakui/modules/{inventory,sales,treasury}/: datos
  declarativos del dominio (nsmc.json, schema.k, morphisms/) que el
  crate consume — no son crates.

cargo check -p nakui-core: 0 errores.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-05-08 05:49:58 +00:00
parent 53dbdf0f1d
commit 4d50bfc587
49 changed files with 11953 additions and 40 deletions
+258
View File
@@ -0,0 +1,258 @@
//! End-to-end drift detector: spin up `run_server` against log A, run
//! `check_against_socket` first against the same log (in-sync) and then
//! against a divergent log B (drift detected, with the expected diff
//! list).
//!
//! Same threading inversion as `tests/run.rs`: server on main thread
//! (Executor is `!Send`), client on a worker thread.
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::thread;
use nakui_core::drift::{DriftDiff, check_against_socket};
use nakui_core::event_log::{EventLog, execute_and_log, seed_and_log};
use nakui_core::executor::Executor;
use nakui_core::run::run_server;
use nakui_core::store::MemoryStore;
use serde_json::json;
use uuid::Uuid;
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root above core/")
.to_path_buf()
}
fn treasury_module() -> PathBuf {
workspace_root().join("modules/treasury")
}
fn fresh_log_path() -> PathBuf {
std::env::temp_dir().join(format!("nakui_drift_log_{}.jsonl", Uuid::new_v4()))
}
fn fresh_socket_path() -> PathBuf {
std::env::temp_dir().join(format!("nakui_drift_{}.sock", Uuid::new_v4()))
}
/// Build a real WAL-formed log: two cajas seeded + one deposit.
fn build_log_a(path: &Path, caja_a: Uuid, caja_b: Uuid) {
let executor = Executor::load_module(treasury_module()).expect("load");
let mut log = EventLog::open(path).expect("open log");
let mut store = MemoryStore::new();
seed_and_log(
&executor,
&mut store,
&mut log,
"Caja",
caja_a,
json!({"id": caja_a.to_string(), "name": "A", "saldo": 100_000_i64, "currency": "USD"}),
)
.unwrap();
seed_and_log(
&executor,
&mut store,
&mut log,
"Caja",
caja_b,
json!({"id": caja_b.to_string(), "name": "B", "saldo": 50_000_i64, "currency": "USD"}),
)
.unwrap();
execute_and_log(
&executor,
&mut store,
&mut log,
"register_cash_move",
&[("caja", caja_a)],
json!({
"monto": 5_000_i64,
"tipo": "in",
"timestamp": "2026-05-04T10:00:00Z",
"memo": "x",
"movimiento_id": Uuid::new_v4().to_string(),
}),
)
.unwrap();
}
/// Build a divergent log: only caja_a seeded, no deposit, no caja_b.
/// Replaying B produces a different state than the server (which used A).
fn build_log_b(path: &Path, caja_a: Uuid) {
let executor = Executor::load_module(treasury_module()).expect("load");
let mut log = EventLog::open(path).expect("open log b");
let mut store = MemoryStore::new();
seed_and_log(
&executor,
&mut store,
&mut log,
"Caja",
caja_a,
json!({"id": caja_a.to_string(), "name": "A", "saldo": 100_000_i64, "currency": "USD"}),
)
.unwrap();
}
/// Wait for the socket to exist and be connectable, then return a
/// connected stream. Used by helpers that send raw requests bypassing
/// `check_against_socket` (e.g. shutdown).
fn connect_with_retry(path: &Path) -> UnixStream {
for _ in 0..100 {
if let Ok(s) = UnixStream::connect(path) {
return s;
}
thread::sleep(std::time::Duration::from_millis(20));
}
panic!("server never started accepting on {}", path.display());
}
fn send_shutdown(socket_path: &Path) {
let mut stream = connect_with_retry(socket_path);
stream.write_all(b"{\"op\":\"shutdown\"}\n").unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut line = String::new();
reader.read_line(&mut line).unwrap();
}
#[test]
fn drift_check_reports_in_sync_when_log_matches_server() {
let log_path = fresh_log_path();
let socket_path = fresh_socket_path();
let caja_a = Uuid::new_v4();
let caja_b = Uuid::new_v4();
build_log_a(&log_path, caja_a, caja_b);
let executor = Executor::load_module(treasury_module()).expect("load");
let log = EventLog::open(&log_path).expect("reopen");
let store = MemoryStore::new();
let socket_for_client = socket_path.clone();
let log_for_client = log_path.clone();
let client = thread::spawn(move || -> Result<(), String> {
let report = check_against_socket(&log_for_client, &socket_for_client)
.map_err(|e| format!("check failed: {}", e))?;
if !report.in_sync() {
return Err(format!(
"expected in_sync, got {} diffs: {:?}",
report.diffs.len(),
report.diffs
));
}
if report.log_hash != report.server_hash {
return Err("hashes diverged with empty diff — invariant broken".into());
}
if report.log_records != report.server_records {
return Err(format!(
"record count diverged: log={} server={}",
report.log_records, report.server_records
));
}
send_shutdown(&socket_for_client);
Ok(())
});
run_server(executor, log, store, None, &socket_path).expect("server clean exit");
client.join().unwrap().expect("client assertions");
let _ = std::fs::remove_file(&log_path);
}
#[test]
fn drift_check_surfaces_expected_per_record_diffs() {
let log_a_path = fresh_log_path();
let log_b_path = fresh_log_path();
let socket_path = fresh_socket_path();
let caja_a = Uuid::new_v4();
let caja_b = Uuid::new_v4();
build_log_a(&log_a_path, caja_a, caja_b);
build_log_b(&log_b_path, caja_a);
let executor = Executor::load_module(treasury_module()).expect("load");
let log = EventLog::open(&log_a_path).expect("reopen");
let store = MemoryStore::new();
let socket_for_client = socket_path.clone();
let log_b_for_client = log_b_path.clone();
let client = thread::spawn(move || -> Result<(), String> {
// Server is running log A's state; we audit using log B's
// canonical view. Expected diffs:
// - Caja caja_a: tampered (B says saldo=100_000, server has 105_000 from deposit)
// - Caja caja_b: only_on_server (B never seeded it)
// - Movimiento <some uuid>: only_on_server (B never executed the deposit)
let report = check_against_socket(&log_b_for_client, &socket_for_client)
.map_err(|e| format!("check failed: {}", e))?;
if report.in_sync() {
return Err("expected drift, got in_sync".into());
}
let mut tampered = 0;
let mut only_on_server = 0;
let mut only_in_log = 0;
let mut tampered_caja_a = false;
let mut server_extra_caja_b = false;
let mut server_extra_movimiento = false;
for d in &report.diffs {
match d {
DriftDiff::Tampered {
entity,
id,
log_value,
server_value,
} => {
tampered += 1;
if entity == "Caja" && *id == caja_a {
tampered_caja_a = true;
if log_value["saldo"] != json!(100_000_i64) {
return Err(format!("log saldo wrong: {}", log_value));
}
if server_value["saldo"] != json!(105_000_i64) {
return Err(format!("server saldo wrong: {}", server_value));
}
}
}
DriftDiff::OnlyOnServer { entity, id, .. } => {
only_on_server += 1;
if entity == "Caja" && *id == caja_b {
server_extra_caja_b = true;
}
if entity == "Movimiento" {
server_extra_movimiento = true;
}
}
DriftDiff::OnlyInLog { .. } => only_in_log += 1,
}
}
if tampered != 1 {
return Err(format!("expected 1 tampered, got {}", tampered));
}
if only_on_server != 2 {
return Err(format!("expected 2 only_on_server, got {}", only_on_server));
}
if only_in_log != 0 {
return Err(format!("expected 0 only_in_log, got {}", only_in_log));
}
if !tampered_caja_a {
return Err("expected tampered diff for caja_a".into());
}
if !server_extra_caja_b {
return Err("expected only_on_server diff for caja_b".into());
}
if !server_extra_movimiento {
return Err("expected only_on_server diff for some Movimiento".into());
}
send_shutdown(&socket_for_client);
Ok(())
});
run_server(executor, log, store, None, &socket_path).expect("server clean exit");
client.join().unwrap().expect("client assertions");
let _ = std::fs::remove_file(&log_a_path);
let _ = std::fs::remove_file(&log_b_path);
}
@@ -0,0 +1,620 @@
//! Integration tests for the event log: round-trip persistence,
//! replay-equivalence with the live store, and determinism verification.
use std::path::{Path, PathBuf};
use nakui_core::delta::FieldOp;
use nakui_core::event_log::{
EventLog, ExecuteError, LogEntry, RecoverableExecuteError, Snapshot, execute_and_log,
execute_and_log_with_recovery, reconcile, replay, replay_with_snapshot_into, seed_and_log,
verify_log,
};
use nakui_core::executor::Executor;
use nakui_core::store::{MemoryStore, Store, StoreError};
use serde_json::{Value, json};
use uuid::Uuid;
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root above core/")
.to_path_buf()
}
fn treasury_module() -> PathBuf {
workspace_root().join("modules/treasury")
}
fn fresh_log_path() -> PathBuf {
std::env::temp_dir().join(format!("nakui_test_{}.jsonl", Uuid::new_v4()))
}
#[test]
fn replay_reconstructs_live_store() {
let exec = Executor::load_module(treasury_module()).expect("load module");
let log_path = fresh_log_path();
let mut log = EventLog::open(&log_path).expect("open log");
let mut live = MemoryStore::new();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
seed_and_log(
&exec,
&mut live,
&mut log,
"Caja",
a,
json!({
"id": a.to_string(), "name": "A", "saldo": 200_000_i64, "currency": "USD",
}),
)
.unwrap();
seed_and_log(
&exec,
&mut live,
&mut log,
"Caja",
b,
json!({
"id": b.to_string(), "name": "B", "saldo": 50_000_i64, "currency": "USD",
}),
)
.unwrap();
let mov_id = Uuid::new_v4();
execute_and_log(
&exec,
&mut live,
&mut log,
"register_cash_move",
&[("caja", a)],
json!({
"monto": 25_000_i64, "tipo": "in",
"timestamp": "2026-05-04T10:00:00Z", "memo": "x",
"movimiento_id": mov_id.to_string(),
}),
)
.unwrap();
let xfer_id = Uuid::new_v4();
execute_and_log(
&exec,
&mut live,
&mut log,
"transfer_between_cajas",
&[("source", a), ("dest", b)],
json!({
"monto": 75_000_i64,
"timestamp": "2026-05-04T10:30:00Z", "memo": "xf",
"transfer_id": xfer_id.to_string(),
}),
)
.unwrap();
// Failed morphism — should NOT be logged.
let attempt = execute_and_log(
&exec,
&mut live,
&mut log,
"transfer_between_cajas",
&[("source", a), ("dest", b)],
json!({
"monto": 999_999_999_i64,
"timestamp": "2026-05-04T10:45:00Z", "memo": "overdraw",
"transfer_id": Uuid::new_v4().to_string(),
}),
);
assert!(matches!(attempt, Err(ExecuteError::PreLog(_))));
let replayed = replay(&log).expect("replay");
assert_eq!(live, replayed, "replayed store must equal live store");
// Failed attempt left no trace in the log.
let entries = log.entries().unwrap();
assert_eq!(
entries.len(),
4,
"2 seeds + 2 successful morphisms = 4 entries; got {}",
entries.len()
);
let _ = std::fs::remove_file(&log_path);
}
#[test]
fn verify_log_passes_for_deterministic_morphisms() {
let exec = Executor::load_module(treasury_module()).expect("load module");
let log_path = fresh_log_path();
let mut log = EventLog::open(&log_path).expect("open log");
let mut live = MemoryStore::new();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
seed_and_log(
&exec,
&mut live,
&mut log,
"Caja",
a,
json!({"id": a.to_string(), "name": "A", "saldo": 200_000_i64, "currency": "USD"}),
)
.unwrap();
seed_and_log(
&exec,
&mut live,
&mut log,
"Caja",
b,
json!({"id": b.to_string(), "name": "B", "saldo": 50_000_i64, "currency": "USD"}),
)
.unwrap();
execute_and_log(
&exec,
&mut live,
&mut log,
"transfer_between_cajas",
&[("source", a), ("dest", b)],
json!({
"monto": 25_000_i64,
"timestamp": "2026-05-04T11:00:00Z", "memo": "v",
"transfer_id": Uuid::new_v4().to_string(),
}),
)
.unwrap();
verify_log(&log, &exec).expect("re-execution must produce identical ops");
let _ = std::fs::remove_file(&log_path);
}
/// Store wrapper that passes dry_run through to MemoryStore but always
/// fails on apply. Used to simulate a transient backend failure landing
/// AFTER the kernel has validated and the log has been written.
struct FailOnApplyStore {
inner: MemoryStore,
}
impl Store for FailOnApplyStore {
fn load(&self, entity: &str, id: Uuid) -> Option<Value> {
self.inner.load(entity, id)
}
fn seed(&mut self, entity: &str, id: Uuid, data: Value) {
self.inner.seed(entity, id, data);
}
fn apply_dry_run(&self, ops: &[FieldOp]) -> Result<(), StoreError> {
self.inner.apply_dry_run(ops)
}
fn apply(&mut self, _ops: &[FieldOp]) -> Result<(), StoreError> {
Err(StoreError::NotFound(
"synthetic_apply_failure".into(),
Uuid::nil(),
))
}
fn clear(&mut self) -> Result<(), StoreError> {
self.inner.clear()
}
fn iter(&self) -> Result<Box<dyn Iterator<Item = (String, Uuid, Value)> + '_>, StoreError> {
self.inner.iter()
}
}
#[test]
fn post_log_store_failure_leaves_log_canonical() {
let exec = Executor::load_module(treasury_module()).expect("load module");
let log_path = fresh_log_path();
let mut log = EventLog::open(&log_path).expect("open log");
// Seed the inner store directly (no logging — we're simulating the
// backend independently of the log).
let mut inner = MemoryStore::new();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
inner.seed(
"Caja",
a,
json!({"id": a.to_string(), "name": "A", "saldo": 200_000_i64, "currency": "USD"}),
);
inner.seed(
"Caja",
b,
json!({"id": b.to_string(), "name": "B", "saldo": 50_000_i64, "currency": "USD"}),
);
let mut store = FailOnApplyStore { inner };
let result = execute_and_log(
&exec,
&mut store,
&mut log,
"transfer_between_cajas",
&[("source", a), ("dest", b)],
json!({
"monto": 25_000_i64,
"timestamp": "2026-05-04T11:00:00Z",
"memo": "wal-test",
"transfer_id": Uuid::new_v4().to_string(),
}),
);
match result {
Err(ExecuteError::PostLogStore(_)) => {}
other => panic!("expected PostLogStore, got {:?}", other),
}
// Log is canonical: the morphism event is durable.
let entries = log.entries().expect("read");
assert_eq!(entries.len(), 1, "log must contain the morphism event");
assert!(matches!(&entries[0], LogEntry::Morphism { .. }));
// Live store is stale: apply was rejected, so saldos are unchanged.
assert_eq!(
store.load("Caja", a).unwrap().get("saldo").unwrap().as_i64(),
Some(200_000)
);
assert_eq!(
store.load("Caja", b).unwrap().get("saldo").unwrap().as_i64(),
Some(50_000)
);
let _ = std::fs::remove_file(&log_path);
}
#[test]
fn reopen_log_resumes_from_correct_seq() {
let exec = Executor::load_module(treasury_module()).expect("load module");
let log_path = fresh_log_path();
let a = Uuid::new_v4();
{
let mut log = EventLog::open(&log_path).unwrap();
let mut store = MemoryStore::new();
seed_and_log(
&exec,
&mut store,
&mut log,
"Caja",
a,
json!({"id": a.to_string(), "name": "A", "saldo": 100_i64, "currency": "USD"}),
)
.unwrap();
assert_eq!(log.next_seq(), 1);
}
{
let log = EventLog::open(&log_path).unwrap();
assert_eq!(log.next_seq(), 1, "next_seq must persist across reopens");
let entries = log.entries().unwrap();
assert_eq!(entries.len(), 1);
assert!(matches!(&entries[0], LogEntry::Seed { seq: 0, .. }));
}
let _ = std::fs::remove_file(&log_path);
}
#[test]
fn snapshot_plus_log_tail_replays_to_same_state() {
let exec = Executor::load_module(treasury_module()).expect("load");
let log_path = fresh_log_path();
let snap_path = log_path.with_extension("snap");
let mut log = EventLog::open(&log_path).expect("open");
let mut live = MemoryStore::new();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
seed_and_log(
&exec,
&mut live,
&mut log,
"Caja",
a,
json!({"id": a.to_string(), "name": "A", "saldo": 200_000_i64, "currency": "USD"}),
)
.unwrap();
seed_and_log(
&exec,
&mut live,
&mut log,
"Caja",
b,
json!({"id": b.to_string(), "name": "B", "saldo": 50_000_i64, "currency": "USD"}),
)
.unwrap();
execute_and_log(
&exec,
&mut live,
&mut log,
"register_cash_move",
&[("caja", a)],
json!({
"monto": 25_000_i64, "tipo": "in",
"timestamp": "2026-05-04T10:00:00Z", "memo": "before-snap",
"movimiento_id": Uuid::new_v4().to_string(),
}),
)
.unwrap();
// Take snapshot at this point: seq 0 (seed A), 1 (seed B), 2 (deposit)
// are reflected in `live`. Next event will be seq 3.
let snap = Snapshot::from_memory_store(&live, log.next_seq() - 1);
snap.write(&snap_path).expect("write snapshot");
assert_eq!(snap.seq, 2);
// More events after the snapshot.
execute_and_log(
&exec,
&mut live,
&mut log,
"transfer_between_cajas",
&[("source", a), ("dest", b)],
json!({
"monto": 75_000_i64,
"timestamp": "2026-05-04T10:30:00Z", "memo": "after-snap",
"transfer_id": Uuid::new_v4().to_string(),
}),
)
.unwrap();
// Replay from snapshot + log tail; must equal live store.
let loaded_snap = Snapshot::load(&snap_path).expect("load").expect("present");
let mut replayed = MemoryStore::new();
replay_with_snapshot_into(&log, Some(&loaded_snap), &mut replayed).expect("replay");
assert_eq!(live, replayed, "snapshot + tail must equal full replay");
let _ = std::fs::remove_file(&log_path);
let _ = std::fs::remove_file(&snap_path);
}
#[test]
fn compact_through_drops_old_entries_keeps_seq() {
let exec = Executor::load_module(treasury_module()).expect("load module");
let log_path = fresh_log_path();
let mut log = EventLog::open(&log_path).expect("open");
let mut live = MemoryStore::new();
for i in 0..5 {
let id = Uuid::new_v4();
seed_and_log(
&exec,
&mut live,
&mut log,
"Caja",
id,
json!({"id": id.to_string(), "name": format!("c{}", i), "saldo": 100_i64, "currency": "USD"}),
)
.unwrap();
}
assert_eq!(log.next_seq(), 5);
assert_eq!(log.entries().unwrap().len(), 5);
// Compact through seq 2: entries 0,1,2 are dropped; 3,4 remain.
log.compact_through(2).expect("compact");
let surviving = log.entries().unwrap();
assert_eq!(surviving.len(), 2);
assert_eq!(surviving[0].seq(), 3);
assert_eq!(surviving[1].seq(), 4);
// next_seq stays at 5 — we kept the surviving entries' counter intact.
// (Reopen to confirm the persisted log roundtrips this.)
drop(log);
let reopened = EventLog::open(&log_path).expect("reopen after compact");
assert_eq!(reopened.next_seq(), 5);
let _ = std::fs::remove_file(&log_path);
}
#[test]
fn snapshot_then_compact_then_replay_equals_pre_compaction() {
let exec = Executor::load_module(treasury_module()).expect("load");
let log_path = fresh_log_path();
let snap_path = log_path.with_extension("snap");
let mut log = EventLog::open(&log_path).expect("open");
let mut live = MemoryStore::new();
let a = Uuid::new_v4();
seed_and_log(
&exec,
&mut live,
&mut log,
"Caja",
a,
json!({"id": a.to_string(), "name": "A", "saldo": 1_000_i64, "currency": "USD"}),
)
.unwrap();
for i in 0..3 {
execute_and_log(
&exec,
&mut live,
&mut log,
"register_cash_move",
&[("caja", a)],
json!({
"monto": 100_i64, "tipo": "in",
"timestamp": format!("2026-05-04T10:0{}:00Z", i), "memo": "x",
"movimiento_id": Uuid::new_v4().to_string(),
}),
)
.unwrap();
}
// Snapshot at seq 3 (1 seed + 3 morphisms = seqs 0..=3).
let snap = Snapshot::from_memory_store(&live, log.next_seq() - 1);
snap.write(&snap_path).expect("write snap");
log.compact_through(snap.seq).expect("compact");
// After compaction: log has 0 entries (all subsumed). next_seq = 4.
assert_eq!(log.entries().unwrap().len(), 0);
// More events after compaction.
execute_and_log(
&exec,
&mut live,
&mut log,
"register_cash_move",
&[("caja", a)],
json!({
"monto": 500_i64, "tipo": "in",
"timestamp": "2026-05-04T11:00:00Z", "memo": "post-compact",
"movimiento_id": Uuid::new_v4().to_string(),
}),
)
.unwrap();
// Reconstruct from snapshot + remaining log.
let loaded_snap = Snapshot::load(&snap_path).unwrap().unwrap();
let mut replayed = MemoryStore::new();
replay_with_snapshot_into(&log, Some(&loaded_snap), &mut replayed).expect("replay");
assert_eq!(live, replayed, "snapshot + post-compact log must equal live");
let _ = std::fs::remove_file(&log_path);
let _ = std::fs::remove_file(&snap_path);
}
#[test]
fn reconcile_rebuilds_drifted_store_from_log() {
let exec = Executor::load_module(treasury_module()).expect("load module");
let log_path = fresh_log_path();
let mut log = EventLog::open(&log_path).expect("open log");
let mut live = MemoryStore::new();
let a = Uuid::new_v4();
seed_and_log(
&exec,
&mut live,
&mut log,
"Caja",
a,
json!({"id": a.to_string(), "name": "A", "saldo": 100_000_i64, "currency": "USD"}),
)
.unwrap();
execute_and_log(
&exec,
&mut live,
&mut log,
"register_cash_move",
&[("caja", a)],
json!({
"monto": 5_000_i64, "tipo": "in",
"timestamp": "2026-05-04T10:00:00Z", "memo": "x",
"movimiento_id": Uuid::new_v4().to_string(),
}),
)
.unwrap();
// Drift the store out-of-band: a poison record nobody logged, plus a
// tampered saldo on the legitimate one.
let ghost = Uuid::new_v4();
live.seed(
"Caja",
ghost,
json!({"id": ghost.to_string(), "name": "GHOST", "saldo": 0, "currency": "USD"}),
);
live.seed(
"Caja",
a,
json!({"id": a.to_string(), "name": "A", "saldo": 999_999_i64, "currency": "USD"}),
);
// Canonical state: replay from log into a clean store.
let canonical = replay(&log).expect("replay");
assert_ne!(live, canonical, "drift was set up to differ from log");
reconcile(&mut live, &log).expect("reconcile");
assert_eq!(live, canonical, "reconcile must restore log-canonical state");
assert!(live.load("Caja", ghost).is_none(), "poison record must be wiped");
let _ = std::fs::remove_file(&log_path);
}
#[test]
fn execute_and_log_with_recovery_succeeds_on_clean_path() {
// The clean path: no apply failure means the wrapper returns the same
// ops as `execute_and_log` and leaves the store consistent.
let exec = Executor::load_module(treasury_module()).expect("load module");
let log_path = fresh_log_path();
let mut log = EventLog::open(&log_path).expect("open log");
let mut store = MemoryStore::new();
let a = Uuid::new_v4();
seed_and_log(
&exec,
&mut store,
&mut log,
"Caja",
a,
json!({"id": a.to_string(), "name": "A", "saldo": 10_000_i64, "currency": "USD"}),
)
.unwrap();
let ops = execute_and_log_with_recovery(
&exec,
&mut store,
&mut log,
"register_cash_move",
&[("caja", a)],
json!({
"monto": 1_000_i64, "tipo": "in",
"timestamp": "2026-05-04T10:00:00Z", "memo": "x",
"movimiento_id": Uuid::new_v4().to_string(),
}),
)
.expect("recovery wrapper");
assert!(!ops.is_empty(), "morphism produced ops");
let replayed = replay(&log).expect("replay");
assert_eq!(store, replayed, "store and log agree on clean path");
let _ = std::fs::remove_file(&log_path);
}
#[test]
fn execute_and_log_with_recovery_reports_unrecoverable_when_replay_also_fails() {
// Apply is permanently broken — reconcile (which replays through apply)
// will also fail. The wrapper must surface `Unrecoverable` so the
// caller knows the store is no longer in sync with the log.
let exec = Executor::load_module(treasury_module()).expect("load module");
let log_path = fresh_log_path();
let mut log = EventLog::open(&log_path).expect("open log");
let mut inner = MemoryStore::new();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
inner.seed(
"Caja",
a,
json!({"id": a.to_string(), "name": "A", "saldo": 200_000_i64, "currency": "USD"}),
);
inner.seed(
"Caja",
b,
json!({"id": b.to_string(), "name": "B", "saldo": 50_000_i64, "currency": "USD"}),
);
let mut store = FailOnApplyStore { inner };
let result = execute_and_log_with_recovery(
&exec,
&mut store,
&mut log,
"transfer_between_cajas",
&[("source", a), ("dest", b)],
json!({
"monto": 25_000_i64,
"timestamp": "2026-05-04T11:00:00Z",
"memo": "recover-fail",
"transfer_id": Uuid::new_v4().to_string(),
}),
);
assert!(
matches!(result, Err(RecoverableExecuteError::Unrecoverable { .. })),
"expected Unrecoverable, got {:?}",
result
);
// The log entry is still canonical: an operator who fixes the backend
// can recover via `nakui replay` later.
let entries = log.entries().expect("read log");
assert_eq!(entries.len(), 1);
assert!(matches!(&entries[0], LogEntry::Morphism { .. }));
let _ = std::fs::remove_file(&log_path);
}
@@ -0,0 +1,19 @@
// EVIL: creates a Movimiento with monto = -1, violating schema.k:
// check: monto > 0
// Schema check on the Created record (KclPostCreate) must reject this.
let mov_id = input.params.mov_id;
[
#{
op: "create",
entity: "Movimiento",
id: mov_id,
data: #{
id: mov_id,
caja_id: input.ids.caja,
monto: -1,
tipo: "in",
timestamp: "2026-05-04T00:00:00Z",
memo: "evil",
},
},
]
@@ -0,0 +1,9 @@
// EVIL: writes to a Caja id that wasn't declared in inputs.
// The phantom id is passed via params to keep the script syntactically valid.
[
#{
op: "set",
path: #{ entity: "Caja", id: input.params.phantom_id, field: "saldo" },
value: 0,
},
]
@@ -0,0 +1,14 @@
// EVIL: subtracts from BOTH cajas. Same currency, so the conservation rule
// (Σ Δ Caja.saldo group_by currency = 0) catches it.
[
#{
op: "set",
path: #{ entity: "Caja", id: input.ids.source, field: "saldo" },
value: input.states.source.saldo - 100,
},
#{
op: "set",
path: #{ entity: "Caja", id: input.ids.dest, field: "saldo" },
value: input.states.dest.saldo - 1,
},
]
@@ -0,0 +1,7 @@
// Deletes its primary input. The kernel must:
// - accept the Delete op (token = "Caja", in writes)
// - skip the per-input KCL post-check (entity no longer exists)
// - allow apply to remove the record cleanly
[
#{ op: "delete", entity: "Caja", id: input.ids.caja },
]
@@ -0,0 +1,10 @@
// EVIL: tries to write `Stock.cantidad` using a Caja's UUID. The id matches
// a tracked role but the entity does not — the capability check must reject
// with a `<entity-mismatch>` token rather than letting it through.
[
#{
op: "set",
path: #{ entity: "Stock", id: input.ids.caja, field: "cantidad" },
value: 0,
},
]
+355
View File
@@ -0,0 +1,355 @@
//! ManifestGraph: cycle detection on `depends_on`, data-flow indexes for
//! `reads`/`writes`, and the `affected_by` query that powers dirty-marking.
use std::path::{Path, PathBuf};
use nakui_core::executor::Executor;
use nakui_core::graph::{DirtyTracker, GraphError, ManifestGraph};
use nakui_core::manifest::{
ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec,
};
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root above core/")
.to_path_buf()
}
fn module(name: &str) -> PathBuf {
workspace_root().join("modules").join(name)
}
fn morphism(name: &str, depends_on: Vec<String>) -> MorphismSpec {
MorphismSpec {
name: name.into(),
inputs: vec![MorphismInput {
role: "caja".into(),
entity: "Caja".into(),
}],
reads: vec!["caja.saldo".into()],
writes: vec!["caja.saldo".into()],
invariants: Invariants::default(),
depends_on,
script: "morphisms/register_cash_move.rhai".into(),
}
}
fn manifest_with(morphisms: Vec<MorphismSpec>) -> Manifest {
Manifest {
module: "graph_test".into(),
schemas: vec![],
morphisms,
}
}
#[test]
fn detects_two_node_cycle() {
let m = manifest_with(vec![
morphism("a", vec!["b".into()]),
morphism("b", vec!["a".into()]),
]);
match ManifestGraph::build(&m) {
Err(GraphError::Cycle(names)) => {
assert!(names.contains(&"a".to_string()));
assert!(names.contains(&"b".to_string()));
}
other => panic!("expected Cycle, got {:?}", other),
}
}
#[test]
fn detects_self_loop() {
let m = manifest_with(vec![morphism("loop", vec!["loop".into()])]);
match ManifestGraph::build(&m) {
Err(GraphError::Cycle(names)) => {
assert_eq!(names, vec!["loop".to_string()]);
}
other => panic!("expected Cycle, got {:?}", other),
}
}
#[test]
fn detects_three_node_cycle() {
let m = manifest_with(vec![
morphism("a", vec!["b".into()]),
morphism("b", vec!["c".into()]),
morphism("c", vec!["a".into()]),
]);
match ManifestGraph::build(&m) {
Err(GraphError::Cycle(names)) => {
assert_eq!(names.len(), 3);
}
other => panic!("expected Cycle, got {:?}", other),
}
}
#[test]
fn topological_order_respects_explicit_dependencies() {
// a <- b <- c (c depends on b depends on a)
let m = manifest_with(vec![
morphism("a", vec![]),
morphism("b", vec!["a".into()]),
morphism("c", vec!["b".into()]),
]);
let g = ManifestGraph::build(&m).expect("acyclic");
let order = g.topological_order();
let pos = |n: &str| order.iter().position(|x| x == n).unwrap();
assert!(pos("a") < pos("b"));
assert!(pos("b") < pos("c"));
}
#[test]
fn unknown_depends_on_target_errors() {
let m = manifest_with(vec![morphism("a", vec!["ghost".into()])]);
match ManifestGraph::build(&m) {
Err(GraphError::UnknownMorphism(name)) => assert_eq!(name, "ghost"),
other => panic!("expected UnknownMorphism, got {:?}", other),
}
}
#[test]
fn treasury_data_flow_indexes_match_manifest() {
let exec = Executor::load_module(module("treasury")).expect("load");
let g = &exec.graph;
// Both register_cash_move and transfer_between_cajas write Caja.saldo.
let mut writers: Vec<&str> = g.writers_of("Caja.saldo").iter().map(|s| s.as_str()).collect();
writers.sort();
assert_eq!(writers, vec!["register_cash_move", "transfer_between_cajas"]);
// Both read Caja.saldo too.
let mut readers: Vec<&str> = g.readers_of("Caja.saldo").iter().map(|s| s.as_str()).collect();
readers.sort();
assert_eq!(readers, vec!["register_cash_move", "transfer_between_cajas"]);
// Movimiento is written only by register_cash_move.
assert_eq!(
g.writers_of("Movimiento"),
&["register_cash_move".to_string()]
);
// Transferencia is written only by transfer_between_cajas.
assert_eq!(
g.writers_of("Transferencia"),
&["transfer_between_cajas".to_string()]
);
// Nothing in treasury reads Movimiento or Transferencia.
assert!(g.readers_of("Movimiento").is_empty());
assert!(g.readers_of("Transferencia").is_empty());
}
#[test]
fn affected_by_excludes_self_and_finds_overlap() {
// A simple two-morphism manifest where one writes what the other reads.
let m = manifest_with(vec![
MorphismSpec {
name: "writer".into(),
inputs: vec![MorphismInput {
role: "caja".into(),
entity: "Caja".into(),
}],
reads: vec![],
writes: vec!["caja.saldo".into()],
invariants: Invariants::default(),
depends_on: vec![],
script: "morphisms/register_cash_move.rhai".into(),
},
MorphismSpec {
name: "reader".into(),
inputs: vec![MorphismInput {
role: "caja".into(),
entity: "Caja".into(),
}],
reads: vec!["caja.saldo".into()],
writes: vec![],
invariants: Invariants::default(),
depends_on: vec![],
script: "morphisms/register_cash_move.rhai".into(),
},
MorphismSpec {
name: "self_loop".into(),
inputs: vec![MorphismInput {
role: "caja".into(),
entity: "Caja".into(),
}],
reads: vec!["caja.saldo".into()],
writes: vec!["caja.saldo".into()],
invariants: Invariants::default(),
depends_on: vec![],
script: "morphisms/register_cash_move.rhai".into(),
},
]);
let g = ManifestGraph::build(&m).expect("acyclic");
let mut affected = g.affected_by("writer");
affected.sort();
// writer writes Caja.saldo; readers are reader + self_loop, but
// self_loop is "writer"? no, self_loop is a separate morphism here,
// and it does read Caja.saldo so it's affected by writer.
assert_eq!(affected, vec!["reader", "self_loop"]);
// self_loop writes its own field but should not list itself.
let affected_self = g.affected_by("self_loop");
assert_eq!(affected_self, vec!["reader"]);
}
#[test]
fn cross_module_graph_canonicalizes_to_entity_tokens() {
// sales/vender uses role "stock" (entity Stock) and role "caja" (entity Caja).
// Reads and writes should canonicalize to "Stock.cantidad" and "Caja.saldo".
let exec = Executor::load_module(module("sales")).expect("load sales");
let g = &exec.graph;
assert_eq!(g.writers_of("Stock.cantidad"), &["vender".to_string()]);
assert_eq!(g.writers_of("Caja.saldo"), &["vender".to_string()]);
assert_eq!(g.writers_of("Venta"), &["vender".to_string()]);
let reads = g.morphism_reads("vender");
assert!(reads.contains(&"Stock.cantidad".to_string()));
assert!(reads.contains(&"Caja.saldo".to_string()));
assert!(reads.contains(&"Caja.currency".to_string()));
}
#[test]
fn executor_load_module_rejects_cyclic_manifest() {
// Synthesize a tempdir with a cyclic manifest and confirm Executor
// surfaces ExecError::Graph rather than running.
let tmp = std::env::temp_dir().join(format!("nakui_cycle_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(tmp.join("morphisms")).unwrap();
std::fs::write(
tmp.join("schema.k"),
"schema Caja:\n saldo: int\n check:\n saldo >= 0\n",
)
.unwrap();
std::fs::write(tmp.join("morphisms/op.rhai"), "[]").unwrap();
std::fs::write(
tmp.join("nsmc.json"),
r#"{
"module": "cycle",
"morphisms": [
{"name": "a", "inputs": [{"role":"caja","entity":"Caja"}],
"reads": [], "writes": ["caja.saldo"], "depends_on": ["b"],
"script": "morphisms/op.rhai"},
{"name": "b", "inputs": [{"role":"caja","entity":"Caja"}],
"reads": [], "writes": ["caja.saldo"], "depends_on": ["a"],
"script": "morphisms/op.rhai"}
]
}"#,
)
.unwrap();
let err = match Executor::load_module(&tmp) {
Ok(_) => panic!("must fail with cycle"),
Err(e) => e,
};
let msg = err.to_string();
assert!(msg.contains("graph") || msg.contains("cycle"),
"expected graph diagnostic, got `{}`", msg);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn dirty_tracker_marks_after_treasury_morphism() {
let exec = Executor::load_module(module("treasury")).expect("load");
let mut tracker = DirtyTracker::new();
// register_cash_move writes Caja.saldo + Movimiento. Both are read by
// transfer_between_cajas (Caja.saldo) but Movimiento is read by no one.
tracker.mark_dirty_after("register_cash_move", &exec.graph);
let dirty = tracker.dirty();
assert!(
dirty.contains(&"transfer_between_cajas".to_string()),
"transfer_between_cajas reads Caja.saldo, must be dirty after deposit; got {:?}",
dirty
);
assert!(
!tracker.is_dirty("register_cash_move"),
"self should not be marked dirty by its own write"
);
}
#[test]
fn dirty_tracker_clear_works() {
let exec = Executor::load_module(module("treasury")).expect("load");
let mut tracker = DirtyTracker::new();
tracker.mark_dirty_after("transfer_between_cajas", &exec.graph);
let count_before = tracker.len();
assert!(count_before > 0);
let first = tracker.dirty().into_iter().next().unwrap();
tracker.clear(&first);
assert!(!tracker.is_dirty(&first));
assert_eq!(tracker.len(), count_before - 1);
}
#[test]
fn dirty_tracker_accumulates_across_morphisms() {
// Manifest with three morphisms where each writes what the next reads.
// After running A then B, both readers should be marked.
let m = manifest_with(vec![
MorphismSpec {
name: "writer_a".into(),
inputs: vec![MorphismInput {
role: "caja".into(),
entity: "Caja".into(),
}],
reads: vec![],
writes: vec!["caja.saldo".into()],
invariants: Invariants::default(),
depends_on: vec![],
script: "morphisms/register_cash_move.rhai".into(),
},
MorphismSpec {
name: "writer_b".into(),
inputs: vec![MorphismInput {
role: "caja".into(),
entity: "Caja".into(),
}],
reads: vec![],
writes: vec!["Movimiento".into()],
invariants: Invariants::default(),
depends_on: vec![],
script: "morphisms/register_cash_move.rhai".into(),
},
MorphismSpec {
name: "reader_caja".into(),
inputs: vec![MorphismInput {
role: "caja".into(),
entity: "Caja".into(),
}],
reads: vec!["caja.saldo".into()],
writes: vec![],
invariants: Invariants::default(),
depends_on: vec![],
script: "morphisms/register_cash_move.rhai".into(),
},
MorphismSpec {
name: "reader_mov".into(),
inputs: vec![MorphismInput {
role: "caja".into(),
entity: "Caja".into(),
}],
reads: vec!["Movimiento".into()],
writes: vec![],
invariants: Invariants::default(),
depends_on: vec![],
script: "morphisms/register_cash_move.rhai".into(),
},
]);
let g = ManifestGraph::build(&m).unwrap();
let mut tracker = DirtyTracker::new();
tracker.mark_dirty_after("writer_a", &g);
assert!(tracker.is_dirty("reader_caja"));
assert!(!tracker.is_dirty("reader_mov"));
tracker.mark_dirty_after("writer_b", &g);
assert!(tracker.is_dirty("reader_caja"));
assert!(tracker.is_dirty("reader_mov"));
assert_eq!(tracker.len(), 2);
}
@@ -0,0 +1,173 @@
//! Inventory module integration tests. The point: prove the kernel is
//! module-agnostic — these tests use the SAME executor code path as
//! treasury, just pointed at a different module dir, and the conservation
//! rule is just declarative (Stock.cantidad group_by sku_id).
use std::path::{Path, PathBuf};
use nakui_core::executor::{ExecError, Executor};
use nakui_core::store::{MemoryStore, Store};
use serde_json::{Value, json};
use uuid::Uuid;
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root above core/")
.to_path_buf()
}
fn inventory_module() -> PathBuf {
workspace_root().join("modules/inventory")
}
fn cantidad(store: &MemoryStore, id: Uuid) -> i64 {
store
.load("Stock", id)
.and_then(|v| v.get("cantidad").and_then(Value::as_i64))
.expect("stock with cantidad")
}
fn seed_stock(store: &mut MemoryStore, id: Uuid, sku: &str, cantidad: i64) {
store.seed(
"Stock",
id,
json!({
"id": id.to_string(),
"sku_id": sku,
"ubicacion": "test-loc",
"cantidad": cantidad,
}),
);
}
#[test]
fn transfer_conserves_units_across_same_sku() {
let exec = Executor::load_module(inventory_module()).expect("load module");
let mut store = MemoryStore::new();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
seed_stock(&mut store, a, "sku-X", 500);
seed_stock(&mut store, b, "sku-X", 100);
let ops = exec
.run(
&mut store,
"transferir_stock",
&[("source", a), ("dest", b)],
json!({
"cantidad": 150_i64,
"timestamp": "2026-05-04T00:00:00Z",
"transfer_id": Uuid::new_v4().to_string(),
}),
)
.expect("transfer must pass");
assert_eq!(ops.len(), 3, "2 sets + 1 create = 3 ops");
assert_eq!(cantidad(&store, a), 350);
assert_eq!(cantidad(&store, b), 250);
// Total preserved.
assert_eq!(cantidad(&store, a) + cantidad(&store, b), 600);
}
#[test]
fn transfer_across_different_skus_is_rejected_by_conservation() {
// Construct a buggy synthetic morphism that mimics transfer but skips
// the in-script same-sku check. We do this by pointing at a fixture
// script that lacks the `throw if source.sku_id != dest.sku_id`.
//
// Without that fixture we can rely on the production script's `throw`
// to fire first — which is itself fine but proves the SCRIPT, not the
// KERNEL. To prove the kernel-level conservation works on inventory,
// see kernel_guards.rs (treasury) — that test exercises the same
// executor logic with Caja.saldo grouped by currency. Here we just
// assert the production script rejects cross-SKU.
let exec = Executor::load_module(inventory_module()).expect("load module");
let mut store = MemoryStore::new();
let a = Uuid::new_v4();
let c = Uuid::new_v4();
seed_stock(&mut store, a, "sku-X", 500);
seed_stock(&mut store, c, "sku-Y", 200);
let result = exec.run(
&mut store,
"transferir_stock",
&[("source", a), ("dest", c)],
json!({
"cantidad": 50_i64,
"timestamp": "2026-05-04T00:00:00Z",
"transfer_id": Uuid::new_v4().to_string(),
}),
);
match result {
Err(ExecError::Rhai(_)) => {}
other => panic!("expected Rhai (script throw on sku mismatch), got {:?}", other),
}
assert_eq!(cantidad(&store, a), 500);
assert_eq!(cantidad(&store, c), 200);
}
#[test]
fn overdraw_transfer_blocked_by_kcl_post_check() {
let exec = Executor::load_module(inventory_module()).expect("load module");
let mut store = MemoryStore::new();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
seed_stock(&mut store, a, "sku-X", 100);
seed_stock(&mut store, b, "sku-X", 0);
let result = exec.run(
&mut store,
"transferir_stock",
&[("source", a), ("dest", b)],
json!({
"cantidad": 999_i64,
"timestamp": "2026-05-04T00:00:00Z",
"transfer_id": Uuid::new_v4().to_string(),
}),
);
match result {
Err(ExecError::KclPost { role, entity, .. }) => {
assert_eq!(role, "source");
assert_eq!(entity, "Stock");
}
other => panic!("expected KclPost on source, got {:?}", other),
}
assert_eq!(cantidad(&store, a), 100);
assert_eq!(cantidad(&store, b), 0);
}
#[test]
fn recibir_increases_stock_and_creates_movimiento() {
let exec = Executor::load_module(inventory_module()).expect("load module");
let mut store = MemoryStore::new();
let a = Uuid::new_v4();
seed_stock(&mut store, a, "sku-X", 100);
let mov_id = Uuid::new_v4();
let ops = exec
.run(
&mut store,
"recibir_stock",
&[("stock", a)],
json!({
"cantidad": 50_i64,
"timestamp": "2026-05-04T00:00:00Z",
"movimiento_id": mov_id.to_string(),
}),
)
.expect("recibir must pass");
assert_eq!(ops.len(), 2, "1 set + 1 create");
assert_eq!(cantidad(&store, a), 150);
assert!(
store.load("MovimientoStock", mov_id).is_some(),
"movimiento must be persisted"
);
}
@@ -0,0 +1,297 @@
//! Regression tests for the kernel's enforcement layers.
//!
//! Each test runs a deliberately-broken morphism that should be rejected by
//! a *specific* layer of the executor pipeline. After every rejection we also
//! assert the store is untouched — the kernel must never half-apply a delta.
//!
//! Layers exercised (in pipeline order):
//! 1. CapabilityViolation (untracked write)
//! 2. ConservationViolation (delta sum != 0)
//! 3. KclPostCreate (created record fails its schema)
use std::path::{Path, PathBuf};
use nakui_core::executor::{ExecError, Executor};
use nakui_core::graph::ManifestGraph;
use nakui_core::manifest::{
ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec,
};
use nakui_core::rhai_executor::RhaiExecutor;
use nakui_core::store::{MemoryStore, Store};
use serde_json::{Value, json};
use uuid::Uuid;
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root above core/")
.to_path_buf()
}
fn fixtures_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
}
fn build_executor(spec: MorphismSpec) -> Executor {
let manifest = Manifest {
module: "kernel_guards_test".into(),
schemas: vec![],
morphisms: vec![spec],
};
let graph = ManifestGraph::build(&manifest).expect("graph builds");
Executor {
manifest,
graph,
// module_dir is where script paths resolve; we point it at fixtures.
module_dir: fixtures_dir(),
// schema_path stays on the real treasury schema so we exercise the
// production check blocks. `owned_bundle: false` so Drop leaves it
// alone — it belongs to the source tree.
schema_path: workspace_root().join("modules/treasury/schema.k"),
rhai: RhaiExecutor::new_sandboxed(),
owned_bundle: false,
// Inline-built executors don't go through `load_module`, so they
// have no schema-hash cache. These guard tests don't write to a
// log, so verify_log never runs against this executor.
schema_hashes: std::collections::HashMap::new(),
schema_bundle_hash: [0u8; 32],
}
}
fn seed_caja(store: &mut MemoryStore, id: Uuid, name: &str, saldo: i64, currency: &str) {
store.seed(
"Caja",
id,
json!({
"id": id.to_string(),
"name": name,
"saldo": saldo,
"currency": currency,
}),
);
}
fn caja_saldo(store: &MemoryStore, id: Uuid) -> i64 {
store
.load("Caja", id)
.and_then(|v| v.get("saldo").and_then(Value::as_i64))
.expect("caja with saldo")
}
#[test]
fn capability_violation_blocks_write_to_untracked_caja() {
let spec = MorphismSpec {
name: "evil_capability".into(),
inputs: vec![MorphismInput {
role: "caja".into(),
entity: "Caja".into(),
}],
reads: vec!["caja.saldo".into()],
writes: vec!["caja.saldo".into()],
invariants: Invariants::default(),
depends_on: vec![],
script: "capability_violation.rhai".into(),
};
let exec = build_executor(spec);
let mut store = MemoryStore::new();
let caja_id = Uuid::new_v4();
let phantom_id = Uuid::new_v4();
seed_caja(&mut store, caja_id, "tracked", 100_000, "USD");
seed_caja(&mut store, phantom_id, "phantom", 100_000, "USD");
let params = json!({ "phantom_id": phantom_id.to_string() });
let result = exec.run(&mut store, "evil_capability", &[("caja", caja_id)], params);
match result {
Err(ExecError::CapabilityViolation { token, .. }) => {
assert!(
token.contains("untracked"),
"expected token to flag untracked id, got `{}`",
token
);
}
other => panic!("expected CapabilityViolation, got {:?}", other),
}
// Neither caja moved.
assert_eq!(caja_saldo(&store, caja_id), 100_000);
assert_eq!(caja_saldo(&store, phantom_id), 100_000);
}
#[test]
fn conservation_violation_blocks_unbalanced_transfer() {
let spec = MorphismSpec {
name: "evil_conservation".into(),
inputs: vec![
MorphismInput {
role: "source".into(),
entity: "Caja".into(),
},
MorphismInput {
role: "dest".into(),
entity: "Caja".into(),
},
],
reads: vec![
"source.saldo".into(),
"source.currency".into(),
"dest.saldo".into(),
"dest.currency".into(),
],
writes: vec!["source.saldo".into(), "dest.saldo".into()],
invariants: Invariants {
conserve: vec![ConserveRule {
entity: "Caja".into(),
field: "saldo".into(),
group_by: Some("currency".into()),
}],
},
depends_on: vec![],
script: "conservation_violation.rhai".into(),
};
let exec = build_executor(spec);
let mut store = MemoryStore::new();
let source = Uuid::new_v4();
let dest = Uuid::new_v4();
seed_caja(&mut store, source, "A", 200_000, "USD");
seed_caja(&mut store, dest, "B", 50_000, "USD");
let result = exec.run(
&mut store,
"evil_conservation",
&[("source", source), ("dest", dest)],
json!({}),
);
match result {
Err(ExecError::ConservationViolation {
entity,
field,
total,
..
}) => {
assert_eq!(entity, "Caja");
assert_eq!(field, "saldo");
assert_eq!(total, -101, "expected Δ = -100 + -1 = -101");
}
other => panic!("expected ConservationViolation, got {:?}", other),
}
assert_eq!(caja_saldo(&store, source), 200_000);
assert_eq!(caja_saldo(&store, dest), 50_000);
}
#[test]
fn capability_rejects_entity_mismatch_on_tracked_id() {
// The script writes `Stock.cantidad` using the Caja's UUID. The id is
// tracked (it's the caja role's id) but the entity differs — the
// capability layer must catch this regardless of UUID coincidence.
let spec = MorphismSpec {
name: "evil_entity_mismatch".into(),
inputs: vec![MorphismInput {
role: "caja".into(),
entity: "Caja".into(),
}],
reads: vec!["caja.saldo".into()],
writes: vec!["caja.saldo".into()],
invariants: Invariants::default(),
depends_on: vec![],
script: "entity_mismatch.rhai".into(),
};
let exec = build_executor(spec);
let mut store = MemoryStore::new();
let caja_id = Uuid::new_v4();
seed_caja(&mut store, caja_id, "tracked", 100_000, "USD");
let result = exec.run(&mut store, "evil_entity_mismatch", &[("caja", caja_id)], json!({}));
match result {
Err(ExecError::CapabilityViolation { token, .. }) => {
assert!(
token.contains("entity-mismatch"),
"expected entity-mismatch token, got `{}`",
token
);
}
other => panic!("expected CapabilityViolation, got {:?}", other),
}
assert_eq!(caja_saldo(&store, caja_id), 100_000);
}
#[test]
fn delete_primary_skips_post_check_and_removes_record() {
// A morphism that deletes its primary input must succeed without the
// post-check running against a stale-then-stripped state.
let spec = MorphismSpec {
name: "delete_caja".into(),
inputs: vec![MorphismInput {
role: "caja".into(),
entity: "Caja".into(),
}],
reads: vec![],
writes: vec!["Caja".into()],
invariants: Invariants::default(),
depends_on: vec![],
script: "delete_primary.rhai".into(),
};
let exec = build_executor(spec);
let mut store = MemoryStore::new();
let caja_id = Uuid::new_v4();
seed_caja(&mut store, caja_id, "doomed", 100_000, "USD");
let ops = exec
.run(&mut store, "delete_caja", &[("caja", caja_id)], json!({}))
.expect("delete must succeed");
assert_eq!(ops.len(), 1);
assert!(matches!(&ops[0], nakui_core::delta::FieldOp::Delete { .. }));
assert!(
store.load("Caja", caja_id).is_none(),
"Caja must be gone after Delete"
);
}
#[test]
fn bad_created_record_blocks_negative_movimiento() {
let spec = MorphismSpec {
name: "evil_create".into(),
inputs: vec![MorphismInput {
role: "caja".into(),
entity: "Caja".into(),
}],
reads: vec!["caja.saldo".into()],
writes: vec!["Movimiento".into()],
invariants: Invariants::default(),
depends_on: vec![],
script: "bad_created_record.rhai".into(),
};
let exec = build_executor(spec);
let mut store = MemoryStore::new();
let caja_id = Uuid::new_v4();
seed_caja(&mut store, caja_id, "A", 100_000, "USD");
let mov_id = Uuid::new_v4();
let params = json!({ "mov_id": mov_id.to_string() });
let result = exec.run(&mut store, "evil_create", &[("caja", caja_id)], params);
match result {
Err(ExecError::KclPostCreate { entity, .. }) => {
assert_eq!(entity, "Movimiento");
}
other => panic!("expected KclPostCreate, got {:?}", other),
}
// Caja unchanged, Movimiento never landed.
assert_eq!(caja_saldo(&store, caja_id), 100_000);
assert!(
store.load("Movimiento", mov_id).is_none(),
"Movimiento must not be persisted"
);
}
@@ -0,0 +1,282 @@
//! Manifest::validate covers the contract between authors (humans or AI)
//! and Nakui. Each test inline-builds a manifest with one specific defect
//! and asserts the right diagnostic fires.
//!
//! Most tests point at `modules/treasury/` so the schema/script paths
//! resolve. Two tests need a synthetic tempdir to express their defect
//! (missing schema file, duplicate schema across files).
use std::fs;
use std::path::{Path, PathBuf};
use nakui_core::executor::Executor;
use nakui_core::manifest::{
ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec, ValidationError,
};
use uuid::Uuid;
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root above core/")
.to_path_buf()
}
fn treasury_dir() -> PathBuf {
workspace_root().join("modules/treasury")
}
fn caja_input() -> MorphismInput {
MorphismInput {
role: "caja".into(),
entity: "Caja".into(),
}
}
fn baseline_morphism() -> MorphismSpec {
MorphismSpec {
name: "test_op".into(),
inputs: vec![caja_input()],
reads: vec!["caja.saldo".into()],
writes: vec!["caja.saldo".into()],
invariants: Invariants::default(),
depends_on: vec![],
script: "morphisms/register_cash_move.rhai".into(),
}
}
fn baseline_manifest() -> Manifest {
Manifest {
module: "test".into(),
schemas: vec![],
morphisms: vec![baseline_morphism()],
}
}
#[test]
fn production_modules_validate_clean() {
for name in ["treasury", "inventory", "sales"] {
let dir = workspace_root().join("modules").join(name);
let manifest = Manifest::load(&dir.join("nsmc.json"))
.unwrap_or_else(|e| panic!("load {}: {}", name, e));
manifest
.validate(&dir)
.unwrap_or_else(|e| panic!("validate {}: {}", name, e));
}
}
#[test]
fn rejects_duplicate_morphism_name() {
let mut m = baseline_manifest();
m.morphisms.push(baseline_morphism()); // same name as the first
match m.validate(&treasury_dir()) {
Err(ValidationError::DuplicateMorphism(name)) => assert_eq!(name, "test_op"),
other => panic!("expected DuplicateMorphism, got {:?}", other),
}
}
#[test]
fn rejects_duplicate_role_within_morphism() {
let mut m = baseline_manifest();
m.morphisms[0].inputs.push(caja_input()); // same role twice
match m.validate(&treasury_dir()) {
Err(ValidationError::DuplicateRole { morphism, role }) => {
assert_eq!(morphism, "test_op");
assert_eq!(role, "caja");
}
other => panic!("expected DuplicateRole, got {:?}", other),
}
}
#[test]
fn rejects_input_unknown_entity() {
let mut m = baseline_manifest();
m.morphisms[0].inputs[0].entity = "Banana".into();
match m.validate(&treasury_dir()) {
Err(ValidationError::InputUnknownEntity {
morphism,
entity,
known,
}) => {
assert_eq!(morphism, "test_op");
assert_eq!(entity, "Banana");
assert!(known.contains(&"Caja".to_string()));
}
other => panic!("expected InputUnknownEntity, got {:?}", other),
}
}
#[test]
fn rejects_writes_unknown_role() {
let mut m = baseline_manifest();
m.morphisms[0].writes = vec!["ghost.saldo".into()];
match m.validate(&treasury_dir()) {
Err(ValidationError::WritesUnknownRole {
morphism,
token,
role,
..
}) => {
assert_eq!(morphism, "test_op");
assert_eq!(token, "ghost.saldo");
assert_eq!(role, "ghost");
}
other => panic!("expected WritesUnknownRole, got {:?}", other),
}
}
#[test]
fn rejects_writes_unknown_entity() {
let mut m = baseline_manifest();
m.morphisms[0].writes = vec!["BananaSplit".into()];
match m.validate(&treasury_dir()) {
Err(ValidationError::WritesUnknownEntity { morphism, token }) => {
assert_eq!(morphism, "test_op");
assert_eq!(token, "BananaSplit");
}
other => panic!("expected WritesUnknownEntity, got {:?}", other),
}
}
#[test]
fn rejects_conserve_unknown_entity() {
let mut m = baseline_manifest();
m.morphisms[0].invariants.conserve = vec![ConserveRule {
entity: "Banana".into(),
field: "x".into(),
group_by: None,
}];
match m.validate(&treasury_dir()) {
Err(ValidationError::ConserveUnknownEntity { morphism, entity }) => {
assert_eq!(morphism, "test_op");
assert_eq!(entity, "Banana");
}
other => panic!("expected ConserveUnknownEntity, got {:?}", other),
}
}
#[test]
fn rejects_depends_on_unknown_morphism() {
let mut m = baseline_manifest();
m.morphisms[0].depends_on = vec!["ghost_morphism".into()];
match m.validate(&treasury_dir()) {
Err(ValidationError::DependsOnUnknown { morphism, dep }) => {
assert_eq!(morphism, "test_op");
assert_eq!(dep, "ghost_morphism");
}
other => panic!("expected DependsOnUnknown, got {:?}", other),
}
}
#[test]
fn rejects_missing_script() {
let mut m = baseline_manifest();
m.morphisms[0].script = "morphisms/ghost.rhai".into();
match m.validate(&treasury_dir()) {
Err(ValidationError::ScriptMissing { morphism, script, .. }) => {
assert_eq!(morphism, "test_op");
assert_eq!(script, "morphisms/ghost.rhai");
}
other => panic!("expected ScriptMissing, got {:?}", other),
}
}
#[test]
fn rejects_missing_schema_file() {
let mut m = baseline_manifest();
m.schemas = vec!["nonexistent.k".into()];
match m.validate(&treasury_dir()) {
Err(ValidationError::SchemaFileMissing { path, .. }) => {
assert_eq!(path, "nonexistent.k");
}
other => panic!("expected SchemaFileMissing, got {:?}", other),
}
}
#[test]
fn rejects_duplicate_schema_across_files() {
// Synthesize a tempdir with two .k files that both declare `schema X`.
let tmp = std::env::temp_dir().join(format!("nakui_dup_{}", Uuid::new_v4()));
fs::create_dir_all(&tmp).unwrap();
fs::create_dir_all(tmp.join("morphisms")).unwrap();
fs::write(
tmp.join("a.k"),
"schema Caja:\n saldo: int\n check:\n saldo >= 0\n",
)
.unwrap();
fs::write(
tmp.join("b.k"),
"schema Caja:\n monto: int\n check:\n monto >= 0\n",
)
.unwrap();
fs::write(tmp.join("morphisms/op.rhai"), "[]").unwrap();
let m = Manifest {
module: "dup".into(),
schemas: vec!["a.k".into(), "b.k".into()],
morphisms: vec![MorphismSpec {
name: "op".into(),
inputs: vec![MorphismInput {
role: "caja".into(),
entity: "Caja".into(),
}],
reads: vec![],
writes: vec![],
invariants: Invariants::default(),
depends_on: vec![],
script: "morphisms/op.rhai".into(),
}],
};
match m.validate(&tmp) {
Err(ValidationError::DuplicateSchema { name, files }) => {
assert_eq!(name, "Caja");
assert!(files.contains(&"a.k".to_string()));
assert!(files.contains(&"b.k".to_string()));
}
other => panic!("expected DuplicateSchema, got {:?}", other),
}
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn executor_load_module_runs_validation() {
// Synthesize a module dir whose manifest references a missing script —
// load_module must surface ManifestValidation, not a runtime kernel error.
let tmp = std::env::temp_dir().join(format!("nakui_bad_{}", Uuid::new_v4()));
fs::create_dir_all(&tmp).unwrap();
fs::write(
tmp.join("schema.k"),
"schema Caja:\n saldo: int\n check:\n saldo >= 0\n",
)
.unwrap();
fs::write(
tmp.join("nsmc.json"),
r#"{
"module": "bad",
"morphisms": [{
"name": "op",
"inputs": [{"role": "caja", "entity": "Caja"}],
"reads": [],
"writes": ["caja.saldo"],
"depends_on": [],
"script": "morphisms/missing.rhai"
}]
}"#,
)
.unwrap();
let err = match Executor::load_module(&tmp) {
Ok(_) => panic!("must fail validation"),
Err(e) => e,
};
let msg = err.to_string();
assert!(
msg.contains("validation") && msg.contains("missing.rhai"),
"expected validation diagnostic naming the missing script, got `{}`",
msg
);
let _ = fs::remove_dir_all(&tmp);
}
+286
View File
@@ -0,0 +1,286 @@
//! End-to-end tests for `nakui run` — bind a socket from the main test
//! thread, drive it from a client thread with line-JSON requests, and
//! assert behaviour through the wire.
//!
//! Why server-on-main / client-on-thread: `Executor` is `!Send` (Rhai
//! caches AST in a `RefCell`). Moving it across thread boundaries is a
//! compile-time error, so the test thread runs the server and a worker
//! thread plays the client. The worker calls `shutdown` last, which lets
//! the main thread return from `run_server` and join.
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::Duration;
use nakui_core::event_log::{EventLog, execute_and_log, seed_and_log};
use nakui_core::executor::Executor;
use nakui_core::run::run_server;
use nakui_core::store::MemoryStore;
use serde_json::{Value, json};
use uuid::Uuid;
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root above core/")
.to_path_buf()
}
fn treasury_module() -> PathBuf {
workspace_root().join("modules/treasury")
}
fn fresh_log_path() -> PathBuf {
std::env::temp_dir().join(format!("nakui_run_log_{}.jsonl", Uuid::new_v4()))
}
fn fresh_socket_path() -> PathBuf {
std::env::temp_dir().join(format!("nakui_run_{}.sock", Uuid::new_v4()))
}
/// One client connection: keeps a single BufReader alive across
/// exchanges so buffered bytes from one response don't get dropped
/// before the next read.
struct Conn {
writer: UnixStream,
reader: BufReader<UnixStream>,
}
impl Conn {
fn connect_with_retry(path: &Path) -> Self {
for _ in 0..100 {
if let Ok(stream) = UnixStream::connect(path) {
let reader_stream = stream.try_clone().expect("clone for reader");
return Self {
writer: stream,
reader: BufReader::new(reader_stream),
};
}
thread::sleep(Duration::from_millis(20));
}
panic!("server never started accepting on {}", path.display());
}
fn exchange(&mut self, req: Value) -> Value {
let mut s = serde_json::to_vec(&req).expect("serialize request");
s.push(b'\n');
self.writer.write_all(&s).expect("write request");
let mut line = String::new();
let n = self.reader.read_line(&mut line).expect("read response");
assert!(n > 0, "server closed connection without responding");
serde_json::from_str(line.trim()).expect("parse response")
}
}
#[test]
fn run_server_full_protocol_round_trip() {
let log_path = fresh_log_path();
let socket_path = fresh_socket_path();
let caja_id = Uuid::new_v4();
{
let executor = Executor::load_module(treasury_module()).expect("load module");
let mut log = EventLog::open(&log_path).expect("open log");
let mut store = MemoryStore::new();
seed_and_log(
&executor,
&mut store,
&mut log,
"Caja",
caja_id,
json!({
"id": caja_id.to_string(),
"name": "A",
"saldo": 100_000_i64,
"currency": "USD",
}),
)
.expect("seed");
}
let executor = Executor::load_module(treasury_module()).expect("load module");
let log = EventLog::open(&log_path).expect("reopen log");
let store = MemoryStore::new();
let socket_for_client = socket_path.clone();
let client = thread::spawn(move || -> Result<(), String> {
let mut conn = Conn::connect_with_retry(&socket_for_client);
let resp = conn.exchange(json!({"op": "describe"}));
if resp["ok"] != json!(true) {
return Err(format!("describe not ok: {}", resp));
}
if resp["module"] != json!("treasury") {
return Err(format!("module mismatch: {}", resp));
}
if resp["protocol"] != json!(1) {
return Err(format!("protocol mismatch: {}", resp));
}
let morphisms = resp["morphisms"]
.as_array()
.ok_or("morphisms not array")?;
if !morphisms.iter().any(|m| m["name"] == "register_cash_move") {
return Err("register_cash_move missing from describe".into());
}
let resp = conn.exchange(json!({
"op": "load",
"entity": "Caja",
"id": caja_id.to_string(),
}));
if resp["value"]["saldo"].as_i64() != Some(100_000) {
return Err(format!("initial saldo wrong: {}", resp));
}
let resp = conn.exchange(json!({
"op": "execute",
"morphism": "register_cash_move",
"inputs": {"caja": caja_id.to_string()},
"params": {
"monto": 5_000_i64,
"tipo": "in",
"timestamp": "2026-05-04T10:00:00Z",
"memo": "via run",
"movimiento_id": Uuid::new_v4().to_string(),
},
}));
if resp["ok"] != json!(true) {
return Err(format!("execute failed: {}", resp));
}
if resp["seq"].as_u64().is_none() {
return Err(format!("execute missing seq: {}", resp));
}
if resp["ops"].as_array().map(|a| a.is_empty()).unwrap_or(true) {
return Err(format!("execute missing ops: {}", resp));
}
let resp = conn.exchange(json!({
"op": "load",
"entity": "Caja",
"id": caja_id.to_string(),
}));
if resp["value"]["saldo"].as_i64() != Some(105_000) {
return Err(format!("post-execute saldo wrong: {}", resp));
}
// Kernel rejection: returns ok=false with stage=pre_log.
let other = Uuid::new_v4();
let resp = conn.exchange(json!({
"op": "execute",
"morphism": "transfer_between_cajas",
"inputs": {"source": caja_id.to_string(), "dest": other.to_string()},
"params": {
"monto": 999_999_999_i64,
"timestamp": "2026-05-04T10:30:00Z",
"memo": "overdraw",
"transfer_id": Uuid::new_v4().to_string(),
},
}));
if resp["ok"] != json!(false) || resp["stage"] != json!("pre_log") {
return Err(format!("expected pre_log rejection: {}", resp));
}
// Bad JSON — connection survives, server keeps serving.
conn.writer.write_all(b"not json\n").map_err(|e| e.to_string())?;
let mut line = String::new();
conn.reader.read_line(&mut line).map_err(|e| e.to_string())?;
let parsed: Value = serde_json::from_str(line.trim()).map_err(|e| e.to_string())?;
if parsed["ok"] != json!(false) {
return Err(format!("bad request didn't get error: {}", parsed));
}
let resp = conn.exchange(json!({"op": "verify"}));
if resp["ok"] != json!(true) {
return Err(format!("verify failed: {}", resp));
}
if resp["entries"].as_u64() != Some(2) {
return Err(format!("verify entries wrong: {}", resp));
}
let resp = conn.exchange(json!({"op": "shutdown"}));
if resp["ok"] != json!(true) || resp["shutdown"] != json!(true) {
return Err(format!("shutdown response wrong: {}", resp));
}
Ok(())
});
run_server(executor, log, store, None, &socket_path).expect("server clean exit");
client.join().expect("client thread joined").expect("client assertions");
assert!(
!socket_path.exists(),
"shutdown must remove the socket file"
);
let _ = std::fs::remove_file(&log_path);
}
#[test]
fn run_server_reconciles_drifted_store_on_startup() {
let log_path = fresh_log_path();
let socket_path = fresh_socket_path();
let caja_id = Uuid::new_v4();
{
let executor = Executor::load_module(treasury_module()).expect("load");
let mut log = EventLog::open(&log_path).expect("open log");
let mut store = MemoryStore::new();
seed_and_log(
&executor,
&mut store,
&mut log,
"Caja",
caja_id,
json!({
"id": caja_id.to_string(),
"name": "A",
"saldo": 200_000_i64,
"currency": "USD",
}),
)
.expect("seed");
execute_and_log(
&executor,
&mut store,
&mut log,
"register_cash_move",
&[("caja", caja_id)],
json!({
"monto": 1_500_i64,
"tipo": "in",
"timestamp": "2026-05-04T09:00:00Z",
"memo": "pre-run",
"movimiento_id": Uuid::new_v4().to_string(),
}),
)
.expect("deposit");
}
let executor = Executor::load_module(treasury_module()).expect("load");
let log = EventLog::open(&log_path).expect("reopen");
let empty_store = MemoryStore::new();
let socket_for_client = socket_path.clone();
let client = thread::spawn(move || -> Result<(), String> {
let mut conn = Conn::connect_with_retry(&socket_for_client);
let resp = conn.exchange(json!({
"op": "load",
"entity": "Caja",
"id": caja_id.to_string(),
}));
if resp["value"]["saldo"].as_i64() != Some(201_500) {
return Err(format!(
"expected saldo 201_500 (200k seed + 1.5k replayed deposit), got {}",
resp
));
}
conn.exchange(json!({"op": "shutdown"}));
Ok(())
});
run_server(executor, log, empty_store, None, &socket_path).expect("clean exit");
client.join().unwrap().expect("client assertions");
let _ = std::fs::remove_file(&log_path);
}
@@ -0,0 +1,328 @@
//! Smoke test for the persistent backend wired into `nakui run`.
//!
//! Gated behind `--features persistent` because SurrealKV pulls in a
//! ~5 min cold native build. Run with:
//! cargo test --features persistent --test run_persistent
//!
//! What this proves:
//! 1. `run_server` accepts a `SurrealStore` and serves the standard
//! protocol (execute/load/shutdown round-trip).
//! 2. After shutdown, reopening the same backing store path reveals
//! the records were actually written through to disk — i.e., the
//! runtime wasn't just hitting an in-memory façade.
//!
//! What this does NOT prove (covered elsewhere or deferred):
//! - That startup skips replay when the persistent state is current.
//! V1 always replays from log, even with a persistent store; the
//! persistent layer is durability for the runtime cache, not a
//! replay shortcut. A future `last_applied_seq` tracker would
//! change that.
//! - Cross-backend hash equality (Memory vs Surreal). Different
//! concern — round-trip parity of serde_json::Value through the
//! SurrealDB driver.
#![cfg(feature = "persistent")]
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::Duration;
use nakui_core::event_log::{EventLog, seed_and_log};
use nakui_core::executor::Executor;
use nakui_core::run::run_server;
use nakui_core::store::Store;
use nakui_core::surreal_store::SurrealStore;
use serde_json::{Value, json};
use uuid::Uuid;
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root above core/")
.to_path_buf()
}
fn treasury_module() -> PathBuf {
workspace_root().join("modules/treasury")
}
fn fresh_log_path() -> PathBuf {
std::env::temp_dir().join(format!("nakui_runp_log_{}.jsonl", Uuid::new_v4()))
}
fn fresh_store_path() -> PathBuf {
std::env::temp_dir().join(format!("nakui_runp_store_{}", Uuid::new_v4()))
}
fn fresh_socket_path() -> PathBuf {
std::env::temp_dir().join(format!("nakui_runp_{}.sock", Uuid::new_v4()))
}
struct Conn {
writer: UnixStream,
reader: BufReader<UnixStream>,
}
fn connect_with_retry(path: &Path) -> Conn {
for _ in 0..200 {
if let Ok(stream) = UnixStream::connect(path) {
let reader_stream = stream.try_clone().expect("clone");
return Conn {
writer: stream,
reader: BufReader::new(reader_stream),
};
}
thread::sleep(Duration::from_millis(20));
}
panic!("server never started accepting on {}", path.display());
}
fn exchange(conn: &mut Conn, req: Value) -> Value {
let mut bytes = serde_json::to_vec(&req).unwrap();
bytes.push(b'\n');
conn.writer.write_all(&bytes).unwrap();
let mut line = String::new();
conn.reader.read_line(&mut line).unwrap();
serde_json::from_str(line.trim()).unwrap()
}
#[test]
fn run_server_with_persistent_surreal_serves_protocol_and_writes_to_disk() {
let log_path = fresh_log_path();
let store_path = fresh_store_path();
let socket_path = fresh_socket_path();
// Pre-seed via the WAL so the log has a record the server can
// replay into the persistent store on startup.
let caja = Uuid::new_v4();
{
let mut store = SurrealStore::new_persistent(&store_path).expect("open persistent");
let mut log = EventLog::open(&log_path).expect("open log");
seed_and_log(
&executor,
&mut store,
&mut log,
"Caja",
caja,
json!({
"id": caja.to_string(),
"name": "A",
"saldo": 100_000_i64,
"currency": "USD",
}),
)
.expect("seed");
}
// Start the server with the same persistent store path.
let executor = Executor::load_module(treasury_module()).expect("load module");
let log = EventLog::open(&log_path).expect("reopen log");
let store = SurrealStore::new_persistent(&store_path).expect("reopen persistent");
let socket_for_client = socket_path.clone();
let client = thread::spawn(move || -> Result<(), String> {
let mut conn = connect_with_retry(&socket_for_client);
// Initial load picks up the seed (replayed at startup into the
// persistent store).
let resp = exchange(
&mut conn,
json!({"op": "load", "entity": "Caja", "id": caja.to_string()}),
);
if resp["value"]["saldo"].as_i64() != Some(100_000) {
return Err(format!("startup replay didn't land seed: {}", resp));
}
// Drive a deposit through the server — this writes through the
// log AND the persistent store.
let resp = exchange(
&mut conn,
json!({
"op": "execute",
"morphism": "register_cash_move",
"inputs": {"caja": caja.to_string()},
"params": {
"monto": 7_500_i64,
"tipo": "in",
"timestamp": "2026-05-04T10:00:00Z",
"memo": "persisted",
"movimiento_id": Uuid::new_v4().to_string(),
}
}),
);
if resp["ok"] != json!(true) {
return Err(format!("execute failed: {}", resp));
}
let resp = exchange(
&mut conn,
json!({"op": "load", "entity": "Caja", "id": caja.to_string()}),
);
if resp["value"]["saldo"].as_i64() != Some(107_500) {
return Err(format!("post-execute saldo wrong: {}", resp));
}
let _ = exchange(&mut conn, json!({"op": "shutdown"}));
Ok(())
});
run_server(executor, log, store, None, &socket_path).expect("server clean exit");
client.join().unwrap().expect("client assertions");
// Now the server is gone. Open a fresh handle to the SAME persistent
// store path — the records must be there without any replay. This
// is what proves "persistent backend" beyond the unit-level tests
// in surreal_persist.rs: the runtime actually wrote through.
let store_again = SurrealStore::new_persistent(&store_path).expect("reopen final");
let v = store_again
.load("Caja", caja)
.expect("Caja persisted across server shutdown");
assert_eq!(
v.get("saldo").and_then(Value::as_i64),
Some(107_500),
"deposit landed in persistent store"
);
let _ = std::fs::remove_file(&log_path);
let _ = std::fs::remove_dir_all(&store_path);
}
#[test]
fn run_server_skips_replay_when_persistent_store_is_in_sync() {
// The optimization: when the persistent store's `last_applied_seq`
// matches the log's last seq, startup_replay must skip the
// clear+replay entirely. We prove that by mutating the store
// out-of-band between cycles — if skip happens, the mutation
// survives; if full replay runs (clear+replay), it'd be wiped.
let log_path = fresh_log_path();
let store_path = fresh_store_path();
let socket_path1 = fresh_socket_path();
let socket_path2 = fresh_socket_path();
let caja = Uuid::new_v4();
// Cycle 1: drive a deposit through the server. After shutdown the
// persistent store's marker should equal the log's last seq.
{
let executor = Executor::load_module(treasury_module()).expect("load");
let mut log = EventLog::open(&log_path).expect("open log");
let mut store = SurrealStore::new_persistent(&store_path).expect("open persistent");
seed_and_log(
&executor,
&mut store,
&mut log,
"Caja",
caja,
json!({
"id": caja.to_string(),
"name": "A",
"saldo": 100_000_i64,
"currency": "USD",
}),
)
.expect("seed");
// We end the WAL flow without running run_server in this cycle —
// the next cycle is the one that exercises the skip path.
drop(store);
drop(log);
drop(executor);
}
// Out-of-band mutation: open the persistent store directly and
// change the saldo. Marker stays at the same seq.
{
let mut store =
SurrealStore::new_persistent(&store_path).expect("reopen for poison");
store.seed(
"Caja",
caja,
json!({
"id": caja.to_string(),
"name": "A",
"saldo": 999_999_i64, // poison
"currency": "USD",
}),
);
// The marker we set during the WAL flow stays intact — seed()
// alone does not bump it.
}
// Cycle 2: run_server with the poisoned store. Marker == log_last
// (still 0 from the seed) → skip path → poison saldo survives.
let executor = Executor::load_module(treasury_module()).expect("load");
let log = EventLog::open(&log_path).expect("reopen log");
let store = SurrealStore::new_persistent(&store_path).expect("reopen final");
let socket_for_client = socket_path1.clone();
let client = thread::spawn(move || -> Result<(), String> {
let mut conn = connect_with_retry(&socket_for_client);
let resp = exchange(
&mut conn,
json!({"op": "load", "entity": "Caja", "id": caja.to_string()}),
);
let saldo = resp["value"]["saldo"].as_i64();
let _ = exchange(&mut conn, json!({"op": "shutdown"}));
if saldo != Some(999_999) {
return Err(format!(
"skip-replay should preserve out-of-band saldo (999_999), got {:?}",
saldo
));
}
Ok(())
});
run_server(executor, log, store, None, &socket_path1).expect("server clean exit");
client.join().unwrap().expect("client assertions");
// Cycle 3: explicitly invalidate the marker (simulating a backend
// that lost track) and confirm full replay restores log-canonical
// state — wiping the poison.
{
let mut store = SurrealStore::new_persistent(&store_path).expect("reopen for marker reset");
// Force the marker into the "uninitialized" state by clearing
// and reseeding the legitimate record without bumping it. The
// simplest way is to clear() then re-seed; clear nukes
// last_applied_seq.
store.clear().expect("clear");
store.seed(
"Caja",
caja,
json!({
"id": caja.to_string(),
"name": "A",
"saldo": 999_999_i64, // poison still present
"currency": "USD",
}),
);
// last_applied_seq is now None → mismatch with log_last → full replay path.
}
let executor = Executor::load_module(treasury_module()).expect("load");
let log = EventLog::open(&log_path).expect("reopen");
let store = SurrealStore::new_persistent(&store_path).expect("reopen");
let socket_for_client = socket_path2.clone();
let client = thread::spawn(move || -> Result<(), String> {
let mut conn = connect_with_retry(&socket_for_client);
let resp = exchange(
&mut conn,
json!({"op": "load", "entity": "Caja", "id": caja.to_string()}),
);
let saldo = resp["value"]["saldo"].as_i64();
let _ = exchange(&mut conn, json!({"op": "shutdown"}));
if saldo != Some(100_000) {
return Err(format!(
"full replay should restore canonical saldo (100_000), got {:?}",
saldo
));
}
Ok(())
});
run_server(executor, log, store, None, &socket_path2).expect("server clean exit");
client.join().unwrap().expect("client assertions");
let _ = std::fs::remove_file(&log_path);
let _ = std::fs::remove_dir_all(&store_path);
}
+164
View File
@@ -0,0 +1,164 @@
//! Cross-module integration tests. The `sales` module references entities
//! defined in `treasury` and `inventory` via its manifest's `schemas` list.
//! These tests assert:
//! - The kernel correctly bundles multiple .k files at module load.
//! - Per-entity KCL post-checks fire against the right schema even when
//! three are concatenated.
//! - A non-conserving morphism (sale = stock1, caja+price) passes the
//! kernel cleanly because no `invariants.conserve` was declared.
use std::path::{Path, PathBuf};
use nakui_core::executor::{ExecError, Executor};
use nakui_core::store::{MemoryStore, Store};
use serde_json::{Value, json};
use uuid::Uuid;
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root above core/")
.to_path_buf()
}
fn sales_module() -> PathBuf {
workspace_root().join("modules/sales")
}
fn caja_saldo(store: &MemoryStore, id: Uuid) -> i64 {
store
.load("Caja", id)
.and_then(|v| v.get("saldo").and_then(Value::as_i64))
.expect("caja with saldo")
}
fn stock_cantidad(store: &MemoryStore, id: Uuid) -> i64 {
store
.load("Stock", id)
.and_then(|v| v.get("cantidad").and_then(Value::as_i64))
.expect("stock with cantidad")
}
fn seed(store: &mut MemoryStore) -> (Uuid, Uuid) {
let stock = Uuid::new_v4();
let caja = Uuid::new_v4();
store.seed(
"Stock",
stock,
json!({
"id": stock.to_string(),
"sku_id": "sku-test",
"ubicacion": "test-loc",
"cantidad": 500_i64,
}),
);
store.seed(
"Caja",
caja,
json!({
"id": caja.to_string(),
"name": "Caja Test",
"saldo": 1_000_000_i64,
"currency": "USD",
}),
);
(stock, caja)
}
#[test]
fn sale_decreases_stock_and_increases_caja() {
let exec = Executor::load_module(sales_module()).expect("load module");
let mut store = MemoryStore::new();
let (stock, caja) = seed(&mut store);
let venta_id = Uuid::new_v4();
let ops = exec
.run(
&mut store,
"vender",
&[("stock", stock), ("caja", caja)],
json!({
"cantidad": 100_i64,
"precio_unitario": 5_000_i64,
"timestamp": "2026-05-04T10:00:00Z",
"venta_id": venta_id.to_string(),
}),
)
.expect("sale must succeed");
assert_eq!(ops.len(), 3, "2 sets + 1 create");
assert_eq!(stock_cantidad(&store, stock), 400);
assert_eq!(caja_saldo(&store, caja), 1_500_000);
let venta = store
.load("Venta", venta_id)
.expect("venta must be persisted");
assert_eq!(venta.get("total").and_then(Value::as_i64), Some(500_000));
assert_eq!(venta.get("cantidad").and_then(Value::as_i64), Some(100));
assert_eq!(
venta.get("currency").and_then(Value::as_str),
Some("USD")
);
}
#[test]
fn overdraw_stock_rejected_by_inventory_post_check() {
let exec = Executor::load_module(sales_module()).expect("load module");
let mut store = MemoryStore::new();
let (stock, caja) = seed(&mut store);
let result = exec.run(
&mut store,
"vender",
&[("stock", stock), ("caja", caja)],
json!({
"cantidad": 9999_i64,
"precio_unitario": 100_i64,
"timestamp": "2026-05-04T10:00:00Z",
"venta_id": Uuid::new_v4().to_string(),
}),
);
match result {
Err(ExecError::KclPost { role, entity, .. }) => {
assert_eq!(role, "stock");
assert_eq!(entity, "Stock");
}
other => panic!("expected KclPost on stock, got {:?}", other),
}
assert_eq!(stock_cantidad(&store, stock), 500);
assert_eq!(caja_saldo(&store, caja), 1_000_000);
}
#[test]
fn venta_total_invariant_caught_when_corrupted() {
// The Venta schema's check block enforces `total == cantidad * precio`.
// The production script always produces a consistent total. To prove
// the schema check fires, this test would need a buggy script — that's
// covered indirectly: if anyone breaks the script, this fails. For now
// we just confirm a clean sale's Venta passes its own invariant.
let exec = Executor::load_module(sales_module()).expect("load module");
let mut store = MemoryStore::new();
let (stock, caja) = seed(&mut store);
let venta_id = Uuid::new_v4();
exec.run(
&mut store,
"vender",
&[("stock", stock), ("caja", caja)],
json!({
"cantidad": 7_i64,
"precio_unitario": 13_i64,
"timestamp": "2026-05-04T10:00:00Z",
"venta_id": venta_id.to_string(),
}),
)
.expect("sale must pass");
let venta = store.load("Venta", venta_id).expect("venta");
assert_eq!(
venta.get("total").and_then(Value::as_i64),
Some(7 * 13),
"Venta.total must equal cantidad * precio"
);
}
@@ -0,0 +1,444 @@
//! Schema versioning: every logged morphism carries a `schema_hash` that
//! pins it to the (kcl + manifest spec + rhai) bundle active at write
//! time. `verify_log` rejects logs whose entries were produced under
//! rules that no longer match the loaded executor.
//!
//! The tests here build a *temp copy* of the treasury module so we can
//! mutate its files without polluting the source tree. Each test cleans
//! its temp dir even if it panics (the helper drops via `TempModule`).
use std::path::{Path, PathBuf};
use nakui_core::event_log::{
EventLog, LogEntry, VerifyError, execute_and_log, replay, seed_and_log, verify_log,
};
use nakui_core::executor::Executor;
use nakui_core::store::MemoryStore;
use serde_json::{Value, json};
use uuid::Uuid;
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root above core/")
.to_path_buf()
}
fn treasury_module() -> PathBuf {
workspace_root().join("modules/treasury")
}
fn fresh_log_path() -> PathBuf {
std::env::temp_dir().join(format!("nakui_schema_{}.jsonl", Uuid::new_v4()))
}
/// Owned temp copy of a module directory. Drops the entire tree.
struct TempModule {
pub path: PathBuf,
}
impl TempModule {
fn from(src: &Path) -> Self {
let dst = std::env::temp_dir().join(format!("nakui_module_{}", Uuid::new_v4()));
copy_dir_recursive(src, &dst).expect("copy module");
Self { path: dst }
}
}
impl Drop for TempModule {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
let dst_path = dst.join(entry.file_name());
if ty.is_dir() {
copy_dir_recursive(&entry.path(), &dst_path)?;
} else {
std::fs::copy(entry.path(), &dst_path)?;
}
}
Ok(())
}
fn deposit_5k(
exec: &Executor,
store: &mut MemoryStore,
log: &mut EventLog,
caja: Uuid,
) {
execute_and_log(
exec,
store,
log,
"register_cash_move",
&[("caja", caja)],
json!({
"monto": 5_000_i64,
"tipo": "in",
"timestamp": "2026-05-04T10:00:00Z",
"memo": "x",
"movimiento_id": Uuid::new_v4().to_string(),
}),
)
.expect("deposit");
}
fn seed_caja(exec: &Executor, store: &mut MemoryStore, log: &mut EventLog, id: Uuid) {
seed_and_log(
exec,
store,
log,
"Caja",
id,
json!({"id": id.to_string(), "name": "A", "saldo": 100_000_i64, "currency": "USD"}),
)
.unwrap();
}
#[test]
fn executor_exposes_per_morphism_schema_hash() {
let exec = Executor::load_module(treasury_module()).expect("load");
let h_deposit = exec
.schema_hash("register_cash_move")
.expect("register_cash_move has a hash");
let h_transfer = exec
.schema_hash("transfer_between_cajas")
.expect("transfer_between_cajas has a hash");
assert_ne!(
h_deposit, h_transfer,
"different morphisms must have different hashes"
);
assert!(
exec.schema_hash("not_a_real_morphism").is_none(),
"unknown morphisms have no hash"
);
// Re-loading the same module yields the same hashes — the contract
// depends only on the bytes on disk, not load-time state.
let exec2 = Executor::load_module(treasury_module()).expect("reload");
assert_eq!(exec.schema_hash("register_cash_move"), exec2.schema_hash("register_cash_move"));
}
#[test]
fn execute_and_log_writes_schema_hash_into_entries() {
let temp = TempModule::from(&treasury_module());
let exec = Executor::load_module(&temp.path).expect("load");
let log_path = fresh_log_path();
let mut log = EventLog::open(&log_path).unwrap();
let mut store = MemoryStore::new();
let a = Uuid::new_v4();
seed_caja(&exec, &mut store, &mut log, a);
deposit_5k(&exec, &mut store, &mut log, a);
let entries = log.entries().unwrap();
let morphism_entry = entries
.iter()
.find_map(|e| match e {
LogEntry::Morphism { schema_hash, .. } => Some(*schema_hash),
_ => None,
})
.expect("morphism entry present");
assert_eq!(
morphism_entry,
Some(exec.schema_hash("register_cash_move").unwrap()),
"logged hash must equal the executor's hash for that morphism"
);
let _ = std::fs::remove_file(&log_path);
}
#[test]
fn verify_log_passes_when_module_is_unchanged() {
let temp = TempModule::from(&treasury_module());
let exec = Executor::load_module(&temp.path).expect("load");
let log_path = fresh_log_path();
let mut log = EventLog::open(&log_path).unwrap();
let mut store = MemoryStore::new();
let a = Uuid::new_v4();
seed_caja(&exec, &mut store, &mut log, a);
deposit_5k(&exec, &mut store, &mut log, a);
verify_log(&log, &exec).expect("clean module → verify ok");
let _ = std::fs::remove_file(&log_path);
}
#[test]
fn verify_log_rejects_log_after_morphism_script_changes() {
let temp = TempModule::from(&treasury_module());
// Write a log under the original script.
let log_path = fresh_log_path();
let a = Uuid::new_v4();
let original_hash;
{
let exec = Executor::load_module(&temp.path).expect("load v1");
original_hash = exec.schema_hash("register_cash_move").unwrap();
let mut log = EventLog::open(&log_path).unwrap();
let mut store = MemoryStore::new();
seed_caja(&exec, &mut store, &mut log, a);
deposit_5k(&exec, &mut store, &mut log, a);
}
// Mutate the script with a real (non-cosmetic) change — prepend a
// new statement. The normalizer preserves this since it changes
// tokens, not just whitespace/comments.
let script_path = temp.path.join("morphisms/register_cash_move.rhai");
let original = std::fs::read_to_string(&script_path).expect("read script");
std::fs::write(
&script_path,
format!("let _audit_marker = 42;\n{}", original),
)
.expect("write script");
// Reload — the hash for register_cash_move must change.
let exec2 = Executor::load_module(&temp.path).expect("reload v2");
let new_hash = exec2.schema_hash("register_cash_move").unwrap();
assert_ne!(original_hash, new_hash, "real source edit must move the hash");
// verify_log must surface SchemaMismatch, not OpsMismatch — the
// schema check runs first because "rules changed" is more
// actionable than "ops differ for some reason."
let log = EventLog::open(&log_path).unwrap();
match verify_log(&log, &exec2) {
Err(VerifyError::SchemaMismatch {
morphism,
logged,
current,
..
}) => {
assert_eq!(morphism, "register_cash_move");
assert_eq!(logged, original_hash);
assert_eq!(current, new_hash);
}
other => panic!("expected SchemaMismatch, got {:?}", other),
}
// Replay still works — it doesn't validate against the executor.
let replayed = replay(&log).expect("replay is schema-agnostic");
assert!(replayed.records().contains_key("Caja"));
let _ = std::fs::remove_file(&log_path);
}
#[test]
fn legacy_log_without_schema_hash_still_replays_and_verifies() {
// Hand-craft a log entry that omits schema_hash entirely — what an
// older nakui-core would have written. The Option default lets it
// deserialize, replay walks ops the normal way, and verify_log
// skips the schema check because the entry predates the contract.
let log_path = fresh_log_path();
let a = Uuid::new_v4();
{
let exec = Executor::load_module(treasury_module()).expect("load");
let mut log = EventLog::open(&log_path).unwrap();
let mut store = MemoryStore::new();
seed_caja(&exec, &mut store, &mut log, a);
// Now write a Morphism entry by hand, bypassing execute_and_log,
// simulating a log produced by an older binary.
let entry: Value = json!({
"kind": "morphism",
"seq": log.next_seq(),
"morphism": "register_cash_move",
"inputs": {"caja": a.to_string()},
"params": {
"monto": 5_000,
"tipo": "in",
"timestamp": "2026-05-04T10:00:00Z",
"memo": "legacy",
"movimiento_id": Uuid::new_v4().to_string(),
},
"ops": []
// NOTE: no schema_hash field — that's the legacy shape.
});
// Append via raw IO to skip log.append's monotonic check (which
// we trivially satisfy anyway since seq is correct).
let line = serde_json::to_string(&entry).unwrap();
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&log_path)
.unwrap();
use std::io::Write;
f.write_all(line.as_bytes()).unwrap();
f.write_all(b"\n").unwrap();
f.sync_all().unwrap();
}
// Replay must succeed (no schema check).
let log = EventLog::open(&log_path).unwrap();
let entries = log.entries().expect("entries parse");
assert_eq!(entries.len(), 2, "seed + legacy morphism");
let legacy = entries
.iter()
.find_map(|e| match e {
LogEntry::Morphism { schema_hash, .. } => Some(*schema_hash),
_ => None,
})
.expect("morphism present");
assert!(
legacy.is_none(),
"legacy entry must deserialize with schema_hash=None"
);
let _ = std::fs::remove_file(&log_path);
}
#[test]
fn executor_exposes_schema_bundle_hash() {
let exec1 = Executor::load_module(treasury_module()).expect("load 1");
let exec2 = Executor::load_module(treasury_module()).expect("load 2");
assert_eq!(
exec1.schema_bundle_hash, exec2.schema_bundle_hash,
"bundle hash must be stable across re-loads of the same module"
);
// The bundle hash and the per-morphism hash live in different
// tag namespaces (`nakui-bundle-v1` vs `nakui-schema-v1`), so they
// can't accidentally collide even when the script bytes are
// empty/identical.
let morph_hash = exec1.schema_hash("register_cash_move").unwrap();
assert_ne!(exec1.schema_bundle_hash, morph_hash);
}
#[test]
fn seed_and_log_writes_bundle_hash_into_seed_entries() {
let exec = Executor::load_module(treasury_module()).expect("load");
let log_path = fresh_log_path();
let mut log = EventLog::open(&log_path).unwrap();
let mut store = MemoryStore::new();
let id = Uuid::new_v4();
seed_caja(&exec, &mut store, &mut log, id);
let entries = log.entries().unwrap();
let seed_hash = entries
.iter()
.find_map(|e| match e {
LogEntry::Seed { schema_hash, .. } => Some(*schema_hash),
_ => None,
})
.expect("seed entry present");
assert_eq!(
seed_hash,
Some(exec.schema_bundle_hash),
"logged seed hash must equal the executor's bundle hash"
);
let _ = std::fs::remove_file(&log_path);
}
#[test]
fn verify_log_rejects_seed_after_schema_kcl_changes() {
let temp = TempModule::from(&treasury_module());
let log_path = fresh_log_path();
let id = Uuid::new_v4();
let original_hash;
{
let exec = Executor::load_module(&temp.path).expect("load v1");
original_hash = exec.schema_bundle_hash;
let mut log = EventLog::open(&log_path).unwrap();
let mut store = MemoryStore::new();
seed_caja(&exec, &mut store, &mut log, id);
}
// Mutate schema.k. Even a comment is enough — bundle hash is byte-
// level for the same false-positive-over-false-negative reason as
// morphism hashes.
let schema_path = temp.path.join("schema.k");
let original = std::fs::read_to_string(&schema_path).expect("read schema");
std::fs::write(
&schema_path,
format!("{}\n# seed-versioning-test mutation\n", original),
)
.expect("write schema");
let exec2 = Executor::load_module(&temp.path).expect("reload v2");
let new_hash = exec2.schema_bundle_hash;
assert_ne!(original_hash, new_hash, "schema.k byte change must move the bundle hash");
let log = EventLog::open(&log_path).unwrap();
match verify_log(&log, &exec2) {
Err(VerifyError::SeedSchemaMismatch {
entity,
id: mismatched_id,
logged,
current,
..
}) => {
assert_eq!(entity, "Caja");
assert_eq!(mismatched_id, id);
assert_eq!(logged, original_hash);
assert_eq!(current, new_hash);
}
other => panic!("expected SeedSchemaMismatch, got {:?}", other),
}
let _ = std::fs::remove_file(&log_path);
}
#[test]
fn comment_only_edits_do_not_invalidate_the_hash() {
// The improvement that motivated the AST-aware normalization:
// operators leaving TODOs or whitespace edits in scripts no longer
// re-stamps every log entry. Same script behaviour ⇒ same hash.
let temp = TempModule::from(&treasury_module());
let exec1 = Executor::load_module(&temp.path).expect("load v1");
let h1 = exec1.schema_hash("register_cash_move").unwrap();
let script_path = temp.path.join("morphisms/register_cash_move.rhai");
let original = std::fs::read_to_string(&script_path).expect("read");
std::fs::write(
&script_path,
format!(
"// new top-level comment\n\n\n{}\n\n// trailing TODO\n/*\n block\n comment\n*/\n",
original.replace("// states.caja:", "// states.caja: EDITED COMMENT"),
),
)
.expect("write");
let exec2 = Executor::load_module(&temp.path).expect("reload v2");
let h2 = exec2.schema_hash("register_cash_move").unwrap();
assert_eq!(
h1, h2,
"comment-only and whitespace-only edits must not move the hash"
);
// Sanity: the bundle hash also stays intact (we didn't touch schema.k).
assert_eq!(exec1.schema_bundle_hash, exec2.schema_bundle_hash);
}
#[test]
fn morphism_script_change_does_not_flag_unrelated_seeds() {
// Bundle hash covers schema.k only — a .rhai edit moves the
// morphism hash but leaves the bundle hash alone. So existing
// seeds verify cleanly even when a morphism's behaviour changed.
let temp = TempModule::from(&treasury_module());
let log_path = fresh_log_path();
let id = Uuid::new_v4();
{
let exec = Executor::load_module(&temp.path).expect("load v1");
let mut log = EventLog::open(&log_path).unwrap();
let mut store = MemoryStore::new();
seed_caja(&exec, &mut store, &mut log, id);
// No morphism executed — only the seed is in the log.
}
// Modify a Rhai script. Bundle stays the same.
let script_path = temp.path.join("morphisms/register_cash_move.rhai");
let original = std::fs::read_to_string(&script_path).expect("read");
std::fs::write(&script_path, format!("{}\n// rhai-only mutation\n", original)).unwrap();
let exec2 = Executor::load_module(&temp.path).expect("reload");
let log = EventLog::open(&log_path).unwrap();
verify_log(&log, &exec2)
.expect("seed-only log should pass verify after a morphism-only change");
let _ = std::fs::remove_file(&log_path);
}
@@ -0,0 +1,400 @@
//! End-to-end tests for the snapshot lifecycle: capture, compact, and
//! boot from snapshot. Plus the schema-hash binding that ties a snapshot
//! to the bundle that produced it.
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::Duration;
use nakui_core::event_log::{
EventLog, Snapshot, SnapshotMismatchError, execute_and_log, replay, seed_and_log,
};
use nakui_core::executor::Executor;
use nakui_core::run::run_server;
use nakui_core::store::{MemoryStore, Store};
use serde_json::{Value, json};
use uuid::Uuid;
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root above core/")
.to_path_buf()
}
fn treasury_module() -> PathBuf {
workspace_root().join("modules/treasury")
}
fn fresh_log_path() -> PathBuf {
std::env::temp_dir().join(format!("nakui_snap_log_{}.jsonl", Uuid::new_v4()))
}
fn fresh_snap_path() -> PathBuf {
std::env::temp_dir().join(format!("nakui_snap_{}.json", Uuid::new_v4()))
}
fn fresh_socket_path() -> PathBuf {
std::env::temp_dir().join(format!("nakui_snap_run_{}.sock", Uuid::new_v4()))
}
fn seed_caja(exec: &Executor, store: &mut MemoryStore, log: &mut EventLog, id: Uuid, saldo: i64) {
seed_and_log(
exec,
store,
log,
"Caja",
id,
json!({"id": id.to_string(), "name": "A", "saldo": saldo, "currency": "USD"}),
)
.unwrap();
}
fn deposit(exec: &Executor, store: &mut MemoryStore, log: &mut EventLog, caja: Uuid, monto: i64) {
execute_and_log(
exec,
store,
log,
"register_cash_move",
&[("caja", caja)],
json!({
"monto": monto,
"tipo": "in",
"timestamp": "2026-05-04T10:00:00Z",
"memo": "x",
"movimiento_id": Uuid::new_v4().to_string(),
}),
)
.unwrap();
}
#[test]
fn module_schema_hash_is_stable_and_independent_of_load_order() {
let exec1 = Executor::load_module(treasury_module()).expect("load 1");
let exec2 = Executor::load_module(treasury_module()).expect("load 2");
assert_eq!(
exec1.module_schema_hash(),
exec2.module_schema_hash(),
"two clean loads of the same module → identical module hash"
);
}
#[test]
fn capture_records_executor_hash_legacy_does_not() {
let exec = Executor::load_module(treasury_module()).expect("load");
let mut store = MemoryStore::new();
store.seed("Caja", Uuid::new_v4(), json!({"x": 1}));
let captured = Snapshot::capture(&store, 0, &exec);
assert_eq!(captured.schema_hash, Some(exec.module_schema_hash()));
let legacy = Snapshot::from_memory_store(&store, 0);
assert_eq!(legacy.schema_hash, None, "legacy constructor opts out");
captured
.ensure_compatible_with(&exec)
.expect("captured snapshot is compatible with the executor that built it");
legacy
.ensure_compatible_with(&exec)
.expect("legacy snapshot has no hash → no check, passes");
}
#[test]
fn ensure_compatible_with_rejects_mismatched_hash() {
let exec = Executor::load_module(treasury_module()).expect("load");
let mut snap = Snapshot::capture(&MemoryStore::new(), 0, &exec);
// Tamper with the hash to simulate a snapshot from a different bundle.
snap.schema_hash = Some([0xAB; 32]);
match snap.ensure_compatible_with(&exec) {
Err(SnapshotMismatchError::SchemaMismatch { .. }) => {}
other => panic!("expected SchemaMismatch, got {:?}", other),
}
}
#[test]
fn snapshot_then_compact_then_run_server_resumes_correctly() {
// The full operator workflow:
// 1. Run a series of WAL-validated ops.
// 2. Capture a snapshot covering the last seq.
// 3. Compact the log so it only retains entries past snap.seq.
// 4. Start a server pointing at the (compacted) log + snapshot.
// 5. Confirm the server's state is correct via the load op.
//
// After step 3 the log alone can't reconstruct the state — the
// snapshot is the only thing that proves the server isn't lying.
let log_path = fresh_log_path();
let snap_path = fresh_snap_path();
let socket_path = fresh_socket_path();
let caja = Uuid::new_v4();
let snap_seq;
let captured_module_hash;
{
let exec = Executor::load_module(treasury_module()).expect("load");
captured_module_hash = exec.module_schema_hash();
let mut log = EventLog::open(&log_path).unwrap();
let mut store = MemoryStore::new();
seed_caja(&exec, &mut store, &mut log, caja, 100_000);
deposit(&exec, &mut store, &mut log, caja, 5_000);
deposit(&exec, &mut store, &mut log, caja, 7_500);
snap_seq = log.next_seq() - 1;
let snap = Snapshot::capture(&store, snap_seq, &exec);
snap.write(&snap_path).unwrap();
log.compact_through(snap_seq).unwrap();
// Sanity: after compaction the log has no surviving entries.
let surviving = log.entries().unwrap();
assert_eq!(surviving.len(), 0);
// But next_seq is preserved, so future appends keep monotonicity.
assert_eq!(log.next_seq(), snap_seq + 1);
}
// Verify the snapshot file carries the captured hash (resilient
// through write+read).
let reloaded = Snapshot::load(&snap_path).unwrap().unwrap();
assert_eq!(reloaded.schema_hash, Some(captured_module_hash));
assert_eq!(reloaded.seq, snap_seq);
// Boot the server with snapshot + compacted log.
let executor = Executor::load_module(treasury_module()).expect("reload");
let log = EventLog::open(&log_path).unwrap();
let store = MemoryStore::new();
let socket_for_client = socket_path.clone();
let client = thread::spawn(move || -> Result<(), String> {
let mut conn = connect_with_retry(&socket_for_client);
let resp = exchange(&mut conn, json!({"op": "load", "entity": "Caja", "id": caja.to_string()}));
if resp["value"]["saldo"].as_i64() != Some(112_500) {
return Err(format!(
"expected saldo 112_500 (100k seed + 5k + 7.5k from snapshot), got {}",
resp
));
}
// Append a new op via the live server and load it back —
// confirms the WAL still works on top of a snapshot-loaded state.
let resp = exchange(
&mut conn,
json!({
"op": "execute",
"morphism": "register_cash_move",
"inputs": {"caja": caja.to_string()},
"params": {
"monto": 1_000_i64,
"tipo": "in",
"timestamp": "2026-05-04T11:00:00Z",
"memo": "post-snap",
"movimiento_id": Uuid::new_v4().to_string(),
}
}),
);
if resp["ok"] != json!(true) {
return Err(format!("execute on snapshot-booted server failed: {}", resp));
}
let resp = exchange(&mut conn, json!({"op": "load", "entity": "Caja", "id": caja.to_string()}));
if resp["value"]["saldo"].as_i64() != Some(113_500) {
return Err(format!("post-execute saldo wrong: {}", resp));
}
send_shutdown(&mut conn);
Ok(())
});
run_server(executor, log, store, Some(reloaded), &socket_path).expect("server clean exit");
client.join().unwrap().expect("client assertions");
let _ = std::fs::remove_file(&log_path);
let _ = std::fs::remove_file(&snap_path);
}
#[test]
fn run_server_refuses_snapshot_with_wrong_schema_hash() {
let log_path = fresh_log_path();
let socket_path = fresh_socket_path();
let caja = Uuid::new_v4();
{
let exec = Executor::load_module(treasury_module()).expect("load");
let mut log = EventLog::open(&log_path).unwrap();
let mut store = MemoryStore::new();
seed_caja(&exec, &mut store, &mut log, caja, 100_000);
deposit(&exec, &mut store, &mut log, caja, 5_000);
}
// Build a snapshot with a fabricated hash — simulates "snapshot
// taken under module A, loaded against module B."
let exec = Executor::load_module(treasury_module()).expect("reload");
let log = EventLog::open(&log_path).unwrap();
let snap_state = replay(&log).unwrap();
let last_seq = log.entries().unwrap().last().unwrap().seq();
let mut bad_snap = Snapshot::capture(&snap_state, last_seq, &exec);
bad_snap.schema_hash = Some([0xAB; 32]);
let store = MemoryStore::new();
let result = run_server(exec, log, store, Some(bad_snap), &socket_path);
assert!(
matches!(
result,
Err(nakui_core::run::RunError::SnapshotMismatch(_))
),
"expected SnapshotMismatch, got {:?}",
result
);
// Socket must not have been bound.
assert!(!socket_path.exists());
let _ = std::fs::remove_file(&log_path);
}
#[test]
fn run_server_detects_gap_between_snapshot_and_compacted_log() {
// Snapshot says it covers up to seq K. Log was compacted further,
// so its first remaining entry is K+5 — entries K+1..=K+4 are
// gone. run_server must refuse rather than silently fabricate a
// state that drops events.
let log_path = fresh_log_path();
let socket_path = fresh_socket_path();
let caja = Uuid::new_v4();
let exec = Executor::load_module(treasury_module()).expect("load");
{
let mut log = EventLog::open(&log_path).unwrap();
let mut store = MemoryStore::new();
seed_caja(&exec, &mut store, &mut log, caja, 100_000);
deposit(&exec, &mut store, &mut log, caja, 1_000);
deposit(&exec, &mut store, &mut log, caja, 1_000);
deposit(&exec, &mut store, &mut log, caja, 1_000);
deposit(&exec, &mut store, &mut log, caja, 1_000);
deposit(&exec, &mut store, &mut log, caja, 1_000);
}
// Snapshot at seq 0 (only the seed).
let mut log = EventLog::open(&log_path).unwrap();
let mut state = MemoryStore::new();
nakui_core::event_log::replay_with_snapshot_into(&log, None, &mut state).unwrap();
let snap = Snapshot::capture(&state, 0, &exec);
// Compact the log past the snapshot — drop seqs 0..=3, leaving
// entries from seq 4 onward. The snapshot can't reconstruct the
// missing tail.
log.compact_through(3).unwrap();
drop(log);
let exec = Executor::load_module(treasury_module()).expect("reload");
let log = EventLog::open(&log_path).unwrap();
let store = MemoryStore::new();
let result = run_server(exec, log, store, Some(snap), &socket_path);
match result {
Err(nakui_core::run::RunError::SnapshotGap {
snap_seq,
log_first_seq,
expected,
}) => {
assert_eq!(snap_seq, 0);
assert_eq!(expected, 1);
assert!(
log_first_seq >= 4,
"log's first surviving entry should be ≥ 4, got {}",
log_first_seq
);
}
other => panic!("expected SnapshotGap, got {:?}", other),
}
let _ = std::fs::remove_file(&log_path);
}
#[test]
fn snapshot_write_overwrites_existing_atomically() {
// Two snapshots at different seqs written to the same path. The
// second must completely replace the first; load() returns the
// newer one.
let snap_path = fresh_snap_path();
let exec = Executor::load_module(treasury_module()).expect("load");
let s1 = Snapshot::capture(&MemoryStore::new(), 0, &exec);
s1.write(&snap_path).expect("write first");
let loaded = Snapshot::load(&snap_path).unwrap().unwrap();
assert_eq!(loaded.seq, 0);
// Now write a different snapshot to the same path.
let mut store = MemoryStore::new();
let id = Uuid::new_v4();
store.seed("Caja", id, json!({"id": id.to_string(), "saldo": 7}));
let s2 = Snapshot::capture(&store, 42, &exec);
s2.write(&snap_path).expect("overwrite");
let loaded = Snapshot::load(&snap_path).unwrap().unwrap();
assert_eq!(loaded.seq, 42, "second write must replace the first");
assert!(loaded.records.contains_key("Caja"));
// No leftover tempfile.
let writing_path = snap_path.with_extension("writing");
assert!(
!writing_path.exists(),
"tempfile must be renamed, not left behind"
);
let _ = std::fs::remove_file(&snap_path);
}
#[test]
fn snapshot_write_recovers_from_stale_tempfile() {
// A prior write crashed after creating .writing but before rename.
// The next write must succeed regardless — File::create truncates
// the stale tempfile.
let snap_path = fresh_snap_path();
let writing_path = snap_path.with_extension("writing");
// Plant a stale tempfile with garbage content.
std::fs::write(&writing_path, b"junk from a prior crashed write").unwrap();
assert!(writing_path.exists());
let exec = Executor::load_module(treasury_module()).expect("load");
let snap = Snapshot::capture(&MemoryStore::new(), 0, &exec);
snap.write(&snap_path).expect("write despite stale tempfile");
// Tempfile should be renamed (not orphaned), so it's gone.
assert!(
!writing_path.exists(),
"stale tempfile must be consumed by the rename"
);
let loaded = Snapshot::load(&snap_path).unwrap().unwrap();
assert_eq!(loaded.seq, 0);
let _ = std::fs::remove_file(&snap_path);
}
// === helpers shared with the run-server protocol tests ===
struct Conn {
writer: UnixStream,
reader: BufReader<UnixStream>,
}
fn connect_with_retry(path: &Path) -> Conn {
for _ in 0..200 {
if let Ok(stream) = UnixStream::connect(path) {
let reader_stream = stream.try_clone().expect("clone");
return Conn {
writer: stream,
reader: BufReader::new(reader_stream),
};
}
thread::sleep(Duration::from_millis(20));
}
panic!("server never started accepting on {}", path.display());
}
fn exchange(conn: &mut Conn, req: Value) -> Value {
let mut bytes = serde_json::to_vec(&req).unwrap();
bytes.push(b'\n');
conn.writer.write_all(&bytes).unwrap();
let mut line = String::new();
conn.reader.read_line(&mut line).unwrap();
serde_json::from_str(line.trim()).unwrap()
}
fn send_shutdown(conn: &mut Conn) {
let _ = exchange(conn, json!({"op": "shutdown"}));
}
@@ -0,0 +1,159 @@
//! Tests for the `Store::iter` / `Store::hash_state` contract under
//! realistic WAL flows: a live store and a log-replayed store must hash
//! identically, drift must be detectable as a hash mismatch, and the
//! property must hold across backends (within a backend — cross-backend
//! parity is a separate concern, see notes below).
use std::path::{Path, PathBuf};
use nakui_core::event_log::{EventLog, execute_and_log, replay, seed_and_log};
use nakui_core::executor::Executor;
use nakui_core::store::{MemoryStore, Store};
use serde_json::json;
use uuid::Uuid;
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root above core/")
.to_path_buf()
}
fn treasury_module() -> PathBuf {
workspace_root().join("modules/treasury")
}
fn fresh_log_path() -> PathBuf {
std::env::temp_dir().join(format!("nakui_hash_{}.jsonl", Uuid::new_v4()))
}
fn seed_two_cajas(
exec: &Executor,
store: &mut MemoryStore,
log: &mut EventLog,
a: Uuid,
b: Uuid,
) {
seed_and_log(
exec,
store,
log,
"Caja",
a,
json!({"id": a.to_string(), "name": "A", "saldo": 200_000_i64, "currency": "USD"}),
)
.unwrap();
seed_and_log(
exec,
store,
log,
"Caja",
b,
json!({"id": b.to_string(), "name": "B", "saldo": 50_000_i64, "currency": "USD"}),
)
.unwrap();
}
#[test]
fn live_store_hash_matches_replayed_store_hash() {
let exec = Executor::load_module(treasury_module()).expect("load");
let log_path = fresh_log_path();
let mut log = EventLog::open(&log_path).unwrap();
let mut live = MemoryStore::new();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
seed_two_cajas(&exec, &mut live, &mut log, a, b);
execute_and_log(
&exec,
&mut live,
&mut log,
"register_cash_move",
&[("caja", a)],
json!({
"monto": 25_000_i64,
"tipo": "in",
"timestamp": "2026-05-04T10:00:00Z",
"memo": "x",
"movimiento_id": Uuid::new_v4().to_string(),
}),
)
.unwrap();
execute_and_log(
&exec,
&mut live,
&mut log,
"transfer_between_cajas",
&[("source", a), ("dest", b)],
json!({
"monto": 75_000_i64,
"timestamp": "2026-05-04T10:30:00Z",
"memo": "xf",
"transfer_id": Uuid::new_v4().to_string(),
}),
)
.unwrap();
let replayed = replay(&log).expect("replay");
assert_eq!(
live.hash_state().unwrap(),
replayed.hash_state().unwrap(),
"live and replayed stores must hash identically"
);
let _ = std::fs::remove_file(&log_path);
}
#[test]
fn drift_is_detectable_via_hash_diff() {
let exec = Executor::load_module(treasury_module()).expect("load");
let log_path = fresh_log_path();
let mut log = EventLog::open(&log_path).unwrap();
let mut live = MemoryStore::new();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
seed_two_cajas(&exec, &mut live, &mut log, a, b);
let baseline = live.hash_state().unwrap();
let replayed_baseline = replay(&log).unwrap().hash_state().unwrap();
assert_eq!(baseline, replayed_baseline);
// Drift the live store out-of-band — exactly what the drift detector
// is meant to catch.
live.seed(
"Caja",
a,
json!({"id": a.to_string(), "name": "A", "saldo": 999_999_i64, "currency": "USD"}),
);
let drifted = live.hash_state().unwrap();
let log_canonical = replay(&log).unwrap().hash_state().unwrap();
assert_ne!(
drifted, log_canonical,
"the whole point of hash_state: this comparison must surface the drift"
);
let _ = std::fs::remove_file(&log_path);
}
#[test]
fn hash_state_is_stable_across_repeated_calls() {
// The hash must not drift just because we asked for it twice.
// Sounds obvious; protects against an iteration order that depends
// on a HashMap's per-process random seed sneaking past the sort.
let mut store = MemoryStore::new();
for _ in 0..10 {
let id = Uuid::new_v4();
store.seed(
"Caja",
id,
json!({"id": id.to_string(), "saldo": 100_i64, "currency": "USD"}),
);
}
let h1 = store.hash_state().unwrap();
let h2 = store.hash_state().unwrap();
assert_eq!(h1, h2, "hash must be a function of state, not call order");
}
@@ -0,0 +1,97 @@
//! Persistence test for SurrealStore against the RocksDB backend.
//!
//! Gated behind the `persistent` Cargo feature because RocksDB is a heavy
//! native dep (~5 min to compile cold). Run with:
//! cargo test --features persistent --test surreal_persist
#![cfg(feature = "persistent")]
use std::path::PathBuf;
use nakui_core::store::Store;
use nakui_core::surreal_store::SurrealStore;
use serde_json::{Value, json};
use uuid::Uuid;
fn fresh_db_path() -> PathBuf {
std::env::temp_dir().join(format!("nakui_persist_{}", Uuid::new_v4()))
}
#[test]
fn data_survives_close_and_reopen() {
let path = fresh_db_path();
let id = Uuid::new_v4();
{
let mut store = SurrealStore::new_persistent(&path).expect("open persistent");
store.seed(
"Caja",
id,
json!({
"id": id.to_string(),
"name": "persisted",
"saldo": 12_345_i64,
"currency": "USD",
}),
);
// Drop store; runtime + db released.
}
{
let store = SurrealStore::new_persistent(&path).expect("reopen persistent");
let loaded = store
.load("Caja", id)
.expect("record must survive reopen");
assert_eq!(
loaded.get("saldo").and_then(Value::as_i64),
Some(12_345),
"saldo persisted"
);
assert_eq!(
loaded.get("currency").and_then(Value::as_str),
Some("USD"),
"currency persisted"
);
}
let _ = std::fs::remove_dir_all(&path);
}
#[test]
fn applied_ops_persist_across_reopens() {
use nakui_core::delta::{FieldOp, FieldPath};
let path = fresh_db_path();
let id = Uuid::new_v4();
{
let mut store = SurrealStore::new_persistent(&path).expect("open");
store.seed(
"Caja",
id,
json!({"id": id.to_string(), "saldo": 100_i64, "currency": "USD"}),
);
store
.apply(&[FieldOp::Set {
path: FieldPath {
entity: "Caja".into(),
id,
field: "saldo".into(),
},
value: json!(999_i64),
}])
.expect("apply Set");
}
{
let store = SurrealStore::new_persistent(&path).expect("reopen");
let v = store.load("Caja", id).expect("present");
assert_eq!(
v.get("saldo").and_then(Value::as_i64),
Some(999),
"Set op persisted across restart"
);
}
let _ = std::fs::remove_dir_all(&path);
}
@@ -0,0 +1,537 @@
//! SurrealStore: kv-mem SurrealDB behind the same `Store` trait.
//!
//! Tests confirm: round-trip persistence preserving the application-level
//! `id` field, the dry-run contract, and the full WAL flow against the
//! real DB driver — execute_and_log → replay_into → live equals replayed.
use std::path::{Path, PathBuf};
use nakui_core::delta::{FieldOp, FieldPath};
use nakui_core::event_log::{
EventLog, execute_and_log, reconcile, replay_into, seed_and_log, verify_log,
};
use nakui_core::executor::Executor;
use nakui_core::store::{MemoryStore, Store, StoreError};
use nakui_core::surreal_store::SurrealStore;
use serde_json::{Value, json};
use uuid::Uuid;
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root above core/")
.to_path_buf()
}
fn treasury_module() -> PathBuf {
workspace_root().join("modules/treasury")
}
fn fresh_log_path() -> PathBuf {
std::env::temp_dir().join(format!("nakui_surreal_{}.jsonl", Uuid::new_v4()))
}
fn caja_data(id: Uuid, saldo: i64, currency: &str) -> Value {
json!({
"id": id.to_string(),
"name": "Caja",
"saldo": saldo,
"currency": currency,
})
}
#[test]
fn seed_then_load_preserves_application_id() {
let mut store = SurrealStore::new_in_memory().expect("surreal");
let id = Uuid::new_v4();
store.seed("Caja", id, caja_data(id, 100_000, "USD"));
let loaded = store.load("Caja", id).expect("loaded");
assert_eq!(
loaded.get("id").and_then(Value::as_str),
Some(id.to_string().as_str()),
"load must restore the application-level id field"
);
assert_eq!(loaded.get("saldo").and_then(Value::as_i64), Some(100_000));
assert_eq!(loaded.get("currency").and_then(Value::as_str), Some("USD"));
}
#[test]
fn apply_set_updates_field() {
let mut store = SurrealStore::new_in_memory().expect("surreal");
let id = Uuid::new_v4();
store.seed("Caja", id, caja_data(id, 100_000, "USD"));
store
.apply(&[FieldOp::Set {
path: FieldPath {
entity: "Caja".into(),
id,
field: "saldo".into(),
},
value: json!(250_000_i64),
}])
.expect("apply Set");
let loaded = store.load("Caja", id).expect("loaded");
assert_eq!(loaded.get("saldo").and_then(Value::as_i64), Some(250_000));
// Other fields preserved.
assert_eq!(loaded.get("currency").and_then(Value::as_str), Some("USD"));
}
#[test]
fn apply_create_persists_record() {
let mut store = SurrealStore::new_in_memory().expect("surreal");
let id = Uuid::new_v4();
store
.apply(&[FieldOp::Create {
entity: "Movimiento".into(),
id,
data: json!({
"id": id.to_string(),
"caja_id": Uuid::new_v4().to_string(),
"monto": 1000,
"tipo": "in",
"timestamp": "2026-05-04T00:00:00Z",
}),
}])
.expect("apply Create");
let loaded = store.load("Movimiento", id).expect("loaded");
assert_eq!(loaded.get("monto").and_then(Value::as_i64), Some(1000));
assert_eq!(loaded.get("tipo").and_then(Value::as_str), Some("in"));
}
#[test]
fn apply_delete_removes_record() {
let mut store = SurrealStore::new_in_memory().expect("surreal");
let id = Uuid::new_v4();
store.seed("Caja", id, caja_data(id, 100_000, "USD"));
store
.apply(&[FieldOp::Delete {
entity: "Caja".into(),
id,
}])
.expect("apply Delete");
assert!(store.load("Caja", id).is_none());
}
#[test]
fn dry_run_rejects_create_conflict() {
let mut store = SurrealStore::new_in_memory().expect("surreal");
let id = Uuid::new_v4();
store.seed("Caja", id, caja_data(id, 100, "USD"));
let result = store.apply_dry_run(&[FieldOp::Create {
entity: "Caja".into(),
id,
data: json!({"id": id.to_string()}),
}]);
assert!(matches!(result, Err(StoreError::Conflict(_, _))));
}
#[test]
fn dry_run_rejects_set_not_found() {
let store = SurrealStore::new_in_memory().expect("surreal");
let id = Uuid::new_v4();
let result = store.apply_dry_run(&[FieldOp::Set {
path: FieldPath {
entity: "Caja".into(),
id,
field: "saldo".into(),
},
value: json!(0),
}]);
assert!(matches!(result, Err(StoreError::NotFound(_, _))));
}
#[test]
fn full_wal_flow_against_surreal() {
let exec = Executor::load_module(treasury_module()).expect("load module");
let log_path = fresh_log_path();
let mut log = EventLog::open(&log_path).expect("open log");
let mut live = SurrealStore::new_in_memory().expect("live store");
let a = Uuid::new_v4();
let b = Uuid::new_v4();
seed_and_log(
&exec,
&mut live,
&mut log,
"Caja",
a,
caja_data(a, 200_000, "USD"),
)
.expect("seed A");
seed_and_log(
&exec,
&mut live,
&mut log,
"Caja",
b,
caja_data(b, 50_000, "USD"),
)
.expect("seed B");
execute_and_log(
&exec,
&mut live,
&mut log,
"register_cash_move",
&[("caja", a)],
json!({
"monto": 25_000_i64,
"tipo": "in",
"timestamp": "2026-05-04T10:00:00Z",
"memo": "test",
"movimiento_id": Uuid::new_v4().to_string(),
}),
)
.expect("deposit ok");
execute_and_log(
&exec,
&mut live,
&mut log,
"transfer_between_cajas",
&[("source", a), ("dest", b)],
json!({
"monto": 75_000_i64,
"timestamp": "2026-05-04T10:30:00Z",
"memo": "xfer",
"transfer_id": Uuid::new_v4().to_string(),
}),
)
.expect("transfer ok");
// Replay into a fresh SurrealStore and confirm field-by-field that
// saldos and entity counts match the live one.
let mut replayed = SurrealStore::new_in_memory().expect("replay store");
replay_into(&log, &mut replayed).expect("replay");
let live_a = live.load("Caja", a).expect("live A");
let replayed_a = replayed.load("Caja", a).expect("replayed A");
assert_eq!(
live_a.get("saldo").and_then(Value::as_i64),
replayed_a.get("saldo").and_then(Value::as_i64)
);
let live_b = live.load("Caja", b).expect("live B");
let replayed_b = replayed.load("Caja", b).expect("replayed B");
assert_eq!(
live_b.get("saldo").and_then(Value::as_i64),
replayed_b.get("saldo").and_then(Value::as_i64)
);
assert_eq!(live_a.get("saldo").and_then(Value::as_i64), Some(150_000));
assert_eq!(live_b.get("saldo").and_then(Value::as_i64), Some(125_000));
let _ = std::fs::remove_file(&log_path);
}
#[test]
fn verify_log_against_surreal_passes() {
let exec = Executor::load_module(treasury_module()).expect("load module");
let log_path = fresh_log_path();
let mut log = EventLog::open(&log_path).expect("open log");
let mut live = SurrealStore::new_in_memory().expect("live");
let a = Uuid::new_v4();
let b = Uuid::new_v4();
seed_and_log(&exec, &mut live, &mut log, "Caja", a, caja_data(a, 200_000, "USD")).unwrap();
seed_and_log(&exec, &mut live, &mut log, "Caja", b, caja_data(b, 50_000, "USD")).unwrap();
execute_and_log(
&exec,
&mut live,
&mut log,
"transfer_between_cajas",
&[("source", a), ("dest", b)],
json!({
"monto": 25_000_i64,
"timestamp": "2026-05-04T11:00:00Z",
"memo": "v",
"transfer_id": Uuid::new_v4().to_string(),
}),
)
.unwrap();
// verify_log internally creates its own MemoryStore for re-execution;
// even though `live` is SurrealStore, the determinism check is
// re-running each morphism through the kernel and comparing ops, so
// the verification store backend doesn't need to match the live one.
verify_log(&log, &exec).expect("re-execution must produce identical ops");
let _ = std::fs::remove_file(&log_path);
}
#[test]
fn replay_into_memorystore_from_surreal_run_log() {
// Ensure logs produced by SurrealStore-backed runs replay correctly
// into a *different* backend (MemoryStore). The log is the source of
// truth — backend choice shouldn't change the replay result.
let exec = Executor::load_module(treasury_module()).expect("load");
let log_path = fresh_log_path();
let mut log = EventLog::open(&log_path).expect("open");
let mut surreal_live = SurrealStore::new_in_memory().expect("surreal");
let a = Uuid::new_v4();
seed_and_log(
&exec,
&mut surreal_live,
&mut log,
"Caja",
a,
caja_data(a, 100_000, "USD"),
)
.unwrap();
execute_and_log(
&exec,
&mut surreal_live,
&mut log,
"register_cash_move",
&[("caja", a)],
json!({
"monto": 50_000_i64,
"tipo": "in",
"timestamp": "2026-05-04T08:00:00Z",
"memo": "x",
"movimiento_id": Uuid::new_v4().to_string(),
}),
)
.unwrap();
let mut mem_replay = MemoryStore::new();
replay_into(&log, &mut mem_replay).expect("replay");
let live_saldo = surreal_live
.load("Caja", a)
.and_then(|v| v.get("saldo").and_then(Value::as_i64))
.unwrap();
let replay_saldo = mem_replay
.load("Caja", a)
.and_then(|v| v.get("saldo").and_then(Value::as_i64))
.unwrap();
assert_eq!(live_saldo, replay_saldo);
assert_eq!(live_saldo, 150_000);
let _ = std::fs::remove_file(&log_path);
}
#[test]
fn clear_drops_all_records_across_tables() {
let mut store = SurrealStore::new_in_memory().expect("surreal");
let caja_id = Uuid::new_v4();
let mov_id = Uuid::new_v4();
store.seed("Caja", caja_id, caja_data(caja_id, 100_000, "USD"));
store.seed(
"Movimiento",
mov_id,
json!({
"id": mov_id.to_string(),
"caja_id": caja_id.to_string(),
"monto": 1_000,
"tipo": "in",
"timestamp": "2026-05-04T00:00:00Z",
}),
);
assert!(store.load("Caja", caja_id).is_some());
assert!(store.load("Movimiento", mov_id).is_some());
store.clear().expect("clear");
assert!(
store.load("Caja", caja_id).is_none(),
"clear must drop records from every table"
);
assert!(store.load("Movimiento", mov_id).is_none());
// Store is reusable after clear — seed a new record and load it back.
let fresh = Uuid::new_v4();
store.seed("Caja", fresh, caja_data(fresh, 1, "USD"));
assert!(store.load("Caja", fresh).is_some());
}
#[test]
fn cross_backend_hash_equals_for_equivalent_data() {
// The whole point of the canonical Value hasher: a SurrealStore
// and a MemoryStore that hold the same logical records must hash
// identically. Same WAL log replayed into each backend ⇒
// hash_state produces byte-equal output.
let exec = Executor::load_module(treasury_module()).expect("load module");
let log_path = fresh_log_path();
let mut log = EventLog::open(&log_path).expect("open log");
let mut surreal = SurrealStore::new_in_memory().expect("surreal");
let mut memory = MemoryStore::new();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
// Seed both backends through the WAL so they go through identical
// op sequences. We seed each backend separately because seed_and_log
// takes one store at a time.
seed_and_log(
&exec,
&mut surreal,
&mut log,
"Caja",
a,
caja_data(a, 200_000, "USD"),
)
.unwrap();
seed_and_log(
&exec,
&mut surreal,
&mut log,
"Caja",
b,
caja_data(b, 50_000, "USD"),
)
.unwrap();
execute_and_log(
&exec,
&mut surreal,
&mut log,
"register_cash_move",
&[("caja", a)],
json!({
"monto": 1_000_i64,
"tipo": "in",
"timestamp": "2026-05-04T08:00:00Z",
"memo": "x",
"movimiento_id": Uuid::new_v4().to_string(),
}),
)
.unwrap();
// Replay that same log into a fresh MemoryStore.
nakui_core::event_log::replay_into(&log, &mut memory).expect("replay");
let h_surreal = surreal.hash_state().expect("surreal hash");
let h_memory = memory.hash_state().expect("memory hash");
assert_eq!(
h_surreal, h_memory,
"MemoryStore and SurrealStore must hash identically for the same WAL state"
);
let _ = std::fs::remove_file(&log_path);
}
#[test]
fn iter_and_hash_state_round_trip_against_surreal() {
// Build the same WAL flow against two independent SurrealStores.
// Each store reaches the same logical state via a different path
// (one via execute_and_log, the other via replay_into) and they
// must hash identically — that's the contract drift detection
// sits on top of.
let exec = Executor::load_module(treasury_module()).expect("load module");
let log_path = fresh_log_path();
let mut log = EventLog::open(&log_path).expect("open log");
let mut live = SurrealStore::new_in_memory().expect("live");
let a = Uuid::new_v4();
let b = Uuid::new_v4();
seed_and_log(&exec, &mut live, &mut log, "Caja", a, caja_data(a, 200_000, "USD")).unwrap();
seed_and_log(&exec, &mut live, &mut log, "Caja", b, caja_data(b, 50_000, "USD")).unwrap();
execute_and_log(
&exec,
&mut live,
&mut log,
"register_cash_move",
&[("caja", a)],
json!({
"monto": 1_000_i64,
"tipo": "in",
"timestamp": "2026-05-04T08:00:00Z",
"memo": "x",
"movimiento_id": Uuid::new_v4().to_string(),
}),
)
.unwrap();
// iter must enumerate every record.
let recs: Vec<_> = live.iter().expect("iter").collect();
let by_entity: std::collections::HashMap<&str, usize> =
recs.iter().fold(std::collections::HashMap::new(), |mut m, (e, _, _)| {
*m.entry(e.as_str()).or_insert(0) += 1;
m
});
assert_eq!(by_entity.get("Caja").copied(), Some(2), "two Cajas");
assert_eq!(by_entity.get("Movimiento").copied(), Some(1), "one Movimiento");
// canonical order: entities sorted, ids byte-sorted within entity.
let entities: Vec<&str> = recs.iter().map(|(e, _, _)| e.as_str()).collect();
assert!(
entities.windows(2).all(|w| w[0] <= w[1]),
"entities must be sorted: {:?}",
entities
);
// Replay the log into a fresh SurrealStore — same hash.
let mut replayed = SurrealStore::new_in_memory().expect("replay store");
replay_into(&log, &mut replayed).expect("replay");
assert_eq!(
live.hash_state().unwrap(),
replayed.hash_state().unwrap(),
"live and replayed SurrealStores must hash identically"
);
// Drift detection: tamper one saldo and confirm the hash diverges.
live.seed("Caja", a, caja_data(a, 999_999, "USD"));
assert_ne!(
live.hash_state().unwrap(),
replayed.hash_state().unwrap(),
"out-of-band saldo change must show up as a hash mismatch"
);
let _ = std::fs::remove_file(&log_path);
}
#[test]
fn reconcile_rebuilds_drifted_surreal_store_from_log() {
let exec = Executor::load_module(treasury_module()).expect("load module");
let log_path = fresh_log_path();
let mut log = EventLog::open(&log_path).expect("open log");
let mut store = SurrealStore::new_in_memory().expect("surreal");
let a = Uuid::new_v4();
seed_and_log(&exec, &mut store, &mut log, "Caja", a, caja_data(a, 100_000, "USD")).unwrap();
execute_and_log(
&exec,
&mut store,
&mut log,
"register_cash_move",
&[("caja", a)],
json!({
"monto": 5_000_i64,
"tipo": "in",
"timestamp": "2026-05-04T10:00:00Z",
"memo": "x",
"movimiento_id": Uuid::new_v4().to_string(),
}),
)
.unwrap();
// Drift: a poison record nobody logged + an out-of-band saldo bump.
let ghost = Uuid::new_v4();
store.seed("Caja", ghost, caja_data(ghost, 0, "USD"));
store.seed("Caja", a, caja_data(a, 999_999, "USD"));
assert_eq!(
store.load("Caja", a).and_then(|v| v.get("saldo").and_then(Value::as_i64)),
Some(999_999),
"drift was applied"
);
reconcile(&mut store, &log).expect("reconcile");
// After reconcile: ghost gone, saldo = 100_000 (seed) + 5_000 (deposit).
assert!(store.load("Caja", ghost).is_none(), "poison record wiped");
assert_eq!(
store.load("Caja", a).and_then(|v| v.get("saldo").and_then(Value::as_i64)),
Some(105_000),
"reconcile must restore log-canonical saldo"
);
let _ = std::fs::remove_file(&log_path);
}