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
+38
View File
@@ -0,0 +1,38 @@
[package]
name = "nakui-core"
version = "0.1.0"
edition = "2021"
[features]
default = []
# Pulls in surrealdb's pure-Rust SurrealKV backend so SurrealStore can
# persist to disk across process restarts. Lighter compile cost than
# RocksDB (which would otherwise pull in a C++ build); opt-in only.
persistent = ["surrealdb/kv-surrealkv"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rhai = { version = "1.20", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
thiserror = "1"
petgraph = "0.6"
sha2 = "0.10"
surrealdb = { version = "2", default-features = false, features = ["kv-mem"] }
tokio = { version = "1", features = ["rt", "macros"] }
[[bin]]
name = "nakui"
path = "src/bin/nakui.rs"
[[bin]]
name = "demo"
path = "src/bin/demo.rs"
[[bin]]
name = "inventory_demo"
path = "src/bin/inventory_demo.rs"
[[bin]]
name = "sales_demo"
path = "src/bin/sales_demo.rs"
+224
View File
@@ -0,0 +1,224 @@
use nakui_core::event_log::{
EventLog, ExecuteError, execute_and_log, replay, seed_and_log, verify_log,
};
use nakui_core::executor::Executor;
use nakui_core::store::{MemoryStore, Store};
use serde_json::json;
use uuid::Uuid;
fn main() {
let module_dir =
std::env::var("NAKUI_MODULE").unwrap_or_else(|_| "modules/treasury".into());
let exec = Executor::load_module(&module_dir).expect("load module");
let log_path = std::env::temp_dir().join(format!("nakui_demo_{}.jsonl", Uuid::new_v4()));
let mut log = EventLog::open(&log_path).expect("open log");
let mut store = MemoryStore::new();
let caja_a = Uuid::new_v4();
let caja_b = Uuid::new_v4();
let caja_c = Uuid::new_v4();
seed_and_log(
&exec,
&mut store,
&mut log,
"Caja",
caja_a,
json!({
"id": caja_a.to_string(),
"name": "Caja Principal",
"saldo": 200_000_i64,
"currency": "USD",
}),
)
.expect("seed A");
seed_and_log(
&exec,
&mut store,
&mut log,
"Caja",
caja_b,
json!({
"id": caja_b.to_string(),
"name": "Caja Chica",
"saldo": 50_000_i64,
"currency": "USD",
}),
)
.expect("seed B");
seed_and_log(
&exec,
&mut store,
&mut log,
"Caja",
caja_c,
json!({
"id": caja_c.to_string(),
"name": "Caja EUR",
"saldo": 30_000_i64,
"currency": "EUR",
}),
)
.expect("seed C");
section("== seed ==");
print_caja(&store, "A", caja_a);
print_caja(&store, "B", caja_b);
print_caja(&store, "C", caja_c);
section("== A: deposit 50_000 USD ==");
run_and_report(
&exec,
&mut store,
&mut log,
"register_cash_move",
&[("caja", caja_a)],
json!({
"monto": 50_000_i64,
"tipo": "in",
"timestamp": "2026-05-04T12:00:00Z",
"memo": "deposito A",
"movimiento_id": Uuid::new_v4().to_string(),
}),
);
print_caja(&store, "A", caja_a);
section("== transfer A -> B 100_000 USD ==");
run_and_report(
&exec,
&mut store,
&mut log,
"transfer_between_cajas",
&[("source", caja_a), ("dest", caja_b)],
json!({
"monto": 100_000_i64,
"timestamp": "2026-05-04T12:30:00Z",
"memo": "transferencia operativa",
"transfer_id": Uuid::new_v4().to_string(),
}),
);
print_caja(&store, "A", caja_a);
print_caja(&store, "B", caja_b);
section("== transfer A -> B 999_999_999 USD (reject: post-check on source) ==");
run_and_report(
&exec,
&mut store,
&mut log,
"transfer_between_cajas",
&[("source", caja_a), ("dest", caja_b)],
json!({
"monto": 999_999_999_i64,
"timestamp": "2026-05-04T13:00:00Z",
"memo": "overdraw",
"transfer_id": Uuid::new_v4().to_string(),
}),
);
section("== transfer A(USD) -> C(EUR) (reject: rhai throws) ==");
run_and_report(
&exec,
&mut store,
&mut log,
"transfer_between_cajas",
&[("source", caja_a), ("dest", caja_c)],
json!({
"monto": 10_000_i64,
"timestamp": "2026-05-04T14:00:00Z",
"memo": "USD -> EUR",
"transfer_id": Uuid::new_v4().to_string(),
}),
);
section("== self-transfer A -> A (reject: DuplicateInputId) ==");
run_and_report(
&exec,
&mut store,
&mut log,
"transfer_between_cajas",
&[("source", caja_a), ("dest", caja_a)],
json!({
"monto": 1_000_i64,
"timestamp": "2026-05-04T15:00:00Z",
"memo": "self",
"transfer_id": Uuid::new_v4().to_string(),
}),
);
section("== final live state ==");
print_caja(&store, "A", caja_a);
print_caja(&store, "B", caja_b);
print_caja(&store, "C", caja_c);
let entries = log.entries().expect("read log");
section(&format!(
"== log: {} entries at {} ==",
entries.len(),
log.path().display()
));
for e in &entries {
match e {
nakui_core::event_log::LogEntry::Seed {
seq, entity, id, ..
} => println!(" #{:02} seed {} {}", seq, entity, id),
nakui_core::event_log::LogEntry::Morphism {
seq,
morphism,
ops,
..
} => println!(" #{:02} morph {} ({} ops)", seq, morphism, ops.len()),
}
}
section("== replay verification (state) ==");
let replayed = replay(&log).expect("replay");
if store == replayed {
println!(" ok: replayed store byte-equal to live store");
} else {
println!(" MISMATCH: replay diverges from live");
}
section("== determinism verification (ops) ==");
match verify_log(&log, &exec) {
Ok(()) => println!(
" ok: every logged morphism reproduced its ops on re-execution"
),
Err(e) => println!(" nondeterminism detected: {}", e),
}
if std::env::var_os("NAKUI_DEMO_KEEP").is_none() {
let _ = std::fs::remove_file(&log_path);
} else {
println!("\n(NAKUI_DEMO_KEEP set — keeping log at {})", log_path.display());
}
}
fn run_and_report(
exec: &Executor,
store: &mut MemoryStore,
log: &mut EventLog,
morphism: &str,
inputs: &[(&str, Uuid)],
params: serde_json::Value,
) {
match execute_and_log(exec, store, log, morphism, inputs, params) {
Ok(ops) => println!(" ok ({} ops, logged at #{})", ops.len(), log.next_seq() - 1),
Err(ExecuteError::PreLog(e)) => println!(" rejected: {}", e),
Err(ExecuteError::LogAppend(e)) => println!(" LOG APPEND FAILED: {}", e),
Err(ExecuteError::PostLogStore(e)) => println!(
" POST-LOG STORE FAILED (log is canonical, store stale): {}",
e
),
}
}
fn print_caja(store: &MemoryStore, label: &str, id: Uuid) {
let v = store.load("Caja", id).expect("caja exists");
let saldo = v.get("saldo").and_then(|v| v.as_i64()).unwrap_or(0);
let currency = v.get("currency").and_then(|v| v.as_str()).unwrap_or("?");
println!(" {} {}: saldo={} {}", label, id, saldo, currency);
}
fn section(title: &str) {
println!("\n{}", title);
}
@@ -0,0 +1,170 @@
use nakui_core::event_log::{
EventLog, ExecuteError, execute_and_log, replay, seed_and_log, verify_log,
};
use nakui_core::executor::Executor;
use nakui_core::store::{MemoryStore, Store};
use serde_json::json;
use uuid::Uuid;
fn main() {
let module_dir = std::env::var("NAKUI_MODULE")
.unwrap_or_else(|_| "modules/inventory".into());
let exec = Executor::load_module(&module_dir).expect("load module");
let log_path =
std::env::temp_dir().join(format!("nakui_inv_{}.jsonl", Uuid::new_v4()));
let mut log = EventLog::open(&log_path).expect("open log");
let mut store = MemoryStore::new();
// Two stocks of SKU "kg-cafe-honduras-2026" at warehouses A and B,
// plus a third stock of SKU "lt-aceite-girasol" at warehouse C.
let stock_a = Uuid::new_v4();
let stock_b = Uuid::new_v4();
let stock_c = Uuid::new_v4();
seed_and_log(
&exec,
&mut store, &mut log, "Stock", stock_a,
json!({
"id": stock_a.to_string(),
"sku_id": "kg-cafe-honduras-2026",
"ubicacion": "almacen-norte",
"cantidad": 500_i64,
}),
).expect("seed A");
seed_and_log(
&exec,
&mut store, &mut log, "Stock", stock_b,
json!({
"id": stock_b.to_string(),
"sku_id": "kg-cafe-honduras-2026",
"ubicacion": "almacen-sur",
"cantidad": 100_i64,
}),
).expect("seed B");
seed_and_log(
&exec,
&mut store, &mut log, "Stock", stock_c,
json!({
"id": stock_c.to_string(),
"sku_id": "lt-aceite-girasol",
"ubicacion": "almacen-sur",
"cantidad": 200_i64,
}),
).expect("seed C");
section("== seed ==");
print_stock(&store, "A (cafe norte)", stock_a);
print_stock(&store, "B (cafe sur)", stock_b);
print_stock(&store, "C (aceite sur)", stock_c);
section("== recibir 250 kg cafe en A ==");
run_and_report(&exec, &mut store, &mut log, "recibir_stock",
&[("stock", stock_a)],
json!({
"cantidad": 250_i64,
"timestamp": "2026-05-04T08:00:00Z",
"movimiento_id": Uuid::new_v4().to_string(),
}),
);
print_stock(&store, "A", stock_a);
section("== transferir 200 kg cafe A -> B (conserva por sku_id) ==");
run_and_report(&exec, &mut store, &mut log, "transferir_stock",
&[("source", stock_a), ("dest", stock_b)],
json!({
"cantidad": 200_i64,
"timestamp": "2026-05-04T09:00:00Z",
"transfer_id": Uuid::new_v4().to_string(),
}),
);
print_stock(&store, "A", stock_a);
print_stock(&store, "B", stock_b);
section("== transferir 999_999 kg cafe A -> B (reject: stock <= 0) ==");
run_and_report(&exec, &mut store, &mut log, "transferir_stock",
&[("source", stock_a), ("dest", stock_b)],
json!({
"cantidad": 999_999_i64,
"timestamp": "2026-05-04T10:00:00Z",
"transfer_id": Uuid::new_v4().to_string(),
}),
);
section("== transferir 50 cafe(A) -> aceite(C) (reject: rhai SKU mismatch) ==");
run_and_report(&exec, &mut store, &mut log, "transferir_stock",
&[("source", stock_a), ("dest", stock_c)],
json!({
"cantidad": 50_i64,
"timestamp": "2026-05-04T11:00:00Z",
"transfer_id": Uuid::new_v4().to_string(),
}),
);
section("== final live state ==");
print_stock(&store, "A", stock_a);
print_stock(&store, "B", stock_b);
print_stock(&store, "C", stock_c);
let entries = log.entries().expect("read log");
section(&format!(
"== log: {} entries at {} ==",
entries.len(),
log.path().display()
));
for e in &entries {
match e {
nakui_core::event_log::LogEntry::Seed { seq, entity, id, .. } =>
println!(" #{:02} seed {} {}", seq, entity, id),
nakui_core::event_log::LogEntry::Morphism { seq, morphism, ops, .. } =>
println!(" #{:02} morph {} ({} ops)", seq, morphism, ops.len()),
}
}
section("== replay verification (state) ==");
let replayed = replay(&log).expect("replay");
if store == replayed {
println!(" ok: replayed store byte-equal to live store");
} else {
println!(" MISMATCH");
}
section("== determinism verification (ops) ==");
match verify_log(&log, &exec) {
Ok(()) => println!(
" ok: every logged morphism reproduced its ops on re-execution"
),
Err(e) => println!(" nondeterminism detected: {}", e),
}
let _ = std::fs::remove_file(&log_path);
}
fn run_and_report(
exec: &Executor,
store: &mut MemoryStore,
log: &mut EventLog,
morphism: &str,
inputs: &[(&str, Uuid)],
params: serde_json::Value,
) {
match execute_and_log(exec, store, log, morphism, inputs, params) {
Ok(ops) => println!(" ok ({} ops, logged at #{})", ops.len(), log.next_seq() - 1),
Err(ExecuteError::PreLog(e)) => println!(" rejected: {}", e),
Err(ExecuteError::LogAppend(e)) => println!(" LOG APPEND FAILED: {}", e),
Err(ExecuteError::PostLogStore(e)) => println!(
" POST-LOG STORE FAILED (log canonical, store stale): {}", e
),
}
}
fn print_stock(store: &MemoryStore, label: &str, id: Uuid) {
let v = store.load("Stock", id).expect("stock exists");
let cantidad = v.get("cantidad").and_then(|v| v.as_i64()).unwrap_or(0);
let sku = v.get("sku_id").and_then(|v| v.as_str()).unwrap_or("?");
let loc = v.get("ubicacion").and_then(|v| v.as_str()).unwrap_or("?");
println!(" {}: cantidad={} sku={} ubic={}", label, cantidad, sku, loc);
}
fn section(title: &str) {
println!("\n{}", title);
}
+455
View File
@@ -0,0 +1,455 @@
//! `nakui` — operator CLI for inspecting, replaying, and verifying an
//! event log produced by the kernel. The three subcommands map to the
//! three things you need when something goes sideways in production:
//!
//! - `inspect` — what's in the log? (audit trail)
//! - `replay` — what state does the log produce? (recovery dry-run)
//! - `verify-log` — does every morphism still reproduce its ops?
//! (determinism contract — the regression alarm)
//!
//! Exit codes: 0 on success, 1 on operational error, 2 on bad arguments.
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::process::ExitCode;
use nakui_core::drift::{DriftDiff, check_against_socket};
use nakui_core::event_log::{
EventLog, LogEntry, Snapshot, replay_with_snapshot_into, verify_log,
};
use nakui_core::executor::Executor;
use nakui_core::run::run_server;
use nakui_core::store::MemoryStore;
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().collect();
let prog = args.first().cloned().unwrap_or_else(|| "nakui".into());
let sub = match args.get(1).map(String::as_str) {
Some(s) => s,
None => {
print_usage(&prog);
return ExitCode::from(2);
}
};
let rest = &args[2..];
let result = match sub {
"inspect" => cmd_inspect(rest),
"replay" => cmd_replay(rest),
"verify-log" => cmd_verify_log(rest),
"run" => cmd_run(rest),
"drift" => cmd_drift(rest),
"snapshot" => cmd_snapshot(rest),
"compact" => cmd_compact(rest),
"-h" | "--help" | "help" => {
print_usage(&prog);
return ExitCode::SUCCESS;
}
other => {
eprintln!("nakui: unknown subcommand `{}`", other);
print_usage(&prog);
return ExitCode::from(2);
}
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(CliError::BadArgs(msg)) => {
eprintln!("nakui: {}", msg);
print_usage(&prog);
ExitCode::from(2)
}
Err(CliError::Op(msg)) => {
eprintln!("nakui: {}", msg);
ExitCode::from(1)
}
// Drift uses its own exit code so callers can distinguish "the
// tool failed" (1) from "the tool worked and detected drift" (3).
Err(CliError::DriftDetected) => ExitCode::from(3),
}
}
enum CliError {
BadArgs(String),
Op(String),
DriftDetected,
}
fn print_usage(prog: &str) {
eprintln!(
"usage:
{p} inspect --log <path>
{p} replay --log <path> [--snapshot <path>]
{p} verify-log --log <path> --module <dir>
{p} run --log <path> --module <dir> --socket <path>
[--snapshot <path>] [--store-path <dir>]
{p} drift --log <path> --against <socket>
{p} snapshot --log <path> --module <dir> --out <path>
{p} compact --log <path> --snapshot <path>
--store-path activates persistent SurrealStore (kv-surrealkv);
requires the binary to be built with `--features persistent`.",
p = prog
);
}
/// Minimal flag parser: `--name value` pairs, no `=` form, no clustering.
/// Returns a map of name -> value. Unknown flags are an error so typos
/// surface immediately instead of silently being ignored.
fn parse_flags(args: &[String], allowed: &[&str]) -> Result<BTreeMap<String, String>, CliError> {
let mut out = BTreeMap::new();
let mut i = 0;
while i < args.len() {
let flag = &args[i];
if !flag.starts_with("--") {
return Err(CliError::BadArgs(format!(
"expected --flag, got `{}`",
flag
)));
}
let name = &flag[2..];
if !allowed.contains(&name) {
return Err(CliError::BadArgs(format!("unknown flag `--{}`", name)));
}
let val = args.get(i + 1).ok_or_else(|| {
CliError::BadArgs(format!("flag `--{}` requires a value", name))
})?;
out.insert(name.to_string(), val.clone());
i += 2;
}
Ok(out)
}
fn require<'a>(
flags: &'a BTreeMap<String, String>,
name: &str,
) -> Result<&'a String, CliError> {
flags
.get(name)
.ok_or_else(|| CliError::BadArgs(format!("missing required flag `--{}`", name)))
}
fn cmd_inspect(args: &[String]) -> Result<(), CliError> {
let flags = parse_flags(args, &["log"])?;
let log_path = PathBuf::from(require(&flags, "log")?);
let log = EventLog::open(&log_path).map_err(|e| CliError::Op(format!("open log: {}", e)))?;
let entries = log
.entries()
.map_err(|e| CliError::Op(format!("read log: {}", e)))?;
println!("log: {}", log.path().display());
println!("entries: {}", entries.len());
if entries.is_empty() {
return Ok(());
}
println!("seq range: {}..={}", entries[0].seq(), entries.last().unwrap().seq());
println!();
for e in &entries {
match e {
LogEntry::Seed {
seq, entity, id, ..
} => println!(" #{:04} seed {} {}", seq, entity, id),
LogEntry::Morphism {
seq,
morphism,
ops,
inputs,
..
} => {
let inputs_s = inputs
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join(", ");
println!(
" #{:04} morph {} ({} ops) [{}]",
seq,
morphism,
ops.len(),
inputs_s
);
}
}
}
Ok(())
}
fn cmd_replay(args: &[String]) -> Result<(), CliError> {
let flags = parse_flags(args, &["log", "snapshot"])?;
let log_path = PathBuf::from(require(&flags, "log")?);
let log = EventLog::open(&log_path).map_err(|e| CliError::Op(format!("open log: {}", e)))?;
let snapshot = if let Some(p) = flags.get("snapshot") {
let path = PathBuf::from(p);
Snapshot::load(&path)
.map_err(|e| CliError::Op(format!("load snapshot: {}", e)))?
.ok_or_else(|| CliError::Op(format!("snapshot not found: {}", path.display())))?
.into()
} else {
None::<Snapshot>
};
let mut store = MemoryStore::new();
replay_with_snapshot_into(&log, snapshot.as_ref(), &mut store)
.map_err(|e| CliError::Op(format!("replay: {}", e)))?;
let entries = log
.entries()
.map_err(|e| CliError::Op(format!("read log: {}", e)))?;
let last_seq = entries.last().map(|e| e.seq().to_string()).unwrap_or_else(|| "<empty>".into());
println!("replayed log: {}", log.path().display());
if let Some(snap) = &snapshot {
println!("snapshot: seq {} (covers seq <= {})", snap.seq, snap.seq);
}
println!("last seq: {}", last_seq);
println!("entities:");
let mut by_entity: Vec<(&String, usize)> = store
.records()
.iter()
.map(|(k, v)| (k, v.len()))
.collect();
by_entity.sort_by(|a, b| a.0.cmp(b.0));
if by_entity.is_empty() {
println!(" (none)");
} else {
for (entity, count) in by_entity {
println!(" {:<20} {}", entity, count);
}
}
Ok(())
}
fn cmd_drift(args: &[String]) -> Result<(), CliError> {
let flags = parse_flags(args, &["log", "against"])?;
let log_path = PathBuf::from(require(&flags, "log")?);
let socket_path = PathBuf::from(require(&flags, "against")?);
let report = check_against_socket(&log_path, &socket_path)
.map_err(|e| CliError::Op(format!("drift check: {}", e)))?;
let log_hex = hex_encode(&report.log_hash);
let server_hex = hex_encode(&report.server_hash);
if report.in_sync() {
println!(
"ok: in sync (hash {}, {} records)",
short_hash(&log_hex),
report.log_records
);
return Ok(());
}
println!("DRIFT detected");
println!(
" log replay: hash {} ({} records)",
log_hex, report.log_records
);
println!(
" server state: hash {} ({} records)",
server_hex, report.server_records
);
println!();
println!("diffs:");
for d in &report.diffs {
match d {
DriftDiff::OnlyOnServer { entity, id, .. } => {
println!(" + {} {} (only on server)", entity, id);
}
DriftDiff::OnlyInLog { entity, id, .. } => {
println!(" - {} {} (only in log replay)", entity, id);
}
DriftDiff::Tampered {
entity,
id,
log_value,
server_value,
} => {
println!(
" ~ {} {} (tampered)\n log: {}\n server: {}",
entity, id, log_value, server_value
);
}
}
}
Err(CliError::DriftDetected)
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
fn short_hash(hex: &str) -> String {
if hex.len() <= 12 {
hex.to_string()
} else {
format!("{}{}", &hex[..6], &hex[hex.len() - 4..])
}
}
fn cmd_run(args: &[String]) -> Result<(), CliError> {
let flags = parse_flags(args, &["log", "module", "socket", "snapshot", "store-path"])?;
let log_path = PathBuf::from(require(&flags, "log")?);
let module_dir = PathBuf::from(require(&flags, "module")?);
let socket_path = PathBuf::from(require(&flags, "socket")?);
let snapshot_path = flags.get("snapshot").map(PathBuf::from);
let store_path = flags.get("store-path").map(PathBuf::from);
eprintln!(
"nakui run: module={} log={} socket={} snapshot={} store={}",
module_dir.display(),
log_path.display(),
socket_path.display(),
snapshot_path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<none>".into()),
store_path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<memory>".into()),
);
let executor = Executor::load_module(&module_dir)
.map_err(|e| CliError::Op(format!("load module {}: {}", module_dir.display(), e)))?;
let log = EventLog::open(&log_path).map_err(|e| CliError::Op(format!("open log: {}", e)))?;
let snapshot = match &snapshot_path {
Some(p) => Some(
Snapshot::load(p)
.map_err(|e| CliError::Op(format!("load snapshot: {}", e)))?
.ok_or_else(|| {
CliError::Op(format!("snapshot file does not exist: {}", p.display()))
})?,
),
None => None,
};
if let Some(p) = store_path {
run_persistent(executor, log, snapshot, &socket_path, &p)
} else {
let store = MemoryStore::new();
run_server(executor, log, store, snapshot, &socket_path)
.map_err(|e| CliError::Op(format!("run: {}", e)))
}
}
#[cfg(feature = "persistent")]
fn run_persistent(
executor: Executor,
log: EventLog,
snapshot: Option<Snapshot>,
socket_path: &std::path::Path,
store_path: &std::path::Path,
) -> Result<(), CliError> {
use nakui_core::surreal_store::SurrealStore;
let store = SurrealStore::new_persistent(store_path).map_err(|e| {
CliError::Op(format!(
"open persistent store at {}: {}",
store_path.display(),
e
))
})?;
run_server(executor, log, store, snapshot, socket_path)
.map_err(|e| CliError::Op(format!("run: {}", e)))
}
#[cfg(not(feature = "persistent"))]
fn run_persistent(
_executor: Executor,
_log: EventLog,
_snapshot: Option<Snapshot>,
_socket_path: &std::path::Path,
_store_path: &std::path::Path,
) -> Result<(), CliError> {
Err(CliError::Op(
"--store-path requires building with `--features persistent`".into(),
))
}
fn cmd_snapshot(args: &[String]) -> Result<(), CliError> {
let flags = parse_flags(args, &["log", "module", "out"])?;
let log_path = PathBuf::from(require(&flags, "log")?);
let module_dir = PathBuf::from(require(&flags, "module")?);
let out_path = PathBuf::from(require(&flags, "out")?);
let exec = Executor::load_module(&module_dir)
.map_err(|e| CliError::Op(format!("load module {}: {}", module_dir.display(), e)))?;
let log = EventLog::open(&log_path).map_err(|e| CliError::Op(format!("open log: {}", e)))?;
let mut store = MemoryStore::new();
replay_with_snapshot_into(&log, None, &mut store)
.map_err(|e| CliError::Op(format!("replay: {}", e)))?;
let last_seq = log
.entries()
.map_err(|e| CliError::Op(format!("read log: {}", e)))?
.last()
.map(|e| e.seq())
.ok_or_else(|| CliError::Op("log is empty; nothing to snapshot".into()))?;
let snap = Snapshot::capture(&store, last_seq, &exec);
snap.write(&out_path)
.map_err(|e| CliError::Op(format!("write snapshot: {}", e)))?;
let entity_count: usize = store.records().values().map(|m| m.len()).sum();
println!(
"snapshot written to {} (seq {}, {} records, schema {})",
out_path.display(),
last_seq,
entity_count,
short_hash(&hex_encode(&exec.module_schema_hash())),
);
Ok(())
}
fn cmd_compact(args: &[String]) -> Result<(), CliError> {
let flags = parse_flags(args, &["log", "snapshot"])?;
let log_path = PathBuf::from(require(&flags, "log")?);
let snap_path = PathBuf::from(require(&flags, "snapshot")?);
let snap = Snapshot::load(&snap_path)
.map_err(|e| CliError::Op(format!("load snapshot: {}", e)))?
.ok_or_else(|| CliError::Op(format!("snapshot not found: {}", snap_path.display())))?;
let mut log = EventLog::open(&log_path).map_err(|e| CliError::Op(format!("open log: {}", e)))?;
let before = log
.entries()
.map(|es| es.len())
.map_err(|e| CliError::Op(format!("read log: {}", e)))?;
log.compact_through(snap.seq)
.map_err(|e| CliError::Op(format!("compact: {}", e)))?;
let after = log
.entries()
.map(|es| es.len())
.map_err(|e| CliError::Op(format!("read log: {}", e)))?;
println!(
"compacted {} through seq {} ({}{} entries; {} dropped)",
log_path.display(),
snap.seq,
before,
after,
before.saturating_sub(after),
);
Ok(())
}
fn cmd_verify_log(args: &[String]) -> Result<(), CliError> {
let flags = parse_flags(args, &["log", "module"])?;
let log_path = PathBuf::from(require(&flags, "log")?);
let module_dir = PathBuf::from(require(&flags, "module")?);
let exec = Executor::load_module(&module_dir)
.map_err(|e| CliError::Op(format!("load module {}: {}", module_dir.display(), e)))?;
let log = EventLog::open(&log_path).map_err(|e| CliError::Op(format!("open log: {}", e)))?;
match verify_log(&log, &exec) {
Ok(()) => {
let n = log
.entries()
.map(|es| es.len())
.map_err(|e| CliError::Op(format!("read log: {}", e)))?;
println!("ok: {} entries; every morphism reproduced its ops", n);
Ok(())
}
Err(e) => Err(CliError::Op(format!("verify failed: {}", e))),
}
}
@@ -0,0 +1,176 @@
//! Cross-module demo: a `vender` morphism that touches a Stock entity
//! (defined in inventory's schema) and a Caja entity (defined in
//! treasury's schema). The sales module's `nsmc.json` lists three schema
//! files; the executor concatenates them at load time so KCL validates
//! against all three.
use nakui_core::event_log::{
EventLog, ExecuteError, execute_and_log, replay, seed_and_log, verify_log,
};
use nakui_core::executor::Executor;
use nakui_core::store::{MemoryStore, Store};
use serde_json::json;
use uuid::Uuid;
fn main() {
let module_dir = std::env::var("NAKUI_MODULE")
.unwrap_or_else(|_| "modules/sales".into());
let exec = Executor::load_module(&module_dir).expect("load module");
let log_path =
std::env::temp_dir().join(format!("nakui_sales_{}.jsonl", Uuid::new_v4()));
let mut log = EventLog::open(&log_path).expect("open log");
let mut store = MemoryStore::new();
let stock_id = Uuid::new_v4();
let caja_id = Uuid::new_v4();
seed_and_log(
&exec,
&mut store, &mut log, "Stock", stock_id,
json!({
"id": stock_id.to_string(),
"sku_id": "kg-cafe-honduras-2026",
"ubicacion": "almacen-norte",
"cantidad": 500_i64,
}),
).expect("seed stock");
seed_and_log(
&exec,
&mut store, &mut log, "Caja", caja_id,
json!({
"id": caja_id.to_string(),
"name": "Caja Principal",
"saldo": 1_000_000_i64, // $10_000.00 in cents
"currency": "USD",
}),
).expect("seed caja");
section("== seed ==");
print_stock(&store, "stock", stock_id);
print_caja(&store, "caja", caja_id);
// 1. Sell 100 kg cafe at $50.00 / kg = $5000.00 total.
section("== vender 100 kg @ $50.00 c/u ==");
run_and_report(&exec, &mut store, &mut log, "vender",
&[("stock", stock_id), ("caja", caja_id)],
json!({
"cantidad": 100_i64,
"precio_unitario": 5_000_i64, // $50.00 in cents
"timestamp": "2026-05-04T10:00:00Z",
"venta_id": Uuid::new_v4().to_string(),
}),
);
print_stock(&store, "stock", stock_id);
print_caja(&store, "caja", caja_id);
// 2. Try selling more than available stock — should fail Stock post-check.
section("== vender 9999 kg (reject: stock <= 0) ==");
run_and_report(&exec, &mut store, &mut log, "vender",
&[("stock", stock_id), ("caja", caja_id)],
json!({
"cantidad": 9999_i64,
"precio_unitario": 1_000_i64,
"timestamp": "2026-05-04T11:00:00Z",
"venta_id": Uuid::new_v4().to_string(),
}),
);
// 3. Negative price — caught by Rhai.
section("== vender con precio negativo (reject: rhai throw) ==");
run_and_report(&exec, &mut store, &mut log, "vender",
&[("stock", stock_id), ("caja", caja_id)],
json!({
"cantidad": 10_i64,
"precio_unitario": -100_i64,
"timestamp": "2026-05-04T11:30:00Z",
"venta_id": Uuid::new_v4().to_string(),
}),
);
// 4. Another good sale.
section("== vender 50 kg @ $60.00 c/u ==");
run_and_report(&exec, &mut store, &mut log, "vender",
&[("stock", stock_id), ("caja", caja_id)],
json!({
"cantidad": 50_i64,
"precio_unitario": 6_000_i64,
"timestamp": "2026-05-04T12:00:00Z",
"venta_id": Uuid::new_v4().to_string(),
}),
);
print_stock(&store, "stock", stock_id);
print_caja(&store, "caja", caja_id);
section("== final live state ==");
print_stock(&store, "stock", stock_id);
print_caja(&store, "caja", caja_id);
let entries = log.entries().expect("read log");
section(&format!(
"== log: {} entries at {} ==",
entries.len(),
log.path().display()
));
for e in &entries {
match e {
nakui_core::event_log::LogEntry::Seed { seq, entity, id, .. } =>
println!(" #{:02} seed {} {}", seq, entity, id),
nakui_core::event_log::LogEntry::Morphism { seq, morphism, ops, .. } =>
println!(" #{:02} morph {} ({} ops)", seq, morphism, ops.len()),
}
}
section("== replay verification (state) ==");
let replayed = replay(&log).expect("replay");
if store == replayed {
println!(" ok: replayed store byte-equal to live store");
} else {
println!(" MISMATCH");
}
section("== determinism verification (ops) ==");
match verify_log(&log, &exec) {
Ok(()) => println!(
" ok: every logged morphism reproduced its ops on re-execution"
),
Err(e) => println!(" nondeterminism detected: {}", e),
}
let _ = std::fs::remove_file(&log_path);
}
fn run_and_report(
exec: &Executor,
store: &mut MemoryStore,
log: &mut EventLog,
morphism: &str,
inputs: &[(&str, Uuid)],
params: serde_json::Value,
) {
match execute_and_log(exec, store, log, morphism, inputs, params) {
Ok(ops) => println!(" ok ({} ops, logged at #{})", ops.len(), log.next_seq() - 1),
Err(ExecuteError::PreLog(e)) => println!(" rejected: {}", e),
Err(ExecuteError::LogAppend(e)) => println!(" LOG APPEND FAILED: {}", e),
Err(ExecuteError::PostLogStore(e)) => println!(
" POST-LOG STORE FAILED (log canonical, store stale): {}", e
),
}
}
fn print_stock(store: &MemoryStore, label: &str, id: Uuid) {
let v = store.load("Stock", id).expect("stock exists");
let cantidad = v.get("cantidad").and_then(|v| v.as_i64()).unwrap_or(0);
let sku = v.get("sku_id").and_then(|v| v.as_str()).unwrap_or("?");
println!(" {} cantidad={} sku={}", label, cantidad, sku);
}
fn print_caja(store: &MemoryStore, label: &str, id: Uuid) {
let v = store.load("Caja", id).expect("caja exists");
let saldo = v.get("saldo").and_then(|v| v.as_i64()).unwrap_or(0);
let cur = v.get("currency").and_then(|v| v.as_str()).unwrap_or("?");
println!(" {} saldo={} {} (en centavos)", label, saldo, cur);
}
fn section(title: &str) {
println!("\n{}", title);
}
+72
View File
@@ -0,0 +1,72 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FieldPath {
pub entity: String,
pub id: Uuid,
pub field: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum FieldOp {
Set {
path: FieldPath,
value: Value,
},
Create {
entity: String,
id: Uuid,
data: Value,
},
Delete {
entity: String,
id: Uuid,
},
}
impl FieldOp {
/// Token a manifest's `writes` list matches against.
/// "Caja.saldo" for field updates, "Movimiento" for whole-record ops.
pub fn capability_token(&self) -> String {
match self {
FieldOp::Set { path, .. } => format!("{}.{}", path.entity, path.field),
FieldOp::Create { entity, .. } => entity.clone(),
FieldOp::Delete { entity, .. } => entity.clone(),
}
}
}
/// Apply only the ops that target `(entity, id)` to `state` and return the
/// new value. Returns `None` if a Delete op removes the target — callers
/// should skip post-checks against a deleted entity rather than running
/// them against the stale prior state.
pub fn simulate_on(state: &Value, entity: &str, id: Uuid, ops: &[FieldOp]) -> Option<Value> {
let mut s: Option<Value> = Some(state.clone());
for op in ops {
match op {
FieldOp::Set { path, value } if path.entity == entity && path.id == id => {
if let Some(Value::Object(map)) = s.as_mut() {
map.insert(path.field.clone(), value.clone());
}
}
FieldOp::Create {
entity: e,
id: i,
data,
} if e == entity && *i == id => {
s = Some(data.clone());
}
FieldOp::Delete {
entity: e,
id: i,
} if e == entity && *i == id => {
s = None;
}
_ => {}
}
}
s
}
+496
View File
@@ -0,0 +1,496 @@
//! Drift detection: compare two snapshots of store state and surface
//! the records that differ.
//!
//! "Drift" here means the live store has departed from what the log can
//! reproduce. The `Store::hash_state` contract makes the binary check
//! cheap (32 bytes); when those disagree, `compare_states` walks both
//! enumerations and produces a diff list the operator can act on.
//!
//! No IO in this module. The wire bits (asking a `nakui run` server for
//! its hash and records) live in the CLI; this is the pure comparison
//! used by both the CLI and any future automated drift-watcher.
use serde::Serialize;
use serde_json::Value;
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::Path;
use thiserror::Error;
use uuid::Uuid;
use crate::event_log::{EventLog, replay};
use crate::store::Store;
/// A single record-level difference between two snapshots. Variants are
/// labeled from the perspective of the operator running the check: the
/// "log" side is the canonical state (what the log replays to), the
/// "server" side is the live state being audited.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum DriftDiff {
/// Server has a record the log doesn't know about. Phantom data —
/// either an out-of-band write, or a successful op that never
/// reached the WAL (which would itself be a kernel bug).
OnlyOnServer {
entity: String,
id: Uuid,
value: Value,
},
/// Log expects a record the server lost. Either the server's apply
/// rolled back without a reconcile, or someone deleted a record
/// out-of-band.
OnlyInLog {
entity: String,
id: Uuid,
value: Value,
},
/// Same (entity, id) on both sides but the values differ — the most
/// dangerous case, because it means a logged event was overwritten
/// or a field was tampered with.
Tampered {
entity: String,
id: Uuid,
log_value: Value,
server_value: Value,
},
}
#[derive(Debug, Clone, Serialize)]
pub struct DriftReport {
pub log_hash: [u8; 32],
pub server_hash: [u8; 32],
pub log_records: usize,
pub server_records: usize,
/// Empty iff the two snapshots are byte-identical. Sorted by
/// (entity, id_bytes) so two runs against the same drift produce
/// the same report.
pub diffs: Vec<DriftDiff>,
}
impl DriftReport {
pub fn in_sync(&self) -> bool {
self.log_hash == self.server_hash && self.diffs.is_empty()
}
}
/// Pure comparison: take two canonical-order enumerations (as returned
/// by `Store::iter`) plus their hashes, and return the diff list.
///
/// Inputs need not be pre-sorted — we re-key by (entity, id) and walk
/// the union — but if the iterators were produced via `Store::iter`,
/// they're already in canonical order and the report's `diffs` will be
/// emitted in that same order.
pub fn compare_states(
log_records: Vec<(String, Uuid, Value)>,
log_hash: [u8; 32],
server_records: Vec<(String, Uuid, Value)>,
server_hash: [u8; 32],
) -> DriftReport {
let log_count = log_records.len();
let server_count = server_records.len();
let mut log_map: HashMap<(String, Uuid), Value> = log_records
.into_iter()
.map(|(e, id, v)| ((e, id), v))
.collect();
let server_map: HashMap<(String, Uuid), Value> = server_records
.into_iter()
.map(|(e, id, v)| ((e, id), v))
.collect();
let mut diffs: Vec<DriftDiff> = Vec::new();
for ((entity, id), server_value) in &server_map {
match log_map.remove(&(entity.clone(), *id)) {
None => diffs.push(DriftDiff::OnlyOnServer {
entity: entity.clone(),
id: *id,
value: server_value.clone(),
}),
Some(log_value) => {
if log_value != *server_value {
diffs.push(DriftDiff::Tampered {
entity: entity.clone(),
id: *id,
log_value,
server_value: server_value.clone(),
});
}
}
}
}
// Whatever is left in log_map is missing on the server.
for ((entity, id), value) in log_map {
diffs.push(DriftDiff::OnlyInLog { entity, id, value });
}
// Canonical sort: (entity, id_bytes), then by variant kind so
// diff ordering is fully deterministic even when the same key
// appears (which it can't here, but defensively).
diffs.sort_by(|a, b| {
let (ea, ia) = key(a);
let (eb, ib) = key(b);
ea.cmp(eb)
.then_with(|| ia.as_bytes().cmp(ib.as_bytes()))
.then_with(|| variant_order(a).cmp(&variant_order(b)))
});
DriftReport {
log_hash,
server_hash,
log_records: log_count,
server_records: server_count,
diffs,
}
}
fn key(d: &DriftDiff) -> (&str, &Uuid) {
match d {
DriftDiff::OnlyOnServer { entity, id, .. }
| DriftDiff::OnlyInLog { entity, id, .. }
| DriftDiff::Tampered { entity, id, .. } => (entity.as_str(), id),
}
}
fn variant_order(d: &DriftDiff) -> u8 {
match d {
DriftDiff::OnlyInLog { .. } => 0,
DriftDiff::Tampered { .. } => 1,
DriftDiff::OnlyOnServer { .. } => 2,
}
}
#[derive(Debug, Error)]
pub enum DriftError {
#[error("open log: {0}")]
Log(#[from] crate::event_log::LogError),
#[error("replay log: {0}")]
Replay(#[from] crate::event_log::ReplayError),
#[error("store: {0}")]
Store(#[from] crate::store::StoreError),
#[error("connect to server socket: {0}")]
Connect(#[source] std::io::Error),
#[error("server io: {0}")]
Io(#[from] std::io::Error),
#[error("server response not json: {0}")]
Parse(#[from] serde_json::Error),
#[error("server returned error for `{op}`: {msg}")]
Server { op: String, msg: String },
#[error("server response missing field `{field}` for op `{op}`")]
MissingField { op: String, field: String },
#[error("server hash `{0}` is not 32 hex bytes")]
BadHash(String),
}
/// Audit a live `nakui run` server against the canonical state derived
/// from a log file.
///
/// Cheap path: ask the server for `hash_state`, replay the log locally,
/// hash that. If the hashes match, we return immediately with an empty
/// diff list — no large `dump_records` round-trip.
///
/// Expensive path: hashes differ. Pull the full record dump from the
/// server, run `compare_states`, return the structured report.
pub fn check_against_socket(
log_path: &Path,
socket_path: &Path,
) -> Result<DriftReport, DriftError> {
// Local: replay log → MemoryStore, snapshot.
let log = EventLog::open(log_path)?;
let local_store = replay(&log)?;
let local_records: Vec<(String, Uuid, Value)> = local_store.iter()?.collect();
let local_hash = local_store.hash_state()?;
// Wire: open the connection once and reuse it for both requests.
let stream = UnixStream::connect(socket_path).map_err(DriftError::Connect)?;
let mut conn = SocketClient::new(stream)?;
// Cheap path.
let hash_resp = conn.exchange(serde_json::json!({"op": "hash_state"}))?;
require_ok(&hash_resp, "hash_state")?;
let server_hash = parse_hash(&hash_resp, "hash_state")?;
let server_count = hash_resp
.get("records")
.and_then(Value::as_u64)
.ok_or_else(|| DriftError::MissingField {
op: "hash_state".into(),
field: "records".into(),
})? as usize;
if server_hash == local_hash {
return Ok(DriftReport {
log_hash: local_hash,
server_hash,
log_records: local_records.len(),
server_records: server_count,
diffs: Vec::new(),
});
}
// Expensive path: pull the full server snapshot.
let dump_resp = conn.exchange(serde_json::json!({"op": "dump_records"}))?;
require_ok(&dump_resp, "dump_records")?;
let server_records = parse_records(&dump_resp)?;
Ok(compare_states(
local_records,
local_hash,
server_records,
server_hash,
))
}
struct SocketClient {
writer: UnixStream,
reader: BufReader<UnixStream>,
}
impl SocketClient {
fn new(stream: UnixStream) -> Result<Self, DriftError> {
let reader_stream = stream.try_clone()?;
Ok(Self {
writer: stream,
reader: BufReader::new(reader_stream),
})
}
fn exchange(&mut self, req: Value) -> Result<Value, DriftError> {
let mut bytes = serde_json::to_vec(&req).expect("request serializes");
bytes.push(b'\n');
self.writer.write_all(&bytes)?;
let mut line = String::new();
let n = self.reader.read_line(&mut line)?;
if n == 0 {
return Err(DriftError::Io(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"server closed connection without responding",
)));
}
Ok(serde_json::from_str(line.trim())?)
}
}
fn require_ok(resp: &Value, op: &str) -> Result<(), DriftError> {
if resp.get("ok").and_then(Value::as_bool) == Some(true) {
Ok(())
} else {
Err(DriftError::Server {
op: op.into(),
msg: resp
.get("error")
.and_then(Value::as_str)
.unwrap_or("(no error message)")
.to_string(),
})
}
}
fn parse_hash(resp: &Value, op: &str) -> Result<[u8; 32], DriftError> {
let s = resp
.get("hash")
.and_then(Value::as_str)
.ok_or_else(|| DriftError::MissingField {
op: op.into(),
field: "hash".into(),
})?;
if s.len() != 64 {
return Err(DriftError::BadHash(s.into()));
}
let mut out = [0u8; 32];
for (i, byte) in out.iter_mut().enumerate() {
let hi = hex_nibble(s.as_bytes()[i * 2]).ok_or_else(|| DriftError::BadHash(s.into()))?;
let lo =
hex_nibble(s.as_bytes()[i * 2 + 1]).ok_or_else(|| DriftError::BadHash(s.into()))?;
*byte = (hi << 4) | lo;
}
Ok(out)
}
fn hex_nibble(c: u8) -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
}
fn parse_records(resp: &Value) -> Result<Vec<(String, Uuid, Value)>, DriftError> {
let arr = resp
.get("records")
.and_then(Value::as_array)
.ok_or_else(|| DriftError::MissingField {
op: "dump_records".into(),
field: "records".into(),
})?;
let mut out: Vec<(String, Uuid, Value)> = Vec::with_capacity(arr.len());
for item in arr {
let entity = item
.get("entity")
.and_then(Value::as_str)
.ok_or_else(|| DriftError::MissingField {
op: "dump_records".into(),
field: "records[].entity".into(),
})?
.to_string();
let id_str = item
.get("id")
.and_then(Value::as_str)
.ok_or_else(|| DriftError::MissingField {
op: "dump_records".into(),
field: "records[].id".into(),
})?;
let id = Uuid::parse_str(id_str).map_err(|_| DriftError::MissingField {
op: "dump_records".into(),
field: format!("records[].id (not uuid: {})", id_str),
})?;
let value = item
.get("value")
.cloned()
.ok_or_else(|| DriftError::MissingField {
op: "dump_records".into(),
field: "records[].value".into(),
})?;
out.push((entity, id, value));
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn h(byte: u8) -> [u8; 32] {
[byte; 32]
}
#[test]
fn empty_inputs_yield_no_diffs() {
let report = compare_states(Vec::new(), h(0), Vec::new(), h(0));
assert!(report.in_sync());
assert!(report.diffs.is_empty());
}
#[test]
fn equal_records_yield_no_diffs_even_if_hashes_were_lied_to() {
// The function compares records, not hashes — hash equality is
// the operator's fast-path, but the report's truth is the diffs.
let a = Uuid::new_v4();
let log = vec![(
"Caja".to_string(),
a,
json!({"saldo": 100}),
)];
let server = vec![(
"Caja".to_string(),
a,
json!({"saldo": 100}),
)];
let report = compare_states(log, h(1), server, h(2));
assert!(report.diffs.is_empty(), "records equal → no diffs");
}
#[test]
fn detects_only_on_server() {
let a = Uuid::new_v4();
let b = Uuid::new_v4();
let log = vec![(
"Caja".to_string(),
a,
json!({"saldo": 100}),
)];
let server = vec![
("Caja".to_string(), a, json!({"saldo": 100})),
("Caja".to_string(), b, json!({"saldo": 999})),
];
let report = compare_states(log, h(0), server, h(1));
assert_eq!(report.diffs.len(), 1);
match &report.diffs[0] {
DriftDiff::OnlyOnServer { entity, id, .. } => {
assert_eq!(entity, "Caja");
assert_eq!(*id, b);
}
other => panic!("expected OnlyOnServer, got {:?}", other),
}
}
#[test]
fn detects_only_in_log() {
let a = Uuid::new_v4();
let log = vec![("Caja".to_string(), a, json!({"saldo": 100}))];
let server = vec![];
let report = compare_states(log, h(0), server, h(1));
assert_eq!(report.diffs.len(), 1);
match &report.diffs[0] {
DriftDiff::OnlyInLog { id, .. } => assert_eq!(*id, a),
other => panic!("expected OnlyInLog, got {:?}", other),
}
}
#[test]
fn detects_tampered() {
let a = Uuid::new_v4();
let log = vec![("Caja".to_string(), a, json!({"saldo": 100}))];
let server = vec![("Caja".to_string(), a, json!({"saldo": 999}))];
let report = compare_states(log, h(0), server, h(1));
assert_eq!(report.diffs.len(), 1);
match &report.diffs[0] {
DriftDiff::Tampered {
id,
log_value,
server_value,
..
} => {
assert_eq!(*id, a);
assert_eq!(log_value["saldo"], json!(100));
assert_eq!(server_value["saldo"], json!(999));
}
other => panic!("expected Tampered, got {:?}", other),
}
}
#[test]
fn diffs_emerge_in_canonical_order() {
// Two entities, mixed drift kinds. Result must be sorted by
// (entity, id_bytes) so two runs produce the same report.
let id_caja = Uuid::nil(); // sorts first byte-wise
let id_mov = Uuid::from_u128(u128::MAX);
let log = vec![
("Movimiento".to_string(), id_mov, json!({"x": 1})),
];
let server = vec![
("Caja".to_string(), id_caja, json!({"saldo": 0})),
];
let report = compare_states(log, h(0), server, h(1));
assert_eq!(report.diffs.len(), 2);
// Caja sorts before Movimiento.
match (&report.diffs[0], &report.diffs[1]) {
(DriftDiff::OnlyOnServer { entity: e1, .. }, DriftDiff::OnlyInLog { entity: e2, .. }) => {
assert_eq!(e1, "Caja");
assert_eq!(e2, "Movimiento");
}
other => panic!("unexpected order: {:?}", other),
}
}
#[test]
fn in_sync_requires_both_hashes_and_no_diffs() {
// Defensive: if hashes match but somehow diffs is non-empty
// (caller mismatch), in_sync says no.
let report = DriftReport {
log_hash: h(0),
server_hash: h(0),
log_records: 1,
server_records: 1,
diffs: vec![DriftDiff::Tampered {
entity: "x".into(),
id: Uuid::nil(),
log_value: json!(1),
server_value: json!(2),
}],
};
assert!(!report.in_sync());
}
}
+687
View File
@@ -0,0 +1,687 @@
//! Append-only event log for deterministic replay.
//!
//! Two entry kinds:
//! - `Seed`: an externally-provided initial record (the system boundary).
//! - `Morphism`: a successful kernel-validated morphism call, with the
//! produced ops attached.
//!
//! `replay()` reconstructs a store by reading the log and applying ops
//! directly — fast, no script execution. `verify_log()` re-runs every
//! morphism through the kernel and asserts the recomputed ops match the
//! logged ones, which is the operational definition of determinism.
//!
//! Failures are never logged: a rejected morphism produces no event.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{BTreeMap, HashMap};
use std::fs::OpenOptions;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use thiserror::Error;
use uuid::Uuid;
use crate::delta::FieldOp;
use crate::executor::{ExecError, Executor};
use crate::store::{MemoryStore, Store, StoreError};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum LogEntry {
Seed {
seq: u64,
entity: String,
id: Uuid,
data: Value,
/// Bundle hash (just the KCL schemas) at the moment this seed
/// was logged. `None` for pre-versioning entries — `verify_log`
/// skips the schema check on those. New writes always populate
/// it via `seed_and_log`.
#[serde(default, skip_serializing_if = "Option::is_none")]
schema_hash: Option<[u8; 32]>,
},
Morphism {
seq: u64,
morphism: String,
inputs: BTreeMap<String, Uuid>,
params: Value,
ops: Vec<FieldOp>,
/// Hash of (kcl bundle | manifest spec | rhai script bytes) at
/// the moment this event was logged. `None` for pre-versioning
/// entries — `verify_log` skips the schema check on those (they
/// predate the contract). New writes always populate it.
#[serde(default, skip_serializing_if = "Option::is_none")]
schema_hash: Option<[u8; 32]>,
},
}
impl LogEntry {
pub fn seq(&self) -> u64 {
match self {
LogEntry::Seed { seq, .. } => *seq,
LogEntry::Morphism { seq, .. } => *seq,
}
}
}
#[derive(Debug, Error)]
pub enum LogError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("parse at line {line}: {source}")]
Parse {
line: usize,
#[source]
source: serde_json::Error,
},
#[error("non-monotonic seq: got {got}, expected {expected}")]
NonMonotonic { got: u64, expected: u64 },
}
/// Errors from `execute_and_log`. The variants distinguish *when in the
/// pipeline* the failure occurred — which determines whether the log was
/// updated and whether the live store is still consistent.
#[derive(Debug, Error)]
pub enum ExecuteError {
/// Failure before the log was written. Store untouched, log untouched.
/// Safe to retry with the same inputs.
#[error("pre-log validation failed: {0}")]
PreLog(#[from] ExecError),
/// Log append failed (typically IO). Store untouched, log untouched.
/// Safe to retry once the log backend recovers.
#[error("log append failed: {0}")]
LogAppend(#[from] LogError),
/// Apply to the store failed AFTER the event was logged. The log is
/// canonical; the live store is now stale and should be rebuilt by
/// replaying the log. Retrying the same morphism is incorrect — the
/// event is already on disk.
#[error("store apply failed after log was committed (log is canonical, store stale): {0}")]
PostLogStore(crate::store::StoreError),
}
#[derive(Debug, Error)]
pub enum ReplayError {
#[error("log: {0}")]
Log(#[from] LogError),
#[error("store: {0}")]
Store(#[from] StoreError),
}
/// A reconcile rebuilds a stale store from the log. Either the wipe step
/// or the replay step can fail.
#[derive(Debug, Error)]
pub enum ReconcileError {
#[error("clearing store before replay failed: {0}")]
Clear(#[source] StoreError),
#[error("replay into cleared store failed: {0}")]
Replay(#[from] ReplayError),
}
/// Outcome of `execute_and_log_with_recovery`. PreLog/LogAppend mirror the
/// pre-WAL-fence variants of `ExecuteError` — the store is untouched and
/// the caller can retry. `Unrecoverable` means the WAL fence was crossed
/// (event is canonical on disk) but reconcile *also* failed: the operator
/// must intervene before any further writes.
#[derive(Debug, Error)]
pub enum RecoverableExecuteError {
#[error("pre-log validation failed: {0}")]
PreLog(#[from] ExecError),
#[error("log append failed: {0}")]
LogAppend(#[from] LogError),
#[error(
"store apply failed AND reconcile failed — log is canonical, store is in an unknown state. apply: {post_log}; reconcile: {reconcile}"
)]
Unrecoverable {
#[source]
post_log: StoreError,
reconcile: ReconcileError,
},
}
#[derive(Debug, Error)]
pub enum VerifyError {
#[error("log: {0}")]
Log(#[from] LogError),
#[error("morphism replay failed at seq {seq}: {source}")]
Exec {
seq: u64,
#[source]
source: ExecError,
},
#[error(
"non-determinism at seq {seq} morphism `{morphism}`: recomputed ops differ from logged ops"
)]
OpsMismatch {
seq: u64,
morphism: String,
expected: Vec<FieldOp>,
actual: Vec<FieldOp>,
},
/// The morphism was logged under a different schema/script bundle
/// than the one currently loaded. Re-executing it would (likely)
/// produce different ops, but the more specific signal is "the
/// rules changed since this was logged" — actionable: migrate the
/// log, or pin the executor to a compatible version.
#[error(
"schema mismatch at seq {seq} morphism `{morphism}`: logged schema_hash differs from current executor"
)]
SchemaMismatch {
seq: u64,
morphism: String,
logged: [u8; 32],
current: [u8; 32],
},
/// A `Seed` entry was logged under a different KCL bundle than the
/// one currently loaded. The seed's data may no longer fit the
/// entity definition. Coarser than `SchemaMismatch` (any change
/// to any schema file flips it, even one that doesn't affect the
/// seeded entity) but the operator still wants to know.
#[error(
"seed schema mismatch at seq {seq} entity `{entity}` id {id}: logged bundle hash differs from current executor"
)]
SeedSchemaMismatch {
seq: u64,
entity: String,
id: Uuid,
logged: [u8; 32],
current: [u8; 32],
},
}
pub struct EventLog {
path: PathBuf,
next_seq: u64,
}
impl EventLog {
/// Open or create a log at `path`. Reads existing entries to compute
/// `next_seq` and validate monotonicity. The first entry can start at
/// any seq (compacted logs are rooted at seq > 0); subsequent entries
/// must be strictly contiguous.
pub fn open(path: impl Into<PathBuf>) -> Result<Self, LogError> {
let path = path.into();
let mut next_seq: u64 = 0;
if path.exists() {
let entries = read_entries(&path)?;
let mut iter = entries.iter();
if let Some(first) = iter.next() {
next_seq = first.seq() + 1;
for e in iter {
if e.seq() != next_seq {
return Err(LogError::NonMonotonic {
got: e.seq(),
expected: next_seq,
});
}
next_seq = e.seq() + 1;
}
}
}
Ok(Self { path, next_seq })
}
pub fn next_seq(&self) -> u64 {
self.next_seq
}
pub fn path(&self) -> &Path {
&self.path
}
/// Append an entry. Calls `sync_all()` so the entry is durable on disk
/// before returning Ok — this is the WAL fence: by the time the caller
/// proceeds to mutate the store, the event is recoverable from a power
/// loss.
pub fn append(&mut self, entry: LogEntry) -> Result<(), LogError> {
if entry.seq() != self.next_seq {
return Err(LogError::NonMonotonic {
got: entry.seq(),
expected: self.next_seq,
});
}
let mut f = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
let s = serde_json::to_string(&entry).expect("LogEntry serializes");
f.write_all(s.as_bytes())?;
f.write_all(b"\n")?;
f.sync_all()?;
self.next_seq += 1;
Ok(())
}
pub fn entries(&self) -> Result<Vec<LogEntry>, LogError> {
if !self.path.exists() {
return Ok(Vec::new());
}
read_entries(&self.path)
}
/// Truncate the log to drop entries with `seq <= through_seq`.
/// IRREVERSIBLE: caller must verify a Snapshot covering `through_seq`
/// exists on durable storage before calling this — once the entries
/// are gone, replay can only start from the snapshot.
///
/// Atomic at the filesystem level: writes survivors to a sibling
/// tempfile then renames over the original.
pub fn compact_through(&mut self, through_seq: u64) -> Result<(), LogError> {
let survivors: Vec<LogEntry> = self
.entries()?
.into_iter()
.filter(|e| e.seq() > through_seq)
.collect();
let tmp = self.path.with_extension("compacting");
{
let mut f = std::fs::File::create(&tmp)?;
for e in &survivors {
let s = serde_json::to_string(e).expect("LogEntry serializes");
f.write_all(s.as_bytes())?;
f.write_all(b"\n")?;
}
f.sync_all()?;
}
std::fs::rename(&tmp, &self.path)?;
sync_parent_dir(&self.path)?;
Ok(())
}
}
/// Open and fsync the parent directory of `target`. After an atomic
/// rename, the directory entry change isn't durable until the directory
/// itself is fsynced — without this, a kernel/power crash between the
/// rename and the next disk flush could leave the directory in a state
/// where the rename never happened (depending on filesystem journal
/// mode). With it, the rename survives.
///
/// Best-effort on platforms where opening a directory for sync isn't
/// permitted: the syscalls are POSIX-portable across Linux, macOS, and
/// the BSDs (the OSes Nakui targets), so this generally succeeds. A
/// failure here is propagated as an IO error so the caller can choose
/// to surface it; we prefer "loud" over "silent" for durability code.
fn sync_parent_dir(target: &Path) -> std::io::Result<()> {
let parent = target.parent().unwrap_or_else(|| Path::new("."));
let dir = std::fs::File::open(parent)?;
dir.sync_all()
}
/// A snapshot of a `Store`'s state at a particular log seq. Lets us short-
/// circuit replay: load the snapshot, then apply only the events with
/// `seq > snapshot.seq`. MemoryStore-specific for V1 — backends that
/// already persist (SurrealStore + RocksDB) don't need this layer.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Snapshot {
/// The last log seq this snapshot subsumes. `replay` resumes at seq+1.
pub seq: u64,
/// Full state at that seq, in MemoryStore's native shape.
pub records: HashMap<String, HashMap<Uuid, Value>>,
/// Module schema hash at capture time. `Some` for snapshots taken
/// via `capture(_, _, executor)`; `None` for those taken via the
/// hash-unaware `from_memory_store`. Loaders use this to refuse a
/// snapshot produced under a different bundle.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema_hash: Option<[u8; 32]>,
}
#[derive(Debug, Error)]
pub enum SnapshotMismatchError {
#[error(
"snapshot schema_hash differs from current executor; refusing to load (snapshot was taken under a different module bundle)"
)]
SchemaMismatch {
snapshot: [u8; 32],
current: [u8; 32],
},
}
impl Snapshot {
/// Capture the in-memory store's current state without binding to a
/// schema bundle. Test fixtures and ad-hoc tooling call this; the
/// production path uses `capture` so the snapshot can be validated
/// against the executor on load.
pub fn from_memory_store(store: &MemoryStore, seq: u64) -> Self {
Self {
seq,
records: store.records().clone(),
schema_hash: None,
}
}
/// Production capture: stamp the snapshot with the executor's
/// `module_schema_hash` so future loads can refuse a mismatch.
pub fn capture(store: &MemoryStore, seq: u64, executor: &Executor) -> Self {
Self {
seq,
records: store.records().clone(),
schema_hash: Some(executor.module_schema_hash()),
}
}
/// Verify the snapshot was produced under a bundle compatible with
/// `executor`. Snapshots without a hash (legacy / `from_memory_store`)
/// pass — the operator opted out of this check at capture time.
pub fn ensure_compatible_with(
&self,
executor: &Executor,
) -> Result<(), SnapshotMismatchError> {
let Some(snap_hash) = self.schema_hash else {
return Ok(());
};
let current = executor.module_schema_hash();
if snap_hash != current {
return Err(SnapshotMismatchError::SchemaMismatch {
snapshot: snap_hash,
current,
});
}
Ok(())
}
/// Atomically write the snapshot to `path`. Writes the bytes to a
/// sibling tempfile (`<path>.writing`), fsyncs, renames over the
/// target, then fsyncs the parent directory so the rename survives
/// a crash. A crash mid-write leaves either the previous snapshot
/// at `path` (rename never happened) or the new one (rename
/// completed and was durable) — never a truncated file. A stale
/// tempfile from a prior crash gets overwritten by `File::create`
/// on the next attempt, so writes are also self-healing.
pub fn write(&self, path: &Path) -> Result<(), LogError> {
let data = serde_json::to_vec_pretty(self).expect("snapshot serializes");
let tmp = path.with_extension("writing");
{
let mut f = std::fs::File::create(&tmp)?;
f.write_all(&data)?;
f.sync_all()?;
}
std::fs::rename(&tmp, path)?;
sync_parent_dir(path).map_err(LogError::Io)
}
pub fn load(path: &Path) -> Result<Option<Self>, LogError> {
if !path.exists() {
return Ok(None);
}
let text = std::fs::read_to_string(path).map_err(LogError::Io)?;
let snap: Snapshot = serde_json::from_str(&text).map_err(|e| LogError::Parse {
line: 0,
source: e,
})?;
Ok(Some(snap))
}
}
fn read_entries(path: &Path) -> Result<Vec<LogEntry>, LogError> {
let f = std::fs::File::open(path)?;
let r = BufReader::new(f);
let mut out = Vec::new();
for (i, line) in r.lines().enumerate() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let entry: LogEntry = serde_json::from_str(&line).map_err(|e| LogError::Parse {
line: i + 1,
source: e,
})?;
out.push(entry);
}
Ok(out)
}
/// Seed an entity into the store and persist the event.
///
/// WAL order: append to log *first*, then mutate the store. If the log
/// append fails, the store is untouched and the caller can safely retry.
/// `Store::seed` is infallible by trait contract — once the log entry is
/// durable the store update is guaranteed to land for in-memory backends.
/// For backends with fallible writes (network/disk), failures surface as
/// a panic during `seed()`; callers that need a fallible seed path should
/// wrap their own retry/reconcile loop.
pub fn seed_and_log<S: Store>(
executor: &Executor,
store: &mut S,
log: &mut EventLog,
entity: &str,
id: Uuid,
data: Value,
) -> Result<(), LogError> {
let seq = log.next_seq();
log.append(LogEntry::Seed {
seq,
entity: entity.to_string(),
id,
data: data.clone(),
schema_hash: Some(executor.schema_bundle_hash),
})?;
store.seed(entity, id, data);
// Best-effort: a failure here means next startup does an extra full
// replay, never a correctness issue.
let _ = store.set_last_applied_seq(seq);
Ok(())
}
/// Run a morphism and persist the event in WAL order:
/// 1. compute() — pure, no mutation; full kernel validation incl. dry-run.
/// 2. log.append() — event hits disk *before* the store changes.
/// 3. store.apply() — materialize the change. By WAL semantics the log
/// is now the source of truth: if (3) fails, the stale store can be
/// rebuilt by replaying the log.
///
/// The error variants tell the caller exactly which stage failed so they
/// know whether to retry, recover, or rebuild.
pub fn execute_and_log<S: Store>(
executor: &Executor,
store: &mut S,
log: &mut EventLog,
morphism: &str,
inputs: &[(&str, Uuid)],
params: Value,
) -> Result<Vec<FieldOp>, ExecuteError> {
let ops = executor.compute(store, morphism, inputs, params.clone())?;
let seq = log.next_seq();
let entry = LogEntry::Morphism {
seq,
morphism: morphism.to_string(),
inputs: inputs
.iter()
.map(|(r, id)| (r.to_string(), *id))
.collect(),
params,
ops: ops.clone(),
schema_hash: executor.schema_hash(morphism),
};
log.append(entry)?;
store.apply(&ops).map_err(ExecuteError::PostLogStore)?;
let _ = store.set_last_applied_seq(seq);
Ok(ops)
}
/// Rebuild a (possibly stale) store from the log. Wipes the store, then
/// replays every event. Use this after a `PostLogStore` failure: the WAL
/// fence guarantees the log is the source of truth, so a clean replay
/// brings the store back into agreement with it.
///
/// `execute_and_log_with_recovery` automates this for the common case;
/// reach for `reconcile` directly when an operator/CLI is doing the
/// recovery, or when a backend reports drift detected out-of-band.
pub fn reconcile<S: Store>(store: &mut S, log: &EventLog) -> Result<(), ReconcileError> {
store.clear().map_err(ReconcileError::Clear)?;
replay_into(log, store)?;
Ok(())
}
/// Like `execute_and_log`, but on `PostLogStore` automatically rebuilds
/// the store from the log and returns the ops as if the apply had
/// succeeded. The caller sees a consistent post-state — either the event
/// landed cleanly, or it landed via reconcile, or `Unrecoverable` (which
/// means even the rebuild failed and the store must not be trusted).
///
/// PreLog and LogAppend are forwarded verbatim: the WAL fence wasn't
/// crossed, so there's nothing to reconcile.
pub fn execute_and_log_with_recovery<S: Store>(
executor: &Executor,
store: &mut S,
log: &mut EventLog,
morphism: &str,
inputs: &[(&str, Uuid)],
params: Value,
) -> Result<Vec<FieldOp>, RecoverableExecuteError> {
let ops = executor.compute(store, morphism, inputs, params.clone())?;
let seq = log.next_seq();
let entry = LogEntry::Morphism {
seq,
morphism: morphism.to_string(),
inputs: inputs
.iter()
.map(|(r, id)| (r.to_string(), *id))
.collect(),
params,
ops: ops.clone(),
schema_hash: executor.schema_hash(morphism),
};
log.append(entry)?;
if let Err(post_log) = store.apply(&ops) {
if let Err(reconcile) = reconcile(store, log) {
return Err(RecoverableExecuteError::Unrecoverable {
post_log,
reconcile,
});
}
// After reconcile the store reflects log state up to log.next_seq()-1
// (which equals our seq). The reconcile path itself updated the
// marker; nothing more to do here.
} else {
let _ = store.set_last_applied_seq(seq);
}
Ok(ops)
}
/// Replay the log into a caller-provided `Store`. The store should be
/// empty on entry; existing records are not erased. Use this with any
/// `Store` impl (MemoryStore, SurrealStore, future backends).
pub fn replay_into<S: Store>(log: &EventLog, store: &mut S) -> Result<(), ReplayError> {
replay_with_snapshot_into(log, None, store)
}
/// Replay starting from a snapshot. If `snapshot` is `Some`, every record
/// in it is seeded into `store` first, then events with `seq > snapshot.seq`
/// are applied. The point: replay cost shrinks from O(events) to
/// O(events_after_snapshot), useful when the log grows large.
pub fn replay_with_snapshot_into<S: Store>(
log: &EventLog,
snapshot: Option<&Snapshot>,
store: &mut S,
) -> Result<(), ReplayError> {
let start_seq = if let Some(snap) = snapshot {
for (entity, recs) in &snap.records {
for (id, data) in recs {
store.seed(entity, *id, data.clone());
}
}
snap.seq + 1
} else {
0
};
let mut last_applied: Option<u64> = snapshot.map(|s| s.seq);
for entry in log.entries()? {
if entry.seq() < start_seq {
continue;
}
let seq = entry.seq();
match entry {
LogEntry::Seed {
entity, id, data, ..
} => store.seed(&entity, id, data),
LogEntry::Morphism { ops, .. } => store.apply(&ops)?,
}
last_applied = Some(seq);
}
if let Some(seq) = last_applied {
let _ = store.set_last_applied_seq(seq);
}
Ok(())
}
/// Convenience: replay into a fresh `MemoryStore`. The fast path: O(events)
/// with no Rhai execution.
pub fn replay(log: &EventLog) -> Result<MemoryStore, ReplayError> {
let mut store = MemoryStore::new();
replay_into(log, &mut store)?;
Ok(store)
}
/// Re-execute every logged morphism through the kernel and assert the
/// recomputed ops match the logged ops byte-for-byte. This is the
/// determinism contract: if it ever fails, a morphism became impure.
pub fn verify_log(log: &EventLog, executor: &Executor) -> Result<(), VerifyError> {
let mut store = MemoryStore::new();
for entry in log.entries()? {
match entry {
LogEntry::Seed {
seq,
entity,
id,
data,
schema_hash,
} => {
if let Some(logged_hash) = schema_hash {
let current_hash = executor.schema_bundle_hash;
if logged_hash != current_hash {
return Err(VerifyError::SeedSchemaMismatch {
seq,
entity,
id,
logged: logged_hash,
current: current_hash,
});
}
}
store.seed(&entity, id, data);
}
LogEntry::Morphism {
seq,
morphism,
inputs,
params,
ops: logged,
schema_hash,
} => {
// Schema check first: if the rules changed, re-execution
// is meaningless — it'd just surface as OpsMismatch with
// a less actionable message. Legacy entries with no
// hash predate the contract; we let those through.
if let Some(logged_hash) = schema_hash {
if let Some(current_hash) = executor.schema_hash(&morphism) {
if logged_hash != current_hash {
return Err(VerifyError::SchemaMismatch {
seq,
morphism,
logged: logged_hash,
current: current_hash,
});
}
}
}
let owned: Vec<(String, Uuid)> = inputs.into_iter().collect();
let refs: Vec<(&str, Uuid)> =
owned.iter().map(|(r, id)| (r.as_str(), *id)).collect();
let recomputed = executor
.run(&mut store, &morphism, &refs, params)
.map_err(|e| VerifyError::Exec { seq, source: e })?;
if recomputed != logged {
return Err(VerifyError::OpsMismatch {
seq,
morphism,
expected: logged,
actual: recomputed,
});
}
}
}
}
Ok(())
}
+667
View File
@@ -0,0 +1,667 @@
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use thiserror::Error;
use uuid::Uuid;
use crate::delta::{FieldOp, simulate_on};
use crate::graph::{GraphError, ManifestGraph};
use crate::kcl_wrapper::{self, KclError};
use crate::manifest::{ConserveRule, Manifest, ManifestError, MorphismSpec, ValidationError};
use crate::rhai_executor::{RhaiError, RhaiExecutor};
use crate::store::{Store, StoreError};
#[derive(Debug, Error)]
pub enum ExecError {
#[error("morphism `{0}` not in manifest")]
UnknownMorphism(String),
#[error("missing input role `{role}` for morphism `{morphism}`")]
MissingInput { morphism: String, role: String },
#[error("duplicate input id {id} bound to roles `{role_a}` and `{role_b}`")]
DuplicateInputId {
id: Uuid,
role_a: String,
role_b: String,
},
#[error("entity `{0}` id `{1}` not found in store")]
EntityMissing(String, Uuid),
#[error(
"capability violation: morphism `{morphism}` produced op on `{token}` not in writes={declared:?}"
)]
CapabilityViolation {
morphism: String,
token: String,
declared: Vec<String>,
},
#[error(
"conservation violated: Σ Δ {entity}.{field} where {group_by} = {group:?} = {total} (expected 0)"
)]
ConservationViolation {
entity: String,
field: String,
group_by: String,
group: String,
total: i128,
},
#[error("conservation rule {entity}.{field}: {message}")]
ConservationMalformed {
entity: String,
field: String,
message: String,
},
#[error("kcl pre-check failed on `{role}` ({entity}): {source}")]
KclPre {
role: String,
entity: String,
#[source]
source: KclError,
},
#[error("kcl post-check failed on `{role}` ({entity}): {source}")]
KclPost {
role: String,
entity: String,
#[source]
source: KclError,
},
#[error("kcl post-check failed on created {entity} {id}: {source}")]
KclPostCreate {
entity: String,
id: Uuid,
#[source]
source: KclError,
},
#[error("rhai: {0}")]
Rhai(#[from] RhaiError),
#[error("store: {0}")]
Store(#[from] StoreError),
#[error("manifest: {0}")]
Manifest(#[from] ManifestError),
#[error("manifest validation: {0}")]
ManifestValidation(#[from] ValidationError),
#[error("manifest graph: {0}")]
Graph(#[from] GraphError),
#[error("io: {0}")]
Io(#[from] std::io::Error),
}
pub struct Executor {
pub manifest: Manifest,
pub graph: ManifestGraph,
pub module_dir: PathBuf,
pub schema_path: PathBuf,
pub rhai: RhaiExecutor,
/// `true` when `schema_path` is a tempfile bundle created by
/// `load_module`; Drop removes it. `false` for inline-built executors
/// that point at a real schema file owned by the caller (tests).
pub owned_bundle: bool,
/// Per-morphism `schema_hash`: SHA-256 of (kcl bundle + manifest spec
/// + rhai script bytes), computed once at load. The hash is the
/// determinism contract for KCL evolution — `verify_log` uses it to
/// reject logs whose entries were produced under different rules.
pub schema_hashes: HashMap<String, [u8; 32]>,
/// Module-wide bundle hash: SHA-256 of just the KCL bundle bytes.
/// Stamped onto every `LogEntry::Seed` via `seed_and_log` so
/// `verify_log` can flag seeds whose entity schemas have evolved
/// since they were logged. Coarser than `schema_hashes` (any
/// schema.k edit moves it, even one that doesn't affect the seeded
/// entity) but cheap and conservative — false positives over false
/// negatives, like the morphism hash.
pub schema_bundle_hash: [u8; 32],
}
impl Drop for Executor {
fn drop(&mut self) {
if self.owned_bundle {
let _ = std::fs::remove_file(&self.schema_path);
}
}
}
/// One row of the bound-inputs map. Holds both `role` and `entity` so the
/// capability check can verify a Set's `path.entity` matches the role's
/// declared entity (catches uuid-collision and lazy scripts).
#[derive(Debug, Clone)]
struct InputBinding {
role: String,
entity: String,
}
impl Executor {
pub fn load_module(module_dir: impl Into<PathBuf>) -> Result<Self, ExecError> {
let module_dir = module_dir.into();
let manifest = Manifest::load(&module_dir.join("nsmc.json"))?;
manifest.validate(&module_dir)?;
let graph = ManifestGraph::build(&manifest)?;
let schema_path = build_schema_bundle(&module_dir, &manifest.effective_schemas())?;
let schema_bundle_bytes = std::fs::read(&schema_path)?;
let schema_bundle_hash = compute_schema_bundle_hash(&schema_bundle_bytes);
let mut schema_hashes = HashMap::with_capacity(manifest.morphisms.len());
for spec in &manifest.morphisms {
let script_path = module_dir.join(&spec.script);
let hash = compute_morphism_schema_hash(&schema_bundle_bytes, spec, &script_path)?;
schema_hashes.insert(spec.name.clone(), hash);
}
Ok(Self {
manifest,
graph,
module_dir,
schema_path,
rhai: RhaiExecutor::new_sandboxed(),
owned_bundle: true,
schema_hashes,
schema_bundle_hash,
})
}
/// Hash for the named morphism in the currently loaded module. `None`
/// if no such morphism is declared. Used by `verify_log` to enforce
/// the schema-version contract.
pub fn schema_hash(&self, morphism: &str) -> Option<[u8; 32]> {
self.schema_hashes.get(morphism).copied()
}
/// Single 32-byte hash representing the entire module's schema:
/// every morphism's hash, in canonical name order, framed and
/// chained. Snapshots pin this so a snapshot taken under bundle A
/// can be detected when later loaded against bundle B.
pub fn module_schema_hash(&self) -> [u8; 32] {
let mut entries: Vec<(&String, &[u8; 32])> = self.schema_hashes.iter().collect();
entries.sort_by_key(|(name, _)| name.as_str().to_owned());
let mut hasher = Sha256::new();
hasher.update(b"nakui-module-v1\0");
for (name, hash) in entries {
hasher.update((name.len() as u64).to_le_bytes());
hasher.update(name.as_bytes());
hasher.update(hash);
}
hasher.finalize().into()
}
/// Compute the ops for a morphism without mutating the store.
///
/// Pipeline:
/// 1. Resolve manifest spec; bind caller's role->id to spec inputs.
/// 2. Reject duplicate ids across roles.
/// 3. Load every input entity; KCL pre-check each.
/// 4. Run the Rhai script with `{ states, ids, params }`.
/// 5. Capability check: every Set targets a tracked id whose entity
/// matches the role's declared entity, and produces a `<role>.<field>`
/// token in `writes`; Create/Delete produce `<entity>` tokens.
/// 6. Delta-level invariants (conservation rules).
/// 7. Per-input KCL post-check (skipped for inputs that the ops Delete).
/// 8. KCL-validate every Created record against its entity schema.
/// 9. Pre-apply check: store.apply_dry_run guarantees apply will land.
///
/// On `Ok`, the returned ops are *contractually applicable* — caller can
/// log first and then apply, knowing apply will succeed barring transient
/// backend faults.
pub fn compute<S: Store>(
&self,
store: &S,
morphism_name: &str,
inputs: &[(&str, Uuid)],
params: Value,
) -> Result<Vec<FieldOp>, ExecError> {
let spec: &MorphismSpec = self
.manifest
.morphism(morphism_name)
.ok_or_else(|| ExecError::UnknownMorphism(morphism_name.to_string()))?;
// 1. Bind inputs.
let inputs_map: BTreeMap<String, Uuid> = inputs
.iter()
.map(|(role, id)| (role.to_string(), *id))
.collect();
for spec_in in &spec.inputs {
if !inputs_map.contains_key(&spec_in.role) {
return Err(ExecError::MissingInput {
morphism: morphism_name.to_string(),
role: spec_in.role.clone(),
});
}
}
// 2. Build id -> binding (role + entity), rejecting duplicates.
let mut id_to_input: HashMap<Uuid, InputBinding> = HashMap::new();
for spec_in in &spec.inputs {
let id = inputs_map[&spec_in.role];
if let Some(other) = id_to_input.get(&id) {
return Err(ExecError::DuplicateInputId {
id,
role_a: other.role.clone(),
role_b: spec_in.role.clone(),
});
}
id_to_input.insert(
id,
InputBinding {
role: spec_in.role.clone(),
entity: spec_in.entity.clone(),
},
);
}
// 3. Load + pre-check every input.
let mut loaded: BTreeMap<String, Value> = BTreeMap::new();
let mut id_strings: BTreeMap<String, String> = BTreeMap::new();
for spec_in in &spec.inputs {
let id = inputs_map[&spec_in.role];
let state = store
.load(&spec_in.entity, id)
.ok_or_else(|| ExecError::EntityMissing(spec_in.entity.clone(), id))?;
self.kcl_check(&spec_in.entity, &state)
.map_err(|e| ExecError::KclPre {
role: spec_in.role.clone(),
entity: spec_in.entity.clone(),
source: e,
})?;
loaded.insert(spec_in.role.clone(), state);
id_strings.insert(spec_in.role.clone(), id.to_string());
}
// 4. Rhai.
let script_path = self.module_dir.join(&spec.script);
let input = json!({
"states": loaded,
"ids": id_strings,
"params": params,
});
let ops = self.rhai.run(&script_path, input)?;
// 5. Capability check.
let declared: HashSet<&str> = spec.writes.iter().map(String::as_str).collect();
for op in &ops {
let token = match op {
FieldOp::Set { path, .. } => match id_to_input.get(&path.id) {
Some(binding) if binding.entity == path.entity => {
format!("{}.{}", binding.role, path.field)
}
Some(_) => {
return Err(ExecError::CapabilityViolation {
morphism: morphism_name.to_string(),
token: format!(
"<entity-mismatch>.{}.{}",
path.entity, path.field
),
declared: spec.writes.clone(),
});
}
None => {
return Err(ExecError::CapabilityViolation {
morphism: morphism_name.to_string(),
token: format!("<untracked id>.{}.{}", path.entity, path.field),
declared: spec.writes.clone(),
});
}
},
FieldOp::Create { entity, .. } => entity.clone(),
FieldOp::Delete { entity, .. } => entity.clone(),
};
if !declared.contains(token.as_str()) {
return Err(ExecError::CapabilityViolation {
morphism: morphism_name.to_string(),
token,
declared: spec.writes.clone(),
});
}
}
// 6. Conservation invariants.
for rule in &spec.invariants.conserve {
check_conservation(rule, &loaded, &id_to_input, &ops)?;
}
// 7. Per-input KCL post-check; skip Deleted inputs.
for spec_in in &spec.inputs {
let id = inputs_map[&spec_in.role];
if let Some(new_state) =
simulate_on(&loaded[&spec_in.role], &spec_in.entity, id, &ops)
{
self.kcl_check(&spec_in.entity, &new_state)
.map_err(|e| ExecError::KclPost {
role: spec_in.role.clone(),
entity: spec_in.entity.clone(),
source: e,
})?;
}
}
// 8. Validate every Created record against its entity schema.
for op in &ops {
if let FieldOp::Create { entity, id, data } = op {
self.kcl_check(entity, data)
.map_err(|e| ExecError::KclPostCreate {
entity: entity.clone(),
id: *id,
source: e,
})?;
}
}
// 9. Pre-apply check: structural compatibility with current store state.
store.apply_dry_run(&ops)?;
Ok(ops)
}
/// compute + apply, for callers that don't need event logging.
pub fn run<S: Store>(
&self,
store: &mut S,
morphism_name: &str,
inputs: &[(&str, Uuid)],
params: Value,
) -> Result<Vec<FieldOp>, ExecError> {
let ops = self.compute(store, morphism_name, inputs, params)?;
store.apply(&ops)?;
Ok(ops)
}
fn kcl_check(&self, entity: &str, state: &Value) -> Result<(), KclError> {
let tmp = std::env::temp_dir().join(format!("nakui_{}_{}.json", entity, Uuid::new_v4()));
std::fs::write(&tmp, serde_json::to_vec(state).expect("state serializes"))
.map_err(KclError::Io)?;
let result = kcl_wrapper::vet(&self.schema_path, &tmp, entity);
let _ = std::fs::remove_file(&tmp);
result
}
}
/// Concatenate every declared `.k` file into a single bundle on disk.
/// `kcl vet` only takes one schema arg, so cross-module modules (e.g. sales
/// referencing both treasury and inventory entities) bundle their imports
/// at load time. The bundle lives in `temp_dir` for the lifetime of the
/// executor; one file per Executor instance.
/// Module-wide hash of just the KCL bundle bytes. Stamped on
/// `LogEntry::Seed` entries (which don't run through any morphism, so
/// `compute_morphism_schema_hash` doesn't apply). Bumped by any byte
/// change in any schema file the manifest exposes — coarser than a
/// per-entity hash would be, but doesn't require KCL parsing.
fn compute_schema_bundle_hash(schema_bundle_bytes: &[u8]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(b"nakui-bundle-v1\0");
hasher.update((schema_bundle_bytes.len() as u64).to_le_bytes());
hasher.update(schema_bundle_bytes);
hasher.finalize().into()
}
/// Per-morphism schema hash. SHA-256 with length-prefixed framing over
/// three inputs that together determine the morphism's deterministic
/// behaviour: the KCL schema bundle (entity shapes + invariants), the
/// manifest spec (writes, conserve, depends_on, etc.), and a
/// **normalized** form of the Rhai script — comments stripped and
/// whitespace runs collapsed, with string literals preserved exactly.
///
/// The normalization makes the hash invariant to cosmetic edits (a
/// developer adding a `// TODO` doesn't invalidate the log) without
/// missing real behavioural changes. The framing tag is bumped to
/// `nakui-schema-v2` so logs hashed under v1 (raw bytes) cleanly fail
/// SchemaMismatch on upgrade rather than silently divergence.
fn compute_morphism_schema_hash(
schema_bundle_bytes: &[u8],
spec: &MorphismSpec,
script_path: &Path,
) -> std::io::Result<[u8; 32]> {
let script_bytes = std::fs::read(script_path)?;
let script_source = std::str::from_utf8(&script_bytes).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("script {} is not valid UTF-8: {}", script_path.display(), e),
)
})?;
let normalized_script = normalize_rhai_source(script_source);
let spec_json = serde_json::to_vec(spec).expect("MorphismSpec serializes");
let mut hasher = Sha256::new();
hasher.update(b"nakui-schema-v2\0");
hasher.update(b"schema:");
hasher.update((schema_bundle_bytes.len() as u64).to_le_bytes());
hasher.update(schema_bundle_bytes);
hasher.update(b"spec:");
hasher.update((spec_json.len() as u64).to_le_bytes());
hasher.update(&spec_json);
hasher.update(b"script:");
hasher.update((normalized_script.len() as u64).to_le_bytes());
hasher.update(normalized_script.as_bytes());
Ok(hasher.finalize().into())
}
/// Strip line/block comments and collapse whitespace runs in a Rhai
/// source string. Preserves string literals exactly. Used to make the
/// schema hash invariant to cosmetic edits.
///
/// Limitations:
/// - Doesn't handle backtick template literals (Rhai 1.x interp
/// strings). If the modules ever start using them, the normalizer
/// must be extended; until then it's not a concern for the
/// production scripts in `modules/`.
/// - Doesn't handle nested block comments — Rhai itself doesn't
/// either.
pub fn normalize_rhai_source(src: &str) -> String {
let mut out = String::with_capacity(src.len());
let mut chars = src.chars().peekable();
let mut prev_was_space = true; // strip leading whitespace
while let Some(c) = chars.next() {
// Line comment: //...\n
if c == '/' && chars.peek() == Some(&'/') {
chars.next();
while let Some(&n) = chars.peek() {
if n == '\n' {
break;
}
chars.next();
}
continue;
}
// Block comment: /* ... */
if c == '/' && chars.peek() == Some(&'*') {
chars.next();
let mut prev = '\0';
while let Some(n) = chars.next() {
if prev == '*' && n == '/' {
break;
}
prev = n;
}
continue;
}
// String literal: copy verbatim including escape sequences.
if c == '"' {
out.push('"');
while let Some(n) = chars.next() {
if n == '\\' {
out.push('\\');
if let Some(esc) = chars.next() {
out.push(esc);
}
} else if n == '"' {
out.push('"');
break;
} else {
out.push(n);
}
}
prev_was_space = false;
continue;
}
// Whitespace run → single space (or nothing if at edge).
if c.is_whitespace() {
if !prev_was_space {
out.push(' ');
prev_was_space = true;
}
continue;
}
// Regular character.
out.push(c);
prev_was_space = false;
}
if out.ends_with(' ') {
out.pop();
}
out
}
fn build_schema_bundle(module_dir: &std::path::Path, schemas: &[String]) -> std::io::Result<PathBuf> {
let mut combined = String::new();
for s in schemas {
let p = module_dir.join(s);
let content = std::fs::read_to_string(&p)?;
combined.push_str("# --- ");
combined.push_str(p.to_string_lossy().as_ref());
combined.push_str(" ---\n");
combined.push_str(&content);
combined.push_str("\n\n");
}
let bundle = std::env::temp_dir().join(format!("nakui_schema_{}.k", Uuid::new_v4()));
std::fs::write(&bundle, combined)?;
Ok(bundle)
}
fn check_conservation(
rule: &ConserveRule,
loaded: &BTreeMap<String, Value>,
id_to_input: &HashMap<Uuid, InputBinding>,
ops: &[FieldOp],
) -> Result<(), ExecError> {
let mut delta_by_group: HashMap<String, i128> = HashMap::new();
for op in ops {
if let FieldOp::Set { path, value } = op {
if path.entity != rule.entity || path.field != rule.field {
continue;
}
let binding = id_to_input
.get(&path.id)
.filter(|b| b.entity == path.entity)
.ok_or_else(|| ExecError::ConservationMalformed {
entity: rule.entity.clone(),
field: rule.field.clone(),
message: format!(
"Set on id {} with entity {} cannot be reconciled to a tracked input",
path.id, path.entity
),
})?;
let old_state = &loaded[&binding.role];
let old_val =
old_state
.get(&rule.field)
.and_then(Value::as_i64)
.ok_or_else(|| ExecError::ConservationMalformed {
entity: rule.entity.clone(),
field: rule.field.clone(),
message: format!("old value at role `{}` is not i64", binding.role),
})?;
let new_val =
value
.as_i64()
.ok_or_else(|| ExecError::ConservationMalformed {
entity: rule.entity.clone(),
field: rule.field.clone(),
message: format!("Set value at role `{}` is not i64", binding.role),
})?;
let group_key = match &rule.group_by {
Some(g) => old_state
.get(g)
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
None => String::new(),
};
*delta_by_group.entry(group_key).or_insert(0) +=
(new_val as i128) - (old_val as i128);
}
}
for (group, total) in &delta_by_group {
if *total != 0 {
return Err(ExecError::ConservationViolation {
entity: rule.entity.clone(),
field: rule.field.clone(),
group_by: rule.group_by.clone().unwrap_or_else(|| "(global)".into()),
group: group.clone(),
total: *total,
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_strips_line_and_block_comments() {
let src = r#"
// header comment
let x = 1; // trailing
/* block
spans lines */
let y = 2;
"#;
let normalized = normalize_rhai_source(src);
assert_eq!(normalized, "let x = 1; let y = 2;");
}
#[test]
fn normalize_collapses_whitespace_runs() {
let src = "let a =\t\t1;\n\n\n\nlet b = 2;";
let normalized = normalize_rhai_source(src);
assert_eq!(normalized, "let a = 1; let b = 2;");
}
#[test]
fn normalize_preserves_strings_verbatim_including_double_spaces() {
// The double space, the // inside, and the escape are preserved
// exactly because they're inside a string literal — semantic
// content, not cosmetic.
let src = r#"let s = "hello // not a comment \"world\"";"#;
let normalized = normalize_rhai_source(src);
assert_eq!(normalized, r#"let s = "hello // not a comment \"world\"";"#);
}
#[test]
fn normalize_is_idempotent() {
let src = "// a\nlet x = 1;\n";
let n1 = normalize_rhai_source(src);
let n2 = normalize_rhai_source(&n1);
assert_eq!(n1, n2);
}
#[test]
fn normalize_distinguishes_real_changes() {
// Adding a new statement is a non-cosmetic change — the
// normalized output must reflect it.
let a = "let x = 1;";
let b = "let x = 1; let y = 2;";
assert_ne!(normalize_rhai_source(a), normalize_rhai_source(b));
// Same for changing a literal value.
let c = "let x = 1;";
let d = "let x = 2;";
assert_ne!(normalize_rhai_source(c), normalize_rhai_source(d));
}
#[test]
fn normalize_handles_comment_at_end_without_newline() {
let src = "let x = 1; // no trailing newline";
let normalized = normalize_rhai_source(src);
assert_eq!(normalized, "let x = 1;");
}
#[test]
fn normalize_handles_unterminated_block_comment() {
// Defensive: if someone writes `/* ...` and forgets to close,
// we don't infinite-loop or panic. The trailing content is
// discarded, which is fine — Rhai won't parse this either.
let src = "let x = 1; /* never ends";
let normalized = normalize_rhai_source(src);
assert_eq!(normalized, "let x = 1;");
}
}
+277
View File
@@ -0,0 +1,277 @@
//! Static dependency graph derived from a `Manifest`.
//!
//! Two graphs in one structure:
//! - **Explicit graph** (`depends_on`): morphism-to-morphism edges declared
//! by the manifest author. Cycles here are an error — the graph is built
//! with cycle detection.
//! - **Data-flow indexes** (`reads`/`writes`): inverted indexes from
//! canonical entity tokens (`"Caja.saldo"` or `"Movimiento"`) to the
//! morphisms that read or write them. Self-loops in data flow are
//! legal (a morphism that reads a field and updates it is normal).
//!
//! Tokens are normalized at build time: a manifest's role-prefixed tokens
//! (`"caja.saldo"`) become entity-prefixed (`"Caja.saldo"`) so cross-module
//! queries work uniformly.
use petgraph::algo::tarjan_scc;
use petgraph::graph::{DiGraph, NodeIndex};
use petgraph::visit::Topo;
use std::collections::{HashMap, HashSet};
use thiserror::Error;
use crate::manifest::Manifest;
#[derive(Debug, Error)]
pub enum GraphError {
#[error("dependency cycle in `depends_on` involving morphisms {0:?}")]
Cycle(Vec<String>),
#[error("morphism `{0}` referenced in depends_on but not declared in this manifest")]
UnknownMorphism(String),
}
#[derive(Debug)]
pub struct ManifestGraph {
/// Explicit `depends_on` graph. Edge `a -> b` means: morphism `b`
/// depends on `a`, so `a` must be available before `b`.
explicit: DiGraph<String, ()>,
/// Data-flow indexes. Token form: "Entity.field" or "Entity".
readers_of_token: HashMap<String, Vec<String>>,
writers_of_token: HashMap<String, Vec<String>>,
/// Per-morphism canonicalized token sets.
morphism_reads: HashMap<String, Vec<String>>,
morphism_writes: HashMap<String, Vec<String>>,
}
impl ManifestGraph {
pub fn build(manifest: &Manifest) -> Result<Self, GraphError> {
let explicit = build_explicit(manifest)?;
if let Some(cycle) = find_cycle(&explicit) {
return Err(GraphError::Cycle(cycle));
}
let (readers_of_token, writers_of_token, morphism_reads, morphism_writes) =
build_data_flow(manifest);
Ok(Self {
explicit,
readers_of_token,
writers_of_token,
morphism_reads,
morphism_writes,
})
}
/// Morphisms that read `token`. Token form: "Entity.field" or "Entity".
pub fn readers_of(&self, token: &str) -> &[String] {
self.readers_of_token
.get(token)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
/// Morphisms that write `token`.
pub fn writers_of(&self, token: &str) -> &[String] {
self.writers_of_token
.get(token)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
pub fn morphism_reads(&self, name: &str) -> &[String] {
self.morphism_reads
.get(name)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
pub fn morphism_writes(&self, name: &str) -> &[String] {
self.morphism_writes
.get(name)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
/// Morphisms whose `reads` overlap any of `name`'s `writes`. The
/// dirty-marking primitive: after `name` runs successfully, these are
/// the candidates whose derived state would be invalidated. The result
/// excludes `name` itself even if it reads what it writes.
pub fn affected_by(&self, name: &str) -> Vec<String> {
let writes = match self.morphism_writes.get(name) {
Some(w) => w,
None => return Vec::new(),
};
let mut affected: HashSet<String> = HashSet::new();
for token in writes {
if let Some(readers) = self.readers_of_token.get(token) {
for r in readers {
if r != name {
affected.insert(r.clone());
}
}
}
}
let mut out: Vec<_> = affected.into_iter().collect();
out.sort();
out
}
/// Topological order of the explicit dependency graph. If `a` is in
/// `b.depends_on`, `a` precedes `b` in the result.
pub fn topological_order(&self) -> Vec<String> {
let mut topo = Topo::new(&self.explicit);
let mut out = Vec::new();
while let Some(idx) = topo.next(&self.explicit) {
out.push(self.explicit[idx].clone());
}
out
}
}
fn build_explicit(manifest: &Manifest) -> Result<DiGraph<String, ()>, GraphError> {
let mut graph = DiGraph::new();
let mut nodes: HashMap<String, NodeIndex> = HashMap::new();
for m in &manifest.morphisms {
let idx = graph.add_node(m.name.clone());
nodes.insert(m.name.clone(), idx);
}
for m in &manifest.morphisms {
let to = nodes[&m.name];
for dep in &m.depends_on {
let from = *nodes
.get(dep)
.ok_or_else(|| GraphError::UnknownMorphism(dep.clone()))?;
graph.add_edge(from, to, ());
}
}
Ok(graph)
}
/// Returns one cycle's nodes (sorted) if the graph has any. Self-loops
/// are returned as `[name]`; multi-node SCCs as the SCC's nodes.
fn find_cycle(graph: &DiGraph<String, ()>) -> Option<Vec<String>> {
for scc in tarjan_scc(graph) {
if scc.len() > 1 {
let mut names: Vec<String> = scc.iter().map(|i| graph[*i].clone()).collect();
names.sort();
return Some(names);
}
if scc.len() == 1 && graph.find_edge(scc[0], scc[0]).is_some() {
return Some(vec![graph[scc[0]].clone()]);
}
}
None
}
fn build_data_flow(
manifest: &Manifest,
) -> (
HashMap<String, Vec<String>>,
HashMap<String, Vec<String>>,
HashMap<String, Vec<String>>,
HashMap<String, Vec<String>>,
) {
let mut readers: HashMap<String, Vec<String>> = HashMap::new();
let mut writers: HashMap<String, Vec<String>> = HashMap::new();
let mut m_reads: HashMap<String, Vec<String>> = HashMap::new();
let mut m_writes: HashMap<String, Vec<String>> = HashMap::new();
for m in &manifest.morphisms {
let role_to_entity: HashMap<&str, &str> = m
.inputs
.iter()
.map(|i| (i.role.as_str(), i.entity.as_str()))
.collect();
// Dedupe per-morphism: `source.saldo` and `dest.saldo` both
// canonicalize to `Caja.saldo` — the morphism is one writer, not
// two.
let mut seen_reads: HashSet<String> = HashSet::new();
for r in &m.reads {
if let Some(token) = canonicalize_token(r, &role_to_entity) {
if seen_reads.insert(token.clone()) {
readers.entry(token.clone()).or_default().push(m.name.clone());
m_reads.entry(m.name.clone()).or_default().push(token);
}
}
}
let mut seen_writes: HashSet<String> = HashSet::new();
for w in &m.writes {
if let Some(token) = canonicalize_token(w, &role_to_entity) {
if seen_writes.insert(token.clone()) {
writers.entry(token.clone()).or_default().push(m.name.clone());
m_writes.entry(m.name.clone()).or_default().push(token);
}
}
}
}
(readers, writers, m_reads, m_writes)
}
/// "role.field" -> "Entity.field" via the inputs map; "Entity" -> "Entity".
fn canonicalize_token(t: &str, roles: &HashMap<&str, &str>) -> Option<String> {
if let Some((role, field)) = t.split_once('.') {
roles
.get(role)
.map(|entity| format!("{}.{}", entity, field))
} else {
Some(t.to_string())
}
}
/// Tracks which morphisms have stale derived state because some morphism
/// they read from was applied. Wire it next to your `execute_and_log`
/// loop: after a successful run, call `mark_dirty_after(morphism, &graph)`;
/// then any consumer (cached view, derived report, downstream pipeline)
/// queries `is_dirty(name)` before using its cached output.
///
/// The tracker holds names only — it doesn't know what "recompute" means
/// for any particular morphism. That's deliberate: the kernel exposes the
/// invalidation primitive; what to do with the dirty set is the caller's.
#[derive(Debug, Default, Clone)]
pub struct DirtyTracker {
dirty: HashSet<String>,
}
impl DirtyTracker {
pub fn new() -> Self {
Self::default()
}
/// After `morphism_name` runs successfully, mark every morphism in
/// `graph.affected_by(morphism_name)` as dirty.
pub fn mark_dirty_after(&mut self, morphism_name: &str, graph: &ManifestGraph) {
for affected in graph.affected_by(morphism_name) {
self.dirty.insert(affected);
}
}
pub fn is_dirty(&self, morphism: &str) -> bool {
self.dirty.contains(morphism)
}
/// Sorted list of dirty morphisms. Stable order for UI/telemetry.
pub fn dirty(&self) -> Vec<String> {
let mut out: Vec<String> = self.dirty.iter().cloned().collect();
out.sort();
out
}
pub fn len(&self) -> usize {
self.dirty.len()
}
pub fn is_empty(&self) -> bool {
self.dirty.is_empty()
}
/// Clear the dirty flag for a specific morphism (call after the
/// caller has recomputed it).
pub fn clear(&mut self, morphism: &str) {
self.dirty.remove(morphism);
}
pub fn clear_all(&mut self) {
self.dirty.clear();
}
}
@@ -0,0 +1,43 @@
use std::path::Path;
use std::process::Command;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum KclError {
#[error("kcl binary not found on PATH (install: https://kcl-lang.io)")]
BinaryMissing,
#[error("kcl validation failed:\n{0}")]
ValidationFailed(String),
#[error("io invoking kcl: {0}")]
Io(#[from] std::io::Error),
}
/// Validate `state_path` (json) against a schema defined in `schema_path` (.k),
/// targeting the named schema.
pub fn vet(schema_path: &Path, state_path: &Path, schema_name: &str) -> Result<(), KclError> {
let out = match Command::new("kcl")
.arg("vet")
.arg(state_path)
.arg(schema_path)
.arg("-s")
.arg(schema_name)
.output()
{
Ok(o) => o,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(KclError::BinaryMissing),
Err(e) => return Err(e.into()),
};
if out.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let msg = if stderr.trim().is_empty() {
stdout
} else {
stderr
};
Err(KclError::ValidationFailed(msg))
}
}
+11
View File
@@ -0,0 +1,11 @@
pub mod delta;
pub mod drift;
pub mod event_log;
pub mod executor;
pub mod graph;
pub mod kcl_wrapper;
pub mod manifest;
pub mod rhai_executor;
pub mod run;
pub mod store;
pub mod surreal_store;
+306
View File
@@ -0,0 +1,306 @@
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub module: String,
/// Schema files that compose this module's KCL surface. Paths are
/// resolved relative to the module directory; cross-module references
/// use `"../other_module/schema.k"`. Defaults to `["schema.k"]` when
/// the field is absent — the single-file case.
#[serde(default)]
pub schemas: Vec<String>,
pub morphisms: Vec<MorphismSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MorphismSpec {
pub name: String,
pub inputs: Vec<MorphismInput>,
pub reads: Vec<String>,
pub writes: Vec<String>,
#[serde(default)]
pub invariants: Invariants,
#[serde(default)]
pub depends_on: Vec<String>,
pub script: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MorphismInput {
pub role: String,
pub entity: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Invariants {
/// Sum-conservation rules. The total Δ of (entity, field) across the ops
/// produced by the morphism must be zero — optionally bucketed by another
/// field on the entity (e.g. group_by="currency" so USD and EUR are
/// independent ledgers).
#[serde(default)]
pub conserve: Vec<ConserveRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConserveRule {
pub entity: String,
pub field: String,
#[serde(default)]
pub group_by: Option<String>,
}
#[derive(Debug, Error)]
pub enum ManifestError {
#[error("io reading manifest: {0}")]
Io(#[from] std::io::Error),
#[error("parsing manifest json: {0}")]
Parse(#[from] serde_json::Error),
}
/// Errors raised by `Manifest::validate`. Each variant flags a specific
/// semantic issue caught before the kernel ever runs the module — these
/// are the contract between manifest authors (humans or AI) and Nakui.
#[derive(Debug, Error)]
pub enum ValidationError {
#[error("morphism name `{0}` declared more than once")]
DuplicateMorphism(String),
#[error("morphism `{morphism}`: input role `{role}` declared more than once")]
DuplicateRole { morphism: String, role: String },
#[error(
"morphism `{morphism}`: input entity `{entity}` is not declared in any schema file (known: {known:?})"
)]
InputUnknownEntity {
morphism: String,
entity: String,
known: Vec<String>,
},
#[error(
"morphism `{morphism}`: writes token `{token}` references unknown role `{role}` (declared roles: {roles:?})"
)]
WritesUnknownRole {
morphism: String,
token: String,
role: String,
roles: Vec<String>,
},
#[error(
"morphism `{morphism}`: writes token `{token}` is not a declared role.field nor a known entity name"
)]
WritesUnknownEntity { morphism: String, token: String },
#[error("morphism `{morphism}`: conserve rule references unknown entity `{entity}`")]
ConserveUnknownEntity { morphism: String, entity: String },
#[error("morphism `{morphism}`: depends_on `{dep}` does not name a morphism in this manifest")]
DependsOnUnknown { morphism: String, dep: String },
#[error("morphism `{morphism}`: script file `{script}` not found at {resolved}")]
ScriptMissing {
morphism: String,
script: String,
resolved: String,
},
#[error("schema file `{path}` declared in manifest does not exist at {resolved}")]
SchemaFileMissing { path: String, resolved: String },
#[error("schema name `{name}` is declared in multiple files: {files:?}")]
DuplicateSchema { name: String, files: Vec<String> },
#[error("io reading schema `{path}`: {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
}
impl Manifest {
pub fn load(path: &Path) -> Result<Self, ManifestError> {
let text = std::fs::read_to_string(path)?;
let m: Self = serde_json::from_str(&text)?;
Ok(m)
}
pub fn morphism(&self, name: &str) -> Option<&MorphismSpec> {
self.morphisms.iter().find(|m| m.name == name)
}
/// Schema files this module exposes. Defaults to `["schema.k"]` when
/// the manifest doesn't declare any explicitly.
pub fn effective_schemas(&self) -> Vec<String> {
if self.schemas.is_empty() {
vec!["schema.k".to_string()]
} else {
self.schemas.clone()
}
}
/// Run all semantic checks. Catches author errors that would otherwise
/// surface as opaque runtime failures — misspelled entity names that
/// silently make conservation a no-op, role typos in writes that allow
/// any op through, unresolvable script paths, etc.
pub fn validate(&self, module_dir: &Path) -> Result<(), ValidationError> {
// 1. Resolve schemas: read each file, parse schema names, detect
// cross-file duplicates. Build the set of known entity names.
let mut entity_to_files: HashMap<String, Vec<String>> = HashMap::new();
for s in self.effective_schemas() {
let resolved = module_dir.join(&s);
if !resolved.exists() {
return Err(ValidationError::SchemaFileMissing {
path: s.clone(),
resolved: resolved.display().to_string(),
});
}
let content = std::fs::read_to_string(&resolved).map_err(|e| {
ValidationError::Io {
path: s.clone(),
source: e,
}
})?;
for name in extract_schema_names(&content) {
entity_to_files.entry(name).or_default().push(s.clone());
}
}
for (name, files) in &entity_to_files {
if files.len() > 1 {
return Err(ValidationError::DuplicateSchema {
name: name.clone(),
files: files.clone(),
});
}
}
let known_entities: HashSet<&str> =
entity_to_files.keys().map(String::as_str).collect();
// 2. Manifest-level: morphism names must be unique.
let mut seen: HashSet<&str> = HashSet::new();
for m in &self.morphisms {
if !seen.insert(m.name.as_str()) {
return Err(ValidationError::DuplicateMorphism(m.name.clone()));
}
}
let known_morphisms: HashSet<&str> =
self.morphisms.iter().map(|m| m.name.as_str()).collect();
// 3. Per-morphism checks.
for m in &self.morphisms {
let mut roles: HashSet<&str> = HashSet::new();
for inp in &m.inputs {
if !roles.insert(inp.role.as_str()) {
return Err(ValidationError::DuplicateRole {
morphism: m.name.clone(),
role: inp.role.clone(),
});
}
if !known_entities.contains(inp.entity.as_str()) {
return Err(ValidationError::InputUnknownEntity {
morphism: m.name.clone(),
entity: inp.entity.clone(),
known: sorted(&known_entities),
});
}
}
for token in &m.writes {
if let Some((role, _field)) = token.split_once('.') {
if !roles.contains(role) {
return Err(ValidationError::WritesUnknownRole {
morphism: m.name.clone(),
token: token.clone(),
role: role.to_string(),
roles: m.inputs.iter().map(|i| i.role.clone()).collect(),
});
}
} else if !known_entities.contains(token.as_str()) {
return Err(ValidationError::WritesUnknownEntity {
morphism: m.name.clone(),
token: token.clone(),
});
}
}
for rule in &m.invariants.conserve {
if !known_entities.contains(rule.entity.as_str()) {
return Err(ValidationError::ConserveUnknownEntity {
morphism: m.name.clone(),
entity: rule.entity.clone(),
});
}
}
for dep in &m.depends_on {
if !known_morphisms.contains(dep.as_str()) {
return Err(ValidationError::DependsOnUnknown {
morphism: m.name.clone(),
dep: dep.clone(),
});
}
}
let script_resolved = module_dir.join(&m.script);
if !script_resolved.exists() {
return Err(ValidationError::ScriptMissing {
morphism: m.name.clone(),
script: m.script.clone(),
resolved: script_resolved.display().to_string(),
});
}
}
Ok(())
}
}
/// Cheap line-scan over a `.k` file to extract every `schema NAME` declared
/// at column 0 (top-level). Tolerates inheritance (`schema X(Y):`) and
/// generic params (`schema X[T]:`); ignores comments and string literals
/// because top-level KCL syntax doesn't admit them ambiguously.
fn extract_schema_names(content: &str) -> Vec<String> {
let mut out = Vec::new();
for line in content.lines() {
// Top-level declarations are not indented in idiomatic KCL.
if line.starts_with("schema ") {
let after = &line["schema ".len()..];
let name: String = after
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if !name.is_empty() {
out.push(name);
}
}
}
out
}
fn sorted(set: &HashSet<&str>) -> Vec<String> {
let mut v: Vec<String> = set.iter().map(|s| s.to_string()).collect();
v.sort();
v
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_schema_names_handles_basic_forms() {
let content = r#"
schema Caja:
saldo: int
schema Movimiento(Base):
monto: int
# schema Comentario:
schema Generic[T]:
inner: T
schema _Underscore:
x: int
"#;
let names = extract_schema_names(content);
assert_eq!(
names,
vec!["Caja", "Movimiento", "Generic", "_Underscore"]
);
}
}
@@ -0,0 +1,103 @@
use rhai::packages::{
ArithmeticPackage, BasicArrayPackage, BasicIteratorPackage, BasicMapPackage,
BasicStringPackage, CorePackage, LogicPackage, Package,
};
use rhai::{AST, Dynamic, Engine, Scope};
use serde_json::Value;
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use thiserror::Error;
use crate::delta::FieldOp;
#[derive(Debug, Error)]
pub enum RhaiError {
#[error("compile error: {0}")]
Compile(String),
#[error("runtime error: {0}")]
Runtime(String),
#[error("morphism returned non-array")]
BadDelta,
#[error("delta op malformed: {0}")]
BadOp(String),
#[error("io reading script: {0}")]
Io(#[from] std::io::Error),
}
pub struct RhaiExecutor {
engine: Engine,
/// Compiled-AST cache keyed by absolute script path. Avoids reading +
/// reparsing on every call (verify_log re-runs every morphism in the
/// log; without the cache that becomes an O(events × parse) blowup).
asts: RefCell<HashMap<PathBuf, Arc<AST>>>,
}
impl RhaiExecutor {
/// Build a deterministic engine. Time, random, IO, debug/print are all
/// excluded by construction (we register packages by name, not the
/// StandardPackage bundle which would pull in BasicTimePackage).
pub fn new_sandboxed() -> Self {
let mut engine = Engine::new_raw();
// Deliberately omitted: BasicTimePackage, EvalPackage, DebugPackage.
CorePackage::new().register_into_engine(&mut engine);
LogicPackage::new().register_into_engine(&mut engine);
ArithmeticPackage::new().register_into_engine(&mut engine);
BasicArrayPackage::new().register_into_engine(&mut engine);
BasicMapPackage::new().register_into_engine(&mut engine);
BasicStringPackage::new().register_into_engine(&mut engine);
BasicIteratorPackage::new().register_into_engine(&mut engine);
engine.set_max_call_levels(64);
engine.set_max_expr_depths(64, 32);
Self {
engine,
asts: RefCell::new(HashMap::new()),
}
}
pub fn run(&self, script_path: &Path, input: Value) -> Result<Vec<FieldOp>, RhaiError> {
let ast = self.ast_for(script_path)?;
let dyn_input: Dynamic = rhai::serde::to_dynamic(input)
.map_err(|e| RhaiError::Runtime(format!("input -> dynamic: {}", e)))?;
let mut scope = Scope::new();
scope.push_dynamic("input", dyn_input);
let result: Dynamic = self
.engine
.eval_ast_with_scope(&mut scope, &ast)
.map_err(|e| RhaiError::Runtime(e.to_string()))?;
let arr = result.into_array().map_err(|_| RhaiError::BadDelta)?;
let mut ops = Vec::with_capacity(arr.len());
for item in arr {
let json: Value = rhai::serde::from_dynamic(&item)
.map_err(|e| RhaiError::BadOp(format!("dynamic -> json: {}", e)))?;
let op: FieldOp = serde_json::from_value(json)
.map_err(|e| RhaiError::BadOp(e.to_string()))?;
ops.push(op);
}
Ok(ops)
}
/// Returns a cached compiled AST for `script_path`, compiling it on the
/// first call. Cache hits avoid filesystem IO and parse cost entirely.
fn ast_for(&self, script_path: &Path) -> Result<Arc<AST>, RhaiError> {
if let Some(ast) = self.asts.borrow().get(script_path) {
return Ok(Arc::clone(ast));
}
let source = std::fs::read_to_string(script_path)?;
let compiled = self
.engine
.compile(&source)
.map_err(|e| RhaiError::Compile(e.to_string()))?;
let arc = Arc::new(compiled);
self.asts
.borrow_mut()
.insert(script_path.to_path_buf(), Arc::clone(&arc));
Ok(arc)
}
}
+352
View File
@@ -0,0 +1,352 @@
//! `nakui run` server: a long-lived process that holds an in-memory store
//! reconstructed from the log, exposes a Unix Domain Socket, and serves
//! line-delimited JSON requests to drive the kernel.
//!
//! Why UDS + line-JSON for V1:
//! - Multi-client without committing to a transport (HTTP/NATS later).
//! - Filesystem permissions gate access; no port exposure.
//! - Self-describing: `describe` returns the manifest's morphism specs
//! so an agent (human or LLM) can drive the server without external
//! docs.
//!
//! Concurrency: one connection at a time. Backed by `&mut Store`, the
//! kernel is single-writer by design. Multiple clients queue in
//! `accept()`. If/when we want concurrency, the right unit to parallelize
//! is reads, not writes — that's a future refactor with locks at the
//! right granularity.
//!
//! Recovery: every `execute` goes through `execute_and_log_with_recovery`
//! so a transient apply failure auto-rebuilds the in-memory store from
//! the log without taking the server down.
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::Path;
use serde::Deserialize;
use serde_json::{Value, json};
use thiserror::Error;
use uuid::Uuid;
use crate::event_log::{
EventLog, RecoverableExecuteError, ReplayError, Snapshot, SnapshotMismatchError,
execute_and_log_with_recovery, replay_with_snapshot_into, verify_log,
};
use crate::executor::Executor;
use crate::store::Store;
#[derive(Debug, Error)]
pub enum RunError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("clear store on startup: {0}")]
Clear(#[source] crate::store::StoreError),
#[error("replay on startup: {0}")]
Replay(#[from] ReplayError),
#[error("log: {0}")]
Log(#[from] crate::event_log::LogError),
#[error("snapshot incompatible: {0}")]
SnapshotMismatch(#[from] SnapshotMismatchError),
#[error(
"snapshot/log gap: snapshot covers up to seq {snap_seq}, log's first remaining entry is seq {log_first_seq} (expected ≤ {expected})"
)]
SnapshotGap {
snap_seq: u64,
log_first_seq: u64,
expected: u64,
},
}
/// Run the server until a `shutdown` request is received or `accept`
/// returns an unrecoverable error. On exit, removes the socket file.
///
/// Startup reconstruction:
/// - With `Some(snapshot)`: validate its `schema_hash` against the
/// executor, seed the store from the snapshot, replay only the log
/// tail (entries with `seq > snapshot.seq`).
/// - With `None`: full replay from seq 0. Slower for long logs.
///
/// In both cases the store is wiped first, so the server never serves
/// requests against a state the log can't reproduce. This is true for
/// `MemoryStore` and for persistent backends like `SurrealStore` —
/// persistence is a durability property of the runtime cache, not a
/// way to skip replay. (A future "skip replay if last_applied_seq
/// matches" optimization would change that.)
pub fn run_server<S: Store>(
executor: Executor,
mut log: EventLog,
mut store: S,
snapshot: Option<Snapshot>,
socket_path: &Path,
) -> Result<(), RunError> {
startup_replay(&executor, &log, &mut store, snapshot.as_ref())?;
// Best-effort cleanup of stale sockets from a prior crashed run.
// Bind itself will fail if a live process is already listening.
let _ = std::fs::remove_file(socket_path);
let listener = UnixListener::bind(socket_path)?;
let result = accept_loop(&listener, &executor, &mut store, &mut log);
let _ = std::fs::remove_file(socket_path);
result
}
fn startup_replay<S: Store>(
executor: &Executor,
log: &EventLog,
store: &mut S,
snapshot: Option<&Snapshot>,
) -> Result<(), RunError> {
// Snapshot validation runs first (cheap) so a bad snapshot is caught
// even when we'd otherwise take the skip-replay fast path.
if let Some(snap) = snapshot {
snap.ensure_compatible_with(executor)?;
let entries = log.entries()?;
if let Some(first) = entries.first() {
let expected = snap.seq.saturating_add(1);
if first.seq() > expected {
return Err(RunError::SnapshotGap {
snap_seq: snap.seq,
log_first_seq: first.seq(),
expected,
});
}
}
}
// Fast path: persistent stores carry a `last_applied_seq` marker;
// when it matches the log's last seq, the store is verifiably in
// sync and we can skip the clear+replay entirely. Failures here
// (e.g. backend can't read meta) just fall through to full replay
// — never a correctness issue.
let log_last_seq = log.entries()?.last().map(|e| e.seq());
if let Ok(applied) = store.last_applied_seq() {
if applied == log_last_seq && applied.is_some() {
return Ok(());
}
}
store.clear().map_err(RunError::Clear)?;
replay_with_snapshot_into(log, snapshot, store)?;
Ok(())
}
fn accept_loop<S: Store>(
listener: &UnixListener,
executor: &Executor,
store: &mut S,
log: &mut EventLog,
) -> Result<(), RunError> {
loop {
let (stream, _addr) = listener.accept()?;
let shutdown = handle_connection(stream, executor, store, log);
if shutdown {
return Ok(());
}
}
}
#[derive(Debug, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
enum Request {
Execute {
morphism: String,
#[serde(default)]
inputs: std::collections::BTreeMap<String, Uuid>,
#[serde(default)]
params: Value,
},
Load {
entity: String,
id: Uuid,
},
Describe,
Verify,
/// Return the SHA-256 of the live store's full state plus a record
/// count. Used by the drift detector as the cheap fast-path check
/// before asking for the full record dump.
HashState,
/// Return every record on the server in canonical order. Used after
/// a hash mismatch to compute the per-record diff. Response can be
/// large — the operator opts into it.
DumpRecords,
Shutdown,
}
/// Process one connection. Returns `true` if the client requested
/// shutdown — the caller should stop the accept loop after the response
/// has been flushed.
///
/// IO errors on a single connection don't kill the server: we log to
/// stderr and move on. Only a request-level shutdown ends the loop.
fn handle_connection<S: Store>(
stream: UnixStream,
executor: &Executor,
store: &mut S,
log: &mut EventLog,
) -> bool {
let mut writer = match stream.try_clone() {
Ok(s) => s,
Err(e) => {
eprintln!("nakui run: clone stream: {}", e);
return false;
}
};
let reader = BufReader::new(stream);
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(e) => {
eprintln!("nakui run: read: {}", e);
return false;
}
};
if line.trim().is_empty() {
continue;
}
let (response, shutdown) = dispatch(&line, executor, store, log);
let bytes = serde_json::to_vec(&response).expect("response serializes");
if let Err(e) = writer.write_all(&bytes).and_then(|_| writer.write_all(b"\n")) {
eprintln!("nakui run: write: {}", e);
return false;
}
if shutdown {
let _ = writer.flush();
return true;
}
}
false
}
fn dispatch<S: Store>(
line: &str,
executor: &Executor,
store: &mut S,
log: &mut EventLog,
) -> (Value, bool) {
let req: Request = match serde_json::from_str(line) {
Ok(r) => r,
Err(e) => return (error_response(&format!("bad request: {}", e)), false),
};
match req {
Request::Execute {
morphism,
inputs,
params,
} => {
let inputs_vec: Vec<(&str, Uuid)> =
inputs.iter().map(|(k, v)| (k.as_str(), *v)).collect();
match execute_and_log_with_recovery(
executor,
store,
log,
&morphism,
&inputs_vec,
params,
) {
Ok(ops) => (
json!({
"ok": true,
"seq": log.next_seq().saturating_sub(1),
"ops": ops,
"schema_hash": executor.schema_hash(&morphism).map(|h| hex_encode(&h)),
}),
false,
),
Err(RecoverableExecuteError::PreLog(e)) => (
json!({"ok": false, "stage": "pre_log", "error": e.to_string()}),
false,
),
Err(RecoverableExecuteError::LogAppend(e)) => (
json!({"ok": false, "stage": "log_append", "error": e.to_string()}),
false,
),
Err(e @ RecoverableExecuteError::Unrecoverable { .. }) => (
json!({"ok": false, "stage": "unrecoverable", "error": e.to_string()}),
false,
),
}
}
Request::Load { entity, id } => {
let value = store.load(&entity, id);
(json!({"ok": true, "value": value}), false)
}
Request::Describe => {
let hashes: std::collections::BTreeMap<String, String> = executor
.schema_hashes
.iter()
.map(|(k, v)| (k.clone(), hex_encode(v)))
.collect();
(
json!({
"ok": true,
"protocol": 1,
"module": executor.manifest.module,
"schemas": executor.manifest.effective_schemas(),
"morphisms": executor.manifest.morphisms,
"schema_hashes": hashes,
}),
false,
)
}
Request::Verify => match verify_log(log, executor) {
Ok(()) => {
let entries = log
.entries()
.map(|es| es.len())
.unwrap_or(0);
(json!({"ok": true, "entries": entries}), false)
}
Err(e) => (
json!({"ok": false, "error": e.to_string()}),
false,
),
},
Request::HashState => {
let records: Vec<_> = match store.iter() {
Ok(it) => it.collect(),
Err(e) => return (json!({"ok": false, "error": e.to_string()}), false),
};
let count = records.len();
let hash = match store.hash_state() {
Ok(h) => h,
Err(e) => return (json!({"ok": false, "error": e.to_string()}), false),
};
(
json!({
"ok": true,
"hash": hex_encode(&hash),
"records": count,
}),
false,
)
}
Request::DumpRecords => match store.iter() {
Ok(it) => {
let records: Vec<Value> = it
.map(|(entity, id, value)| {
json!({"entity": entity, "id": id, "value": value})
})
.collect();
(json!({"ok": true, "records": records}), false)
}
Err(e) => (json!({"ok": false, "error": e.to_string()}), false),
},
Request::Shutdown => (json!({"ok": true, "shutdown": true}), true),
}
}
fn error_response(msg: &str) -> Value {
json!({"ok": false, "error": msg})
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
+593
View File
@@ -0,0 +1,593 @@
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use thiserror::Error;
use uuid::Uuid;
use crate::delta::FieldOp;
#[derive(Debug, Clone, Error)]
pub enum StoreError {
#[error("entity {0} id {1} not found")]
NotFound(String, Uuid),
#[error("entity {0} id {1} already exists")]
Conflict(String, Uuid),
#[error("set on non-object record at {0} {1}")]
NotAnObject(String, Uuid),
/// Backend-specific transient or systemic failure (network, disk,
/// driver). Distinct from the data-shape errors above.
#[error("backend error: {0}")]
Backend(String),
}
pub trait Store {
fn load(&self, entity: &str, id: Uuid) -> Option<Value>;
/// Insert or replace a record without going through the morphism
/// pipeline. Represents external/boundary input — the source of
/// records that didn't originate from a kernel-validated event.
fn seed(&mut self, entity: &str, id: Uuid, data: Value);
/// Read-only check: would `apply(ops)` succeed against current state?
/// Does NOT mutate. The kernel runs this as the last step of `compute`
/// so that, by the time we log an event, the apply is contractually
/// guaranteed to land.
fn apply_dry_run(&self, ops: &[FieldOp]) -> Result<(), StoreError>;
fn apply(&mut self, ops: &[FieldOp]) -> Result<(), StoreError>;
/// Drop every record. Used by `reconcile` to wipe a stale store before
/// replaying the log. Must leave the store in the same state it would
/// be in immediately after construction. Implementors that override
/// `last_applied_seq` must reset that marker here too — a cleared
/// store has applied nothing.
fn clear(&mut self) -> Result<(), StoreError>;
/// The last log seq whose effects are reflected in this store, if
/// the store can persist that fact. Default `Ok(None)` covers
/// transient backends. The startup path uses this to skip the full
/// replay when the store is verifiably already in sync with the log.
fn last_applied_seq(&self) -> Result<Option<u64>, StoreError> {
Ok(None)
}
/// Persist the marker after a successful apply / seed / replay.
/// Best-effort: callers ignore failures here because a stale marker
/// only costs an extra full replay on next startup, never
/// correctness — full replay starts with `clear()`, so it tolerates
/// any prior state. Default impl is a no-op for transient backends.
fn set_last_applied_seq(&mut self, _seq: u64) -> Result<(), StoreError> {
Ok(())
}
/// Enumerate every record in canonical order: sorted first by entity
/// name, then by id bytes. The canonical order is what makes
/// `hash_state` reproducible — without it two stores with the same
/// records would hash differently depending on insertion order.
///
/// Returns owned `Value`s. For an in-memory backend this clones; for
/// a remote backend it materializes a snapshot. V1 chooses simplicity
/// over streaming — the hash and drift-comparison use cases need to
/// see all records anyway, and an iterator over a Vec keeps the
/// trait method object-safe and free of async lifetime concerns.
fn iter(&self) -> Result<Box<dyn Iterator<Item = (String, Uuid, Value)> + '_>, StoreError>;
/// Deterministic SHA-256 of the store's full state. Two stores with
/// the same records (regardless of how they got there or which
/// backend they live in) produce the same hash; any drift produces
/// a different one. The default impl is the contract — backends
/// should only override it for backend-native acceleration (e.g.
/// server-side table digests), and an override must produce the
/// same bytes as the default.
///
/// Framing per record:
/// entity_bytes | 0x00 | id_bytes | 0x00 | canonical_value_hash
/// The length prefix on entity/id prevents (entity="ab", id="c")
/// from colliding with (entity="a", id="bc"). The value bytes are
/// produced by `hash_value`, which walks the JSON tree with
/// type-tagged framing — that decouples the hash from
/// `serde_json::to_vec`'s representation choices (especially
/// integer-valued floats vs ints) so cross-backend comparison
/// works.
fn hash_state(&self) -> Result<[u8; 32], StoreError> {
let mut hasher = Sha256::new();
for (entity, id, value) in self.iter()? {
hasher.update(entity.as_bytes());
hasher.update([0u8]);
hasher.update(id.as_bytes());
hasher.update([0u8]);
hash_value(&mut hasher, &value);
}
Ok(hasher.finalize().into())
}
}
/// Canonical hash of a `serde_json::Value`. Type-tagged so a string
/// "true" can't collide with the boolean `true`; length-prefixed so
/// concatenation can't shift bytes between fields. Numbers normalize:
/// any integer-valued number (i64, u64, or a finite f64 with no
/// fractional part) is hashed as an i128 — that's what makes
/// cross-backend equality work, since SurrealDB may round-trip
/// what the caller wrote as `100_i64` back as the same numeric value
/// without us needing to commit to a wire-format-specific
/// representation.
pub fn hash_value(hasher: &mut Sha256, v: &Value) {
match v {
Value::Null => hasher.update([TAG_NULL]),
Value::Bool(b) => {
hasher.update([TAG_BOOL]);
hasher.update([*b as u8]);
}
Value::Number(n) => {
if let Some(i) = n.as_i64() {
hash_int(hasher, i as i128);
} else if let Some(u) = n.as_u64() {
hash_int(hasher, u as i128);
} else if let Some(f) = n.as_f64() {
// Integer-valued floats canonicalize to int. Anything
// else (fractions, NaN, infinities) hashes as the raw
// f64 bit pattern — that's still deterministic, just
// not normalized.
if f.is_finite()
&& f.fract() == 0.0
&& f >= I128_MIN_AS_F64
&& f <= I128_MAX_AS_F64
{
hash_int(hasher, f as i128);
} else {
hasher.update([TAG_FLOAT]);
hasher.update(f.to_bits().to_le_bytes());
}
} else {
// serde_json::Number guarantees one of the above; this
// branch only fires if a future variant appears.
hasher.update([TAG_FLOAT]);
hasher.update(f64::NAN.to_bits().to_le_bytes());
}
}
Value::String(s) => {
hasher.update([TAG_STRING]);
hasher.update((s.len() as u64).to_le_bytes());
hasher.update(s.as_bytes());
}
Value::Array(arr) => {
hasher.update([TAG_ARRAY]);
hasher.update((arr.len() as u64).to_le_bytes());
for item in arr {
hash_value(hasher, item);
}
}
Value::Object(map) => {
hasher.update([TAG_OBJECT]);
hasher.update((map.len() as u64).to_le_bytes());
// serde_json::Map without `preserve_order` is BTreeMap
// (alphabetical). We sort defensively in case the build
// pulls in `preserve_order` transitively from a future dep.
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
for k in keys {
hasher.update((k.len() as u64).to_le_bytes());
hasher.update(k.as_bytes());
hash_value(hasher, &map[k]);
}
}
}
}
fn hash_int(hasher: &mut Sha256, n: i128) {
hasher.update([TAG_INT]);
hasher.update(n.to_le_bytes());
}
const TAG_NULL: u8 = 0;
const TAG_BOOL: u8 = 1;
const TAG_INT: u8 = 2;
const TAG_FLOAT: u8 = 3;
const TAG_STRING: u8 = 4;
const TAG_ARRAY: u8 = 5;
const TAG_OBJECT: u8 = 6;
// f64 can't represent i128::MAX exactly; the cast truncates upward to
// the next representable f64. Use those as the comparison bounds so
// `f as i128` stays well-defined.
const I128_MIN_AS_F64: f64 = -1.7014118346046923e38;
const I128_MAX_AS_F64: f64 = 1.7014118346046923e38;
#[derive(Debug, Default, Clone, PartialEq)]
pub struct MemoryStore {
records: HashMap<String, HashMap<Uuid, Value>>,
/// Last log seq whose effects are reflected here. In-process only —
/// resets to `None` on construction or `clear`. The skip-replay
/// optimization in `nakui run` benefits the persistent backends;
/// for `MemoryStore` it's harmless bookkeeping (process restart =
/// new store = `None`, which forces full replay).
last_applied: Option<u64>,
}
impl MemoryStore {
pub fn new() -> Self {
Self::default()
}
/// Borrow the internal records map. Used by `Snapshot::from_memory_store`
/// to capture state for snapshot persistence.
pub fn records(&self) -> &HashMap<String, HashMap<Uuid, Value>> {
&self.records
}
}
impl Store for MemoryStore {
fn load(&self, entity: &str, id: Uuid) -> Option<Value> {
self.records.get(entity)?.get(&id).cloned()
}
fn seed(&mut self, entity: &str, id: Uuid, data: Value) {
self.records
.entry(entity.to_string())
.or_default()
.insert(id, data);
}
fn apply_dry_run(&self, ops: &[FieldOp]) -> Result<(), StoreError> {
for op in ops {
match op {
FieldOp::Set { path, .. } => {
match self.records.get(&path.entity).and_then(|m| m.get(&path.id)) {
None => {
return Err(StoreError::NotFound(path.entity.clone(), path.id));
}
Some(Value::Object(_)) => {}
Some(_) => {
return Err(StoreError::NotAnObject(path.entity.clone(), path.id));
}
}
}
FieldOp::Create { entity, id, .. } => {
if self
.records
.get(entity)
.and_then(|m| m.get(id))
.is_some()
{
return Err(StoreError::Conflict(entity.clone(), *id));
}
}
FieldOp::Delete { entity, id } => {
if self
.records
.get(entity)
.and_then(|m| m.get(id))
.is_none()
{
return Err(StoreError::NotFound(entity.clone(), *id));
}
}
}
}
Ok(())
}
fn apply(&mut self, ops: &[FieldOp]) -> Result<(), StoreError> {
self.apply_dry_run(ops)?;
for op in ops {
match op {
FieldOp::Set { path, value } => {
let rec = self
.records
.get_mut(&path.entity)
.and_then(|m| m.get_mut(&path.id))
.expect("validated by dry_run");
let map = match rec {
Value::Object(m) => m,
_ => unreachable!("dry_run guards against non-object"),
};
map.insert(path.field.clone(), value.clone());
}
FieldOp::Create { entity, id, data } => {
self.records
.entry(entity.clone())
.or_default()
.insert(*id, data.clone());
}
FieldOp::Delete { entity, id } => {
self.records
.get_mut(entity)
.expect("validated by dry_run")
.remove(id);
}
}
}
Ok(())
}
fn clear(&mut self) -> Result<(), StoreError> {
self.records.clear();
self.last_applied = None;
Ok(())
}
fn last_applied_seq(&self) -> Result<Option<u64>, StoreError> {
Ok(self.last_applied)
}
fn set_last_applied_seq(&mut self, seq: u64) -> Result<(), StoreError> {
self.last_applied = Some(seq);
Ok(())
}
fn iter(&self) -> Result<Box<dyn Iterator<Item = (String, Uuid, Value)> + '_>, StoreError> {
let mut out: Vec<(String, Uuid, Value)> = self
.records
.iter()
.flat_map(|(entity, m)| {
m.iter()
.map(move |(id, v)| (entity.clone(), *id, v.clone()))
})
.collect();
out.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.as_bytes().cmp(b.1.as_bytes())));
Ok(Box::new(out.into_iter()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::delta::{FieldOp, FieldPath};
use serde_json::json;
#[test]
fn dry_run_rejects_set_on_non_object() {
let mut store = MemoryStore::new();
let id = Uuid::new_v4();
store.seed("Caja", id, json!(42)); // not an object
let op = FieldOp::Set {
path: FieldPath {
entity: "Caja".into(),
id,
field: "saldo".into(),
},
value: json!(100),
};
match store.apply_dry_run(&[op.clone()]) {
Err(StoreError::NotAnObject(e, i)) => {
assert_eq!(e, "Caja");
assert_eq!(i, id);
}
other => panic!("expected NotAnObject, got {:?}", other),
}
// apply must reject too without panicking.
assert!(matches!(
store.apply(&[op]),
Err(StoreError::NotAnObject(_, _))
));
}
#[test]
fn dry_run_rejects_create_conflict() {
let mut store = MemoryStore::new();
let id = Uuid::new_v4();
store.seed("Caja", id, json!({"id": id.to_string()}));
let op = FieldOp::Create {
entity: "Caja".into(),
id,
data: json!({"id": id.to_string()}),
};
assert!(matches!(
store.apply_dry_run(&[op]),
Err(StoreError::Conflict(_, _))
));
}
#[test]
fn dry_run_passes_for_valid_set() {
let mut store = MemoryStore::new();
let id = Uuid::new_v4();
store.seed("Caja", id, json!({"saldo": 100, "currency": "USD"}));
let op = FieldOp::Set {
path: FieldPath {
entity: "Caja".into(),
id,
field: "saldo".into(),
},
value: json!(150),
};
assert!(store.apply_dry_run(&[op]).is_ok());
}
#[test]
fn iter_returns_canonical_order_regardless_of_insertion() {
let a = Uuid::new_v4();
let b = Uuid::new_v4();
let c = Uuid::new_v4();
let mut s1 = MemoryStore::new();
s1.seed("Caja", a, json!({"id": a.to_string(), "x": 1}));
s1.seed("Movimiento", c, json!({"id": c.to_string(), "y": 3}));
s1.seed("Caja", b, json!({"id": b.to_string(), "x": 2}));
let mut s2 = MemoryStore::new();
s2.seed("Movimiento", c, json!({"id": c.to_string(), "y": 3}));
s2.seed("Caja", b, json!({"id": b.to_string(), "x": 2}));
s2.seed("Caja", a, json!({"id": a.to_string(), "x": 1}));
let r1: Vec<_> = s1.iter().unwrap().collect();
let r2: Vec<_> = s2.iter().unwrap().collect();
assert_eq!(r1, r2, "iter order must be insertion-independent");
// Entities lexicographically sorted (Caja before Movimiento).
let entities: Vec<&str> = r1.iter().map(|(e, _, _)| e.as_str()).collect();
assert_eq!(entities, vec!["Caja", "Caja", "Movimiento"]);
// Within Caja, ids in byte order.
let caja_ids: Vec<Uuid> = r1
.iter()
.filter(|(e, _, _)| e == "Caja")
.map(|(_, id, _)| *id)
.collect();
let mut expected = vec![a, b];
expected.sort_by(|x, y| x.as_bytes().cmp(y.as_bytes()));
assert_eq!(caja_ids, expected);
}
#[test]
fn hash_state_is_deterministic_and_independent_of_insertion_order() {
let a = Uuid::new_v4();
let b = Uuid::new_v4();
let mut s1 = MemoryStore::new();
s1.seed("Caja", a, json!({"id": a.to_string(), "saldo": 100}));
s1.seed("Caja", b, json!({"id": b.to_string(), "saldo": 200}));
let mut s2 = MemoryStore::new();
s2.seed("Caja", b, json!({"id": b.to_string(), "saldo": 200}));
s2.seed("Caja", a, json!({"id": a.to_string(), "saldo": 100}));
assert_eq!(
s1.hash_state().unwrap(),
s2.hash_state().unwrap(),
"equal state must hash identically regardless of how it was built"
);
}
#[test]
fn hash_state_changes_when_state_changes() {
let a = Uuid::new_v4();
let mut s1 = MemoryStore::new();
s1.seed("Caja", a, json!({"id": a.to_string(), "saldo": 100}));
let mut s2 = MemoryStore::new();
s2.seed("Caja", a, json!({"id": a.to_string(), "saldo": 101}));
assert_ne!(
s1.hash_state().unwrap(),
s2.hash_state().unwrap(),
"off-by-one in a single field must produce a different hash"
);
// Adding a record changes the hash too.
let mut s3 = MemoryStore::new();
s3.seed("Caja", a, json!({"id": a.to_string(), "saldo": 100}));
s3.seed("Caja", Uuid::new_v4(), json!({"id": "extra", "saldo": 0}));
assert_ne!(s1.hash_state().unwrap(), s3.hash_state().unwrap());
}
#[test]
fn last_applied_seq_round_trips_and_resets_on_clear() {
let mut store = MemoryStore::new();
assert_eq!(
store.last_applied_seq().unwrap(),
None,
"fresh MemoryStore has no marker"
);
store.set_last_applied_seq(5).unwrap();
assert_eq!(store.last_applied_seq().unwrap(), Some(5));
store.set_last_applied_seq(12).unwrap();
assert_eq!(store.last_applied_seq().unwrap(), Some(12));
store.clear().unwrap();
assert_eq!(
store.last_applied_seq().unwrap(),
None,
"clear() resets the marker — a cleared store has applied nothing"
);
}
#[test]
fn integer_and_integer_valued_float_hash_identically() {
// The cross-backend property: the same numeric value, written
// by a backend as i64 vs read back as integer-valued f64,
// must hash the same.
let int_value = json!({"saldo": 100_i64});
let float_value = json!({"saldo": 100.0_f64});
let mut h_int = sha2::Sha256::new();
super::hash_value(&mut h_int, &int_value);
let mut h_float = sha2::Sha256::new();
super::hash_value(&mut h_float, &float_value);
assert_eq!(
h_int.finalize(),
h_float.finalize(),
"integer-valued numbers must canonicalize regardless of source representation"
);
}
#[test]
fn fractional_floats_do_not_canonicalize_to_int() {
// Floats with fractional parts must remain floats — collapsing
// 100.5 into 100 would hide real differences.
let int_value = json!({"x": 100_i64});
let frac_value = json!({"x": 100.5_f64});
let mut h_int = sha2::Sha256::new();
super::hash_value(&mut h_int, &int_value);
let mut h_frac = sha2::Sha256::new();
super::hash_value(&mut h_frac, &frac_value);
assert_ne!(
h_int.finalize(),
h_frac.finalize(),
"100 and 100.5 must hash differently"
);
}
#[test]
fn same_object_with_different_insertion_order_hashes_same() {
// serde_json::Map is BTreeMap by default but we sort defensively
// in case `preserve_order` is enabled by some transitive dep.
let mut m1 = serde_json::Map::new();
m1.insert("a".into(), json!(1));
m1.insert("b".into(), json!(2));
m1.insert("c".into(), json!(3));
let mut m2 = serde_json::Map::new();
m2.insert("c".into(), json!(3));
m2.insert("a".into(), json!(1));
m2.insert("b".into(), json!(2));
let mut h1 = sha2::Sha256::new();
super::hash_value(&mut h1, &Value::Object(m1));
let mut h2 = sha2::Sha256::new();
super::hash_value(&mut h2, &Value::Object(m2));
assert_eq!(h1.finalize(), h2.finalize());
}
#[test]
fn type_tagged_framing_distinguishes_string_from_number() {
// The string "42" must not collide with the number 42.
let str_v = json!("42");
let num_v = json!(42);
let mut h_str = sha2::Sha256::new();
super::hash_value(&mut h_str, &str_v);
let mut h_num = sha2::Sha256::new();
super::hash_value(&mut h_num, &num_v);
assert_ne!(h_str.finalize(), h_num.finalize());
// Bool true must not collide with the number 1.
let bool_v = json!(true);
let one_v = json!(1);
let mut h_bool = sha2::Sha256::new();
super::hash_value(&mut h_bool, &bool_v);
let mut h_one = sha2::Sha256::new();
super::hash_value(&mut h_one, &one_v);
assert_ne!(h_bool.finalize(), h_one.finalize());
}
#[test]
fn empty_store_has_a_well_defined_hash() {
let s1 = MemoryStore::new();
let s2 = MemoryStore::new();
assert_eq!(s1.hash_state().unwrap(), s2.hash_state().unwrap());
// The empty hash is the SHA-256 of an empty input — fix the
// expected bytes so an accidental framing change in `hash_state`
// can't silently sail through.
let expected = hex_decode(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
);
assert_eq!(s1.hash_state().unwrap().to_vec(), expected);
}
fn hex_decode(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("hex"))
.collect()
}
}
@@ -0,0 +1,403 @@
//! SurrealDB-backed `Store` implementation.
//!
//! Wraps an embedded `kv-mem` SurrealDB instance behind the same sync
//! `Store` trait the kernel uses. Each instance owns a private `tokio`
//! current-thread runtime and `block_on`s every async call.
//!
//! Why everything goes through `db.query()`:
//! SurrealDB 2.x's typed-response API (`db.upsert(thing).content(data)`)
//! deserializes responses through a serializer that is hostile to
//! `serde_json::Value` and to dynamic record shapes. Using raw SurrealQL
//! with parameter binding sidesteps that — `Response::check()` validates
//! success without forcing us to materialize the response into a typed
//! shape.
//!
//! Identity handling: SurrealDB owns record identity via a `RecordId`
//! (table:id). We strip the application-level `id` field before sending
//! and restore it on read so KCL schemas (which require `id: str`) see
//! a stable shape.
use serde_json::Value;
use surrealdb::Surreal;
use surrealdb::engine::local::{Db, Mem};
#[cfg(feature = "persistent")]
use surrealdb::engine::local::SurrealKv;
use thiserror::Error;
use tokio::runtime::Runtime;
use uuid::Uuid;
use crate::delta::FieldOp;
use crate::store::{Store, StoreError};
/// Reserved table prefix for runtime metadata that lives alongside user
/// records. Anything starting with this prefix is hidden from `iter`
/// (and therefore from `hash_state`, `dump_records`, drift detection)
/// so user-facing views never see internal bookkeeping.
const META_TABLE_PREFIX: &str = "nakui_";
/// Single-record table where `last_applied_seq` lives. Singleton id =
/// `singleton`. Wiped by `clear()` because the table prefix is part of
/// the enumeration there — a cleared store has applied nothing.
const META_TABLE: &str = "nakui_runtime_meta";
const META_SINGLETON_ID: &str = "singleton";
/// Field alias used by `iter` to surface the application-level record
/// id alongside the rest of the row, in a single per-table query. The
/// alias is stripped before the row is handed back to the caller, so
/// it never shows up in user views. Reserved — a user record with a
/// field of this name would collide and `iter` would error on UUID
/// parse failure.
const ITER_ID_ALIAS: &str = "__nakui_app_id";
#[derive(Debug, Error)]
pub enum SurrealStoreError {
#[error("io creating tokio runtime: {0}")]
Runtime(#[from] std::io::Error),
#[error("surrealdb: {0}")]
Backend(#[from] surrealdb::Error),
}
pub struct SurrealStore {
runtime: Runtime,
db: Surreal<Db>,
}
impl SurrealStore {
/// Build an in-memory SurrealDB instance (`kv-mem`). Volatile —
/// nothing persists when the process exits.
pub fn new_in_memory() -> Result<Self, SurrealStoreError> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let db = runtime.block_on(async {
let db = Surreal::new::<Mem>(()).await?;
db.use_ns("nakui").use_db("default").await?;
Ok::<_, surrealdb::Error>(db)
})?;
Ok(Self { runtime, db })
}
/// Build a SurrealKV-backed SurrealDB instance at `path`. Records
/// survive process restarts. Requires the `persistent` Cargo feature.
///
/// Reopening an existing path resumes from the persisted state — the
/// canonical use is `let store = SurrealStore::new_persistent(path)?`
/// at process startup, with the path stable across runs.
#[cfg(feature = "persistent")]
pub fn new_persistent(
path: impl AsRef<std::path::Path>,
) -> Result<Self, SurrealStoreError> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let path = path.as_ref().to_path_buf();
let db = runtime.block_on(async {
let db = Surreal::new::<SurrealKv>(path).await?;
db.use_ns("nakui").use_db("default").await?;
Ok::<_, surrealdb::Error>(db)
})?;
Ok(Self { runtime, db })
}
}
fn strip_app_id(mut data: Value) -> Value {
if let Value::Object(map) = &mut data {
map.remove("id");
}
data
}
fn restore_app_id(mut data: Value, id: Uuid) -> Value {
if let Value::Object(map) = &mut data {
map.insert("id".into(), Value::String(id.to_string()));
}
data
}
fn json_to_map(v: Value) -> Result<serde_json::Map<String, Value>, StoreError> {
match v {
Value::Object(map) => Ok(map),
_ => Err(StoreError::Backend(
"SurrealStore expects object-shaped records".into(),
)),
}
}
fn map_err(e: surrealdb::Error) -> StoreError {
StoreError::Backend(e.to_string())
}
impl Store for SurrealStore {
fn load(&self, entity: &str, id: Uuid) -> Option<Value> {
let entity = entity.to_string();
let id_str = id.to_string();
self.runtime.block_on(async {
// `OMIT id` skips SurrealDB's Thing-typed id which serde_json::Value
// can't represent; we restore the application id ourselves.
let mut response = self
.db
.query("SELECT * OMIT id FROM type::thing($table, $id)")
.bind(("table", entity))
.bind(("id", id_str))
.await
.ok()?;
let rows: Vec<Value> = response.take(0).ok()?;
let row = rows.into_iter().next()?;
Some(restore_app_id(row, id))
})
}
fn seed(&mut self, entity: &str, id: Uuid, data: Value) {
let stripped = strip_app_id(data);
let map = json_to_map(stripped).expect("seed data is object-shaped");
let entity = entity.to_string();
let id_str = id.to_string();
self.runtime.block_on(async {
self.db
.query("UPSERT type::thing($table, $id) CONTENT $data")
.bind(("table", entity))
.bind(("id", id_str))
.bind(("data", map))
.await
.and_then(|r| r.check())
.expect("seed upsert");
});
}
fn apply_dry_run(&self, ops: &[FieldOp]) -> Result<(), StoreError> {
self.runtime.block_on(async {
for op in ops {
match op {
FieldOp::Set { path, .. } => {
let exists = self.exists(&path.entity, path.id).await?;
if !exists {
return Err(StoreError::NotFound(
path.entity.clone(),
path.id,
));
}
// We don't model NotAnObject for SurrealStore: every
// record stored via this trait is map-shaped by
// construction (json_to_map enforces it on write).
}
FieldOp::Create { entity, id, .. } => {
if self.exists(entity, *id).await? {
return Err(StoreError::Conflict(entity.clone(), *id));
}
}
FieldOp::Delete { entity, id } => {
if !self.exists(entity, *id).await? {
return Err(StoreError::NotFound(entity.clone(), *id));
}
}
}
}
Ok(())
})
}
fn iter(&self) -> Result<Box<dyn Iterator<Item = (String, Uuid, Value)> + '_>, StoreError> {
// One query per table: pull the application id alongside every
// other field via an alias, strip the SurrealDB-typed `id` via
// OMIT, then restore the application `id` field in code so the
// output is byte-identical to what `load()` produces (cross-
// backend hash equality and the `iter ↔ load` parity contract
// both depend on this).
//
// Filters runtime metadata tables (META_TABLE_PREFIX) so client
// views never leak internal bookkeeping.
self.runtime.block_on(async {
let mut info = self
.db
.query("INFO FOR DB")
.await
.and_then(|r| r.check())
.map_err(map_err)?;
let row: Option<Value> = info.take(0).map_err(map_err)?;
let tables: Vec<String> = row
.as_ref()
.and_then(|v| v.get("tables"))
.and_then(|v| v.as_object())
.map(|m| {
m.keys()
.filter(|k| !k.starts_with(META_TABLE_PREFIX))
.cloned()
.collect()
})
.unwrap_or_default();
let mut out: Vec<(String, Uuid, Value)> = Vec::new();
for table in &tables {
// The alias is parameterised in the SELECT clause so the
// SurrealQL parser sees a literal field name; we can't
// bind it as a parameter (only values bind, not
// identifiers), but it's a compile-time constant so
// there's no injection surface.
let select = format!(
"SELECT meta::id(id) AS {alias}, * OMIT id FROM type::table($t)",
alias = ITER_ID_ALIAS,
);
let mut resp = self
.db
.query(&select)
.bind(("t", table.clone()))
.await
.and_then(|r| r.check())
.map_err(map_err)?;
let rows: Vec<Value> = resp.take(0).map_err(map_err)?;
for row in rows {
let Value::Object(mut map) = row else {
return Err(StoreError::Backend(format!(
"row in table {} is not an object",
table
)));
};
let app_id_str = match map.remove(ITER_ID_ALIAS) {
Some(Value::String(s)) => s,
_ => {
return Err(StoreError::Backend(format!(
"row in table {} missing alias `{}`",
table, ITER_ID_ALIAS
)));
}
};
let id = Uuid::parse_str(&app_id_str).map_err(|e| {
StoreError::Backend(format!(
"non-uuid id in table {}: {} ({})",
table, app_id_str, e
))
})?;
// Match `restore_app_id`: insert the application id
// back as a regular `id: <uuid_str>` field. Callers
// reading the row see exactly what `load()` returns.
map.insert("id".into(), Value::String(app_id_str));
out.push((table.clone(), id, Value::Object(map)));
}
}
out.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.as_bytes().cmp(b.1.as_bytes())));
Ok(Box::new(out.into_iter())
as Box<dyn Iterator<Item = (String, Uuid, Value)>>)
})
}
fn clear(&mut self) -> Result<(), StoreError> {
// Wipes EVERY table including the runtime meta table — a
// cleared store must report `last_applied_seq() == None`.
self.runtime.block_on(async {
let mut info = self
.db
.query("INFO FOR DB")
.await
.and_then(|r| r.check())
.map_err(map_err)?;
let row: Option<Value> = info.take(0).map_err(map_err)?;
let tables = row
.as_ref()
.and_then(|v| v.get("tables"))
.and_then(|v| v.as_object());
let names: Vec<String> = match tables {
Some(map) => map.keys().cloned().collect(),
None => Vec::new(),
};
for name in names {
self.db
.query("DELETE FROM type::table($t)")
.bind(("t", name))
.await
.and_then(|r| r.check())
.map_err(map_err)?;
}
Ok(())
})
}
fn last_applied_seq(&self) -> Result<Option<u64>, StoreError> {
self.runtime.block_on(async {
let mut resp = self
.db
.query("SELECT VALUE last_applied_seq FROM type::thing($t, $id)")
.bind(("t", META_TABLE))
.bind(("id", META_SINGLETON_ID))
.await
.and_then(|r| r.check())
.map_err(map_err)?;
// The query yields either zero rows (no meta record yet) or
// one row containing the i64 value.
let rows: Vec<i64> = resp.take(0).map_err(map_err)?;
Ok(rows.into_iter().next().map(|v| v as u64))
})
}
fn set_last_applied_seq(&mut self, seq: u64) -> Result<(), StoreError> {
let seq_signed = seq as i64;
self.runtime.block_on(async {
self.db
.query("UPSERT type::thing($t, $id) CONTENT { last_applied_seq: $seq }")
.bind(("t", META_TABLE))
.bind(("id", META_SINGLETON_ID))
.bind(("seq", seq_signed))
.await
.and_then(|r| r.check())
.map_err(map_err)?;
Ok(())
})
}
fn apply(&mut self, ops: &[FieldOp]) -> Result<(), StoreError> {
self.apply_dry_run(ops)?;
self.runtime.block_on(async {
for op in ops {
match op {
FieldOp::Set { path, value } => {
let mut patch = serde_json::Map::new();
patch.insert(path.field.clone(), value.clone());
self.db
.query("UPDATE type::thing($table, $id) MERGE $patch")
.bind(("table", path.entity.clone()))
.bind(("id", path.id.to_string()))
.bind(("patch", patch))
.await
.and_then(|r| r.check())
.map_err(map_err)?;
}
FieldOp::Create { entity, id, data } => {
let stripped = strip_app_id(data.clone());
let map = json_to_map(stripped)?;
self.db
.query("CREATE type::thing($table, $id) CONTENT $data")
.bind(("table", entity.clone()))
.bind(("id", id.to_string()))
.bind(("data", map))
.await
.and_then(|r| r.check())
.map_err(map_err)?;
}
FieldOp::Delete { entity, id } => {
self.db
.query("DELETE type::thing($table, $id)")
.bind(("table", entity.clone()))
.bind(("id", id.to_string()))
.await
.and_then(|r| r.check())
.map_err(map_err)?;
}
}
}
Ok(())
})
}
}
impl SurrealStore {
async fn exists(&self, entity: &str, id: Uuid) -> Result<bool, StoreError> {
let mut response = self
.db
.query("SELECT * OMIT id FROM type::thing($table, $id)")
.bind(("table", entity.to_string()))
.bind(("id", id.to_string()))
.await
.map_err(map_err)?;
let rows: Vec<Value> = response.take(0).map_err(map_err)?;
Ok(!rows.is_empty())
}
}
+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);
}
@@ -0,0 +1,36 @@
// recibir_stock
// Inflow: external supplier delivers `cantidad` units into a Stock record.
// NOT conservation-bound: the units enter the system from outside.
//
// states.stock: the current Stock record.
// ids.stock: its UUID (string).
// params: { cantidad:i64, timestamp:str, movimiento_id:str }
let cantidad = input.params.cantidad;
if cantidad <= 0 {
throw "cantidad debe ser positiva (la dirección la fija el morfismo)"
}
let mov_id = input.params.movimiento_id;
if type_of(mov_id) == "()" {
throw "params.movimiento_id es obligatorio (idempotencia)"
}
[
#{
op: "set",
path: #{ entity: "Stock", id: input.ids.stock, field: "cantidad" },
value: input.states.stock.cantidad + cantidad,
},
#{
op: "create",
entity: "MovimientoStock",
id: mov_id,
data: #{
id: mov_id,
stock_id: input.ids.stock,
delta: cantidad,
razon: "recepcion",
timestamp: input.params.timestamp,
},
},
]
@@ -0,0 +1,50 @@
// transferir_stock
// Two-input morphism. Conservation rule (Stock.cantidad grouped by sku_id)
// is enforced by the kernel against the produced ops. Same-SKU is also
// asserted in-script for an explicit error message; without that check
// the kernel would still catch the violation via per-sku grouping.
//
// states.source / states.dest: the two Stock records.
// ids.source / ids.dest: their UUIDs.
// params: { cantidad:i64, timestamp:str, transfer_id:str }
let cantidad = input.params.cantidad;
let source = input.states.source;
let dest = input.states.dest;
if cantidad <= 0 {
throw "cantidad debe ser positiva"
}
if source.sku_id != dest.sku_id {
throw "transferencia exige mismo SKU; source=" + source.sku_id + " dest=" + dest.sku_id
}
let xfr_id = input.params.transfer_id;
if type_of(xfr_id) == "()" {
throw "params.transfer_id es obligatorio (idempotencia)"
}
[
#{
op: "set",
path: #{ entity: "Stock", id: input.ids.source, field: "cantidad" },
value: source.cantidad - cantidad,
},
#{
op: "set",
path: #{ entity: "Stock", id: input.ids.dest, field: "cantidad" },
value: dest.cantidad + cantidad,
},
#{
op: "create",
entity: "TransferenciaStock",
id: xfr_id,
data: #{
id: xfr_id,
source_stock_id: input.ids.source,
dest_stock_id: input.ids.dest,
sku_id: source.sku_id,
cantidad: cantidad,
timestamp: input.params.timestamp,
},
},
]
@@ -0,0 +1,31 @@
{
"module": "inventory",
"morphisms": [
{
"name": "recibir_stock",
"inputs": [
{ "role": "stock", "entity": "Stock" }
],
"reads": ["stock.cantidad", "stock.sku_id"],
"writes": ["stock.cantidad", "MovimientoStock"],
"depends_on": [],
"script": "morphisms/recibir_stock.rhai"
},
{
"name": "transferir_stock",
"inputs": [
{ "role": "source", "entity": "Stock" },
{ "role": "dest", "entity": "Stock" }
],
"reads": ["source.cantidad", "source.sku_id", "dest.cantidad", "dest.sku_id"],
"writes": ["source.cantidad", "dest.cantidad", "TransferenciaStock"],
"invariants": {
"conserve": [
{ "entity": "Stock", "field": "cantidad", "group_by": "sku_id" }
]
},
"depends_on": [],
"script": "morphisms/transferir_stock.rhai"
}
]
}
@@ -0,0 +1,34 @@
schema Stock:
id: str
sku_id: str
ubicacion: str
cantidad: int
check:
cantidad >= 0, "stock no puede ser negativo"
len(ubicacion) > 0, "ubicacion requerida"
len(sku_id) > 0, "sku_id requerido"
schema MovimientoStock:
id: str
stock_id: str
delta: int
razon: str
timestamp: str
check:
razon in ["recepcion", "despacho", "ajuste"], "razon invalida"
delta != 0, "delta no puede ser cero"
schema TransferenciaStock:
id: str
source_stock_id: str
dest_stock_id: str
sku_id: str
cantidad: int
timestamp: str
check:
cantidad > 0, "cantidad debe ser positiva"
source_stock_id != dest_stock_id, "source y dest no pueden ser el mismo stock"
len(sku_id) > 0, "sku_id requerido"
@@ -0,0 +1,52 @@
// vender
// Cross-module morphism: decreases Stock (inventory) and increases Caja
// (treasury). NOT conservation-bound — a sale is asymmetric: units leave
// the system, money enters. The kernel validates each entity against its
// own schema (Stock from inventory, Caja from treasury, Venta from sales).
//
// states.stock: the Stock record (inventory module).
// states.caja: the Caja record (treasury module).
// params: { cantidad:i64, precio_unitario:i64 (en centavos),
// timestamp:str, venta_id:str }
let cantidad = input.params.cantidad;
let precio = input.params.precio_unitario;
let venta_id = input.params.venta_id;
if cantidad <= 0 { throw "cantidad debe ser positiva" }
if precio <= 0 { throw "precio_unitario debe ser positivo" }
if type_of(venta_id) == "()" { throw "params.venta_id es obligatorio (idempotencia)" }
let stock = input.states.stock;
let caja = input.states.caja;
let total = cantidad * precio;
[
#{
op: "set",
path: #{ entity: "Stock", id: input.ids.stock, field: "cantidad" },
value: stock.cantidad - cantidad,
},
#{
op: "set",
path: #{ entity: "Caja", id: input.ids.caja, field: "saldo" },
value: caja.saldo + total,
},
#{
op: "create",
entity: "Venta",
id: venta_id,
data: #{
id: venta_id,
stock_id: input.ids.stock,
caja_id: input.ids.caja,
sku_id: stock.sku_id,
cantidad: cantidad,
precio_unitario: precio,
currency: caja.currency,
total: total,
timestamp: input.params.timestamp,
},
},
]
@@ -0,0 +1,21 @@
{
"module": "sales",
"schemas": [
"schema.k",
"../treasury/schema.k",
"../inventory/schema.k"
],
"morphisms": [
{
"name": "vender",
"inputs": [
{ "role": "stock", "entity": "Stock" },
{ "role": "caja", "entity": "Caja" }
],
"reads": ["stock.cantidad", "stock.sku_id", "caja.saldo", "caja.currency"],
"writes": ["stock.cantidad", "caja.saldo", "Venta"],
"depends_on": [],
"script": "morphisms/vender.rhai"
}
]
}
@@ -0,0 +1,16 @@
schema Venta:
id: str
stock_id: str
caja_id: str
sku_id: str
cantidad: int
precio_unitario: int
currency: str
total: int
timestamp: str
check:
cantidad > 0, "cantidad positiva"
precio_unitario > 0, "precio_unitario positivo"
len(currency) == 3, "currency ISO 4217"
total == cantidad * precio_unitario, "total debe ser cantidad * precio_unitario"
@@ -0,0 +1,46 @@
// register_cash_move
// Pure transition. Receives `input` as { states, ids, params }; returns
// an array of FieldOp objects. No clock, no random, no IO.
//
// states.caja: the current Caja record loaded from the store.
// ids.caja: the Caja's UUID (string form).
// params: { monto:i64, tipo:"in"|"out", timestamp:str, memo:str, movimiento_id:str }
let saldo = input.states.caja.saldo;
let monto = input.params.monto;
let tipo = input.params.tipo;
let caja_id = input.ids.caja;
let nuevo_saldo = if tipo == "in" {
saldo + monto
} else if tipo == "out" {
saldo - monto
} else {
throw "tipo desconocido: " + tipo
};
let mov_id = input.params.movimiento_id;
if type_of(mov_id) == "()" {
throw "params.movimiento_id es obligatorio (idempotencia)"
}
[
#{
op: "set",
path: #{ entity: "Caja", id: caja_id, field: "saldo" },
value: nuevo_saldo,
},
#{
op: "create",
entity: "Movimiento",
id: mov_id,
data: #{
id: mov_id,
caja_id: caja_id,
monto: monto,
tipo: tipo,
timestamp: input.params.timestamp,
memo: input.params.memo,
},
},
]
@@ -0,0 +1,53 @@
// transfer_between_cajas
// Two-input morphism. Conservation rule (Caja.saldo grouped by currency)
// is checked by the kernel against the produced ops; the script just emits
// the deltas.
//
// states.source / states.dest: the two Caja records.
// ids.source / ids.dest: their UUIDs (string form).
// params: { monto:i64, timestamp:str, memo:str, transfer_id:str }
let source = input.states.source;
let dest = input.states.dest;
let monto = input.params.monto;
if monto <= 0 {
throw "monto debe ser positivo"
}
if source.currency != dest.currency {
throw "transferencia exige misma moneda; source=" + source.currency + " dest=" + dest.currency
}
let transfer_id = input.params.transfer_id;
if type_of(transfer_id) == "()" {
throw "params.transfer_id es obligatorio (idempotencia)"
}
let new_source_saldo = source.saldo - monto;
let new_dest_saldo = dest.saldo + monto;
[
#{
op: "set",
path: #{ entity: "Caja", id: input.ids.source, field: "saldo" },
value: new_source_saldo,
},
#{
op: "set",
path: #{ entity: "Caja", id: input.ids.dest, field: "saldo" },
value: new_dest_saldo,
},
#{
op: "create",
entity: "Transferencia",
id: transfer_id,
data: #{
id: transfer_id,
source_caja_id: input.ids.source,
dest_caja_id: input.ids.dest,
monto: monto,
currency: source.currency,
timestamp: input.params.timestamp,
memo: input.params.memo,
},
},
]
@@ -0,0 +1,31 @@
{
"module": "treasury",
"morphisms": [
{
"name": "register_cash_move",
"inputs": [
{ "role": "caja", "entity": "Caja" }
],
"reads": ["caja.saldo", "caja.currency"],
"writes": ["caja.saldo", "Movimiento"],
"depends_on": [],
"script": "morphisms/register_cash_move.rhai"
},
{
"name": "transfer_between_cajas",
"inputs": [
{ "role": "source", "entity": "Caja" },
{ "role": "dest", "entity": "Caja" }
],
"reads": ["source.saldo", "source.currency", "dest.saldo", "dest.currency"],
"writes": ["source.saldo", "dest.saldo", "Transferencia"],
"invariants": {
"conserve": [
{ "entity": "Caja", "field": "saldo", "group_by": "currency" }
]
},
"depends_on": [],
"script": "morphisms/transfer_between_cajas.rhai"
}
]
}
@@ -0,0 +1,35 @@
schema Caja:
id: str
name: str
saldo: int
currency: str
check:
saldo >= 0, "saldo de caja no puede ser negativo"
len(currency) == 3, "currency debe ser ISO 4217 (3 letras)"
schema Movimiento:
id: str
caja_id: str
monto: int
tipo: str
timestamp: str
memo?: str
check:
monto > 0, "monto debe ser positivo (la direccion la fija el tipo)"
tipo in ["in", "out"], "tipo debe ser 'in' u 'out'"
schema Transferencia:
id: str
source_caja_id: str
dest_caja_id: str
monto: int
currency: str
timestamp: str
memo?: str
check:
monto > 0, "monto debe ser positivo"
len(currency) == 3, "currency ISO 4217"
source_caja_id != dest_caja_id, "source y dest no pueden ser la misma caja"