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>
Delta semántico · 413
crates/modules/nakui/core/src/bin/demo.rs
- nueva fn main
- nueva fn print_caja
- nueva fn run_and_report
- nueva fn section
crates/modules/nakui/core/src/bin/inventory_demo.rs
- nueva fn main
- nueva fn print_stock
- nueva fn run_and_report
- nueva fn section
crates/modules/nakui/core/src/bin/nakui.rs
- nueva enum CliError
- nueva def HEX
- nueva fn cmd_compact
- nueva fn cmd_drift
- nueva fn cmd_inspect
- nueva fn cmd_replay
- nueva fn cmd_run
- nueva fn cmd_snapshot
- nueva fn cmd_verify_log
- nueva fn hex_encode
- nueva fn main
- nueva fn parse_flags
- nueva fn print_usage
- nueva fn require
- nueva fn run_persistent
- nueva fn run_persistent
- nueva fn short_hash
crates/modules/nakui/core/src/bin/sales_demo.rs
- nueva fn main
- nueva fn print_caja
- nueva fn print_stock
- nueva fn run_and_report
- nueva fn section
crates/modules/nakui/core/src/delta.rs
- nueva enum FieldOp pub
- nueva struct FieldPath pub
- nueva fn capability_token pub
- nueva fn simulate_on pub
crates/modules/nakui/core/src/drift.rs
- nueva enum DriftDiff pub
- nueva enum DriftError pub
- nueva struct DriftReport pub
- nueva struct SocketClient
- nueva fn check_against_socket pub
- nueva fn compare_states pub
- nueva fn detects_only_in_log
- nueva fn detects_only_on_server
- nueva fn detects_tampered
- nueva fn diffs_emerge_in_canonical_order
- nueva fn empty_inputs_yield_no_diffs
- nueva fn equal_records_yield_no_diffs_even_if_hashes_were_lied_to
- nueva fn exchange
- nueva fn h
- nueva fn hex_nibble
- nueva fn in_sync pub
- nueva fn in_sync_requires_both_hashes_and_no_diffs
- nueva fn key
- nueva fn new
- nueva fn parse_hash
- nueva fn parse_records
- nueva fn require_ok
- nueva mod tests
- nueva fn variant_order
crates/modules/nakui/core/src/event_log.rs
- nueva struct EventLog pub
- nueva enum ExecuteError pub
- nueva enum LogEntry pub
- nueva enum LogError pub
- nueva enum ReconcileError pub
- nueva enum RecoverableExecuteError pub
- nueva enum ReplayError pub
- nueva struct Snapshot pub
- nueva enum SnapshotMismatchError pub
- nueva enum VerifyError pub
- nueva fn append pub
- nueva fn capture pub
- nueva fn compact_through pub
- nueva fn ensure_compatible_with pub
- nueva fn entries pub
- nueva fn execute_and_log pub
- nueva fn execute_and_log_with_recovery pub
- nueva fn from_memory_store pub
- nueva fn load pub
- nueva fn next_seq pub
- nueva fn open pub
- nueva fn path pub
- nueva fn read_entries
- nueva fn reconcile pub
- nueva fn replay pub
- nueva fn replay_into pub
- nueva fn replay_with_snapshot_into pub
- nueva fn seed_and_log pub
- nueva fn seq pub
- nueva fn sync_parent_dir
- nueva fn verify_log pub
- nueva fn write pub
crates/modules/nakui/core/src/executor.rs
- nueva enum ExecError pub
- nueva struct Executor pub
- nueva struct InputBinding
- nueva fn build_schema_bundle
- nueva fn check_conservation
- nueva fn compute pub
- nueva fn compute_morphism_schema_hash
- nueva fn compute_schema_bundle_hash
- nueva fn drop
- nueva fn kcl_check
- nueva fn load_module pub
- nueva fn module_schema_hash pub
- nueva fn normalize_collapses_whitespace_runs
- nueva fn normalize_distinguishes_real_changes
- nueva fn normalize_handles_comment_at_end_without_newline
- nueva fn normalize_handles_unterminated_block_comment
- nueva fn normalize_is_idempotent
- nueva fn normalize_preserves_strings_verbatim_including_double_spaces
- nueva fn normalize_rhai_source pub
- nueva fn normalize_strips_line_and_block_comments
- nueva fn run pub
- nueva fn schema_hash pub
- nueva mod tests
crates/modules/nakui/core/src/graph.rs
- nueva struct DirtyTracker pub
- nueva enum GraphError pub
- nueva struct ManifestGraph pub
- nueva fn affected_by pub
- nueva fn build pub
- nueva fn build_data_flow
- nueva fn build_explicit
- nueva fn canonicalize_token
- nueva fn clear pub
- nueva fn clear_all pub
- nueva fn dirty pub
- nueva fn find_cycle
- nueva fn is_dirty pub
- nueva fn is_empty pub
- nueva fn len pub
- nueva fn mark_dirty_after pub
- nueva fn morphism_reads pub
- nueva fn morphism_writes pub
- nueva fn new pub
- nueva fn readers_of pub
- nueva fn topological_order pub
- nueva fn writers_of pub
crates/modules/nakui/core/src/lib.rs
- nueva mod delta pub
- nueva mod drift pub
- nueva mod event_log pub
- nueva mod executor pub
- nueva mod graph pub
- nueva mod kcl_wrapper pub
- nueva mod manifest pub
- nueva mod rhai_executor pub
- nueva mod run pub
- nueva mod store pub
- nueva mod surreal_store pub
crates/modules/nakui/core/src/manifest.rs
- nueva struct ConserveRule pub
- nueva struct Invariants pub
- nueva struct Manifest pub
- nueva enum ManifestError pub
- nueva struct MorphismInput pub
- nueva struct MorphismSpec pub
- nueva enum ValidationError pub
- nueva fn effective_schemas pub
- nueva fn extract_schema_names
- nueva fn extract_schema_names_handles_basic_forms
- nueva fn load pub
- nueva fn morphism pub
- nueva fn sorted
- nueva mod tests
- nueva fn validate pub
crates/modules/nakui/core/src/rhai_executor.rs
- nueva enum RhaiError pub
- nueva struct RhaiExecutor pub
- nueva fn ast_for
- nueva fn new_sandboxed pub
- nueva fn run pub
crates/modules/nakui/core/src/run.rs
- nueva def HEX
- nueva enum Request
- nueva enum RunError pub
- nueva fn accept_loop
- nueva fn dispatch
- nueva fn error_response
- nueva fn handle_connection
- nueva fn hex_encode
- nueva fn run_server pub
- nueva fn startup_replay
crates/modules/nakui/core/src/store.rs
- nueva def I128_MAX_AS_F64
- nueva def I128_MIN_AS_F64
- nueva struct MemoryStore pub
- nueva trait Store pub
- nueva enum StoreError pub
- nueva def TAG_ARRAY
- nueva def TAG_BOOL
- nueva def TAG_FLOAT
- nueva def TAG_INT
- nueva def TAG_NULL
- nueva def TAG_OBJECT
- nueva def TAG_STRING
- nueva fn apply
- nueva fn apply_dry_run
- nueva fn clear
- nueva fn dry_run_passes_for_valid_set
- nueva fn dry_run_rejects_create_conflict
- nueva fn dry_run_rejects_set_on_non_object
- nueva fn empty_store_has_a_well_defined_hash
- nueva fn fractional_floats_do_not_canonicalize_to_int
- nueva fn hash_int
- nueva fn hash_state
- nueva fn hash_state_changes_when_state_changes
- nueva fn hash_state_is_deterministic_and_independent_of_insertion_order
- nueva fn hash_value pub
- nueva fn hex_decode
- nueva fn integer_and_integer_valued_float_hash_identically
- nueva fn iter
- nueva fn iter_returns_canonical_order_regardless_of_insertion
- nueva fn last_applied_seq
- nueva fn last_applied_seq
- nueva fn last_applied_seq_round_trips_and_resets_on_clear
- nueva fn load
- nueva fn new pub
- nueva fn records pub
- nueva fn same_object_with_different_insertion_order_hashes_same
- nueva fn seed
- nueva fn set_last_applied_seq
- nueva fn set_last_applied_seq
- nueva mod tests
- nueva fn type_tagged_framing_distinguishes_string_from_number
crates/modules/nakui/core/src/surreal_store.rs
- nueva def ITER_ID_ALIAS
- nueva def META_SINGLETON_ID
- nueva def META_TABLE
- nueva def META_TABLE_PREFIX
- nueva struct SurrealStore pub
- nueva enum SurrealStoreError pub
- nueva fn apply
- nueva fn apply_dry_run
- nueva fn clear
- nueva fn exists
- nueva fn iter
- nueva fn json_to_map
- nueva fn last_applied_seq
- nueva fn load
- nueva fn map_err
- nueva fn new_in_memory pub
- nueva fn new_persistent pub
- nueva fn restore_app_id
- nueva fn seed
- nueva fn set_last_applied_seq
- nueva fn strip_app_id
crates/modules/nakui/core/tests/drift.rs
- nueva fn build_log_a
- nueva fn build_log_b
- nueva fn connect_with_retry
- nueva fn drift_check_reports_in_sync_when_log_matches_server
- nueva fn drift_check_surfaces_expected_per_record_diffs
- nueva fn fresh_log_path
- nueva fn fresh_socket_path
- nueva fn send_shutdown
- nueva fn treasury_module
- nueva fn workspace_root
crates/modules/nakui/core/tests/event_log.rs
- nueva struct FailOnApplyStore
- nueva fn apply
- nueva fn apply_dry_run
- nueva fn clear
- nueva fn compact_through_drops_old_entries_keeps_seq
- nueva fn execute_and_log_with_recovery_reports_unrecoverable_when_replay_also_fails
- nueva fn execute_and_log_with_recovery_succeeds_on_clean_path
- nueva fn fresh_log_path
- nueva fn iter
- nueva fn load
- nueva fn post_log_store_failure_leaves_log_canonical
- nueva fn reconcile_rebuilds_drifted_store_from_log
- nueva fn reopen_log_resumes_from_correct_seq
- nueva fn replay_reconstructs_live_store
- nueva fn seed
- nueva fn snapshot_plus_log_tail_replays_to_same_state
- nueva fn snapshot_then_compact_then_replay_equals_pre_compaction
- nueva fn treasury_module
- nueva fn verify_log_passes_for_deterministic_morphisms
- nueva fn workspace_root
crates/modules/nakui/core/tests/graph.rs
- nueva fn affected_by_excludes_self_and_finds_overlap
- nueva fn cross_module_graph_canonicalizes_to_entity_tokens
- nueva fn detects_self_loop
- nueva fn detects_three_node_cycle
- nueva fn detects_two_node_cycle
- nueva fn dirty_tracker_accumulates_across_morphisms
- nueva fn dirty_tracker_clear_works
- nueva fn dirty_tracker_marks_after_treasury_morphism
- nueva fn executor_load_module_rejects_cyclic_manifest
- nueva fn manifest_with
- nueva fn module
- nueva fn morphism
- nueva fn topological_order_respects_explicit_dependencies
- nueva fn treasury_data_flow_indexes_match_manifest
- nueva fn unknown_depends_on_target_errors
- nueva fn workspace_root
crates/modules/nakui/core/tests/inventory.rs
- nueva fn cantidad
- nueva fn inventory_module
- nueva fn overdraw_transfer_blocked_by_kcl_post_check
- nueva fn recibir_increases_stock_and_creates_movimiento
- nueva fn seed_stock
- nueva fn transfer_across_different_skus_is_rejected_by_conservation
- nueva fn transfer_conserves_units_across_same_sku
- nueva fn workspace_root
crates/modules/nakui/core/tests/kernel_guards.rs
- nueva fn bad_created_record_blocks_negative_movimiento
- nueva fn build_executor
- nueva fn caja_saldo
- nueva fn capability_rejects_entity_mismatch_on_tracked_id
- nueva fn capability_violation_blocks_write_to_untracked_caja
- nueva fn conservation_violation_blocks_unbalanced_transfer
- nueva fn delete_primary_skips_post_check_and_removes_record
- nueva fn fixtures_dir
- nueva fn seed_caja
- nueva fn workspace_root
crates/modules/nakui/core/tests/manifest_validation.rs
- nueva fn baseline_manifest
- nueva fn baseline_morphism
- nueva fn caja_input
- nueva fn executor_load_module_runs_validation
- nueva fn production_modules_validate_clean
- nueva fn rejects_conserve_unknown_entity
- nueva fn rejects_depends_on_unknown_morphism
- nueva fn rejects_duplicate_morphism_name
- nueva fn rejects_duplicate_role_within_morphism
- nueva fn rejects_duplicate_schema_across_files
- nueva fn rejects_input_unknown_entity
- nueva fn rejects_missing_schema_file
- nueva fn rejects_missing_script
- nueva fn rejects_writes_unknown_entity
- nueva fn rejects_writes_unknown_role
- nueva fn treasury_dir
- nueva fn workspace_root
crates/modules/nakui/core/tests/run.rs
- nueva struct Conn
- nueva fn connect_with_retry
- nueva fn exchange
- nueva fn fresh_log_path
- nueva fn fresh_socket_path
- nueva fn run_server_full_protocol_round_trip
- nueva fn run_server_reconciles_drifted_store_on_startup
- nueva fn treasury_module
- nueva fn workspace_root
crates/modules/nakui/core/tests/run_persistent.rs
- nueva struct Conn
- nueva fn connect_with_retry
- nueva fn exchange
- nueva fn fresh_log_path
- nueva fn fresh_socket_path
- nueva fn fresh_store_path
- nueva fn run_server_skips_replay_when_persistent_store_is_in_sync
- nueva fn run_server_with_persistent_surreal_serves_protocol_and_writes_to_disk
- nueva fn treasury_module
- nueva fn workspace_root
crates/modules/nakui/core/tests/sales.rs
- nueva fn caja_saldo
- nueva fn overdraw_stock_rejected_by_inventory_post_check
- nueva fn sale_decreases_stock_and_increases_caja
- nueva fn sales_module
- nueva fn seed
- nueva fn stock_cantidad
- nueva fn venta_total_invariant_caught_when_corrupted
- nueva fn workspace_root
crates/modules/nakui/core/tests/schema_versioning.rs
- nueva struct TempModule
- nueva fn comment_only_edits_do_not_invalidate_the_hash
- nueva fn copy_dir_recursive
- nueva fn deposit_5k
- nueva fn drop
- nueva fn execute_and_log_writes_schema_hash_into_entries
- nueva fn executor_exposes_per_morphism_schema_hash
- nueva fn executor_exposes_schema_bundle_hash
- nueva fn fresh_log_path
- nueva fn from
- nueva fn legacy_log_without_schema_hash_still_replays_and_verifies
- nueva fn morphism_script_change_does_not_flag_unrelated_seeds
- nueva fn seed_and_log_writes_bundle_hash_into_seed_entries
- nueva fn seed_caja
- nueva fn treasury_module
- nueva fn verify_log_passes_when_module_is_unchanged
- nueva fn verify_log_rejects_log_after_morphism_script_changes
- nueva fn verify_log_rejects_seed_after_schema_kcl_changes
- nueva fn workspace_root
crates/modules/nakui/core/tests/snapshot_chain.rs
- nueva struct Conn
- nueva fn capture_records_executor_hash_legacy_does_not
- nueva fn connect_with_retry
- nueva fn deposit
- nueva fn ensure_compatible_with_rejects_mismatched_hash
- nueva fn exchange
- nueva fn fresh_log_path
- nueva fn fresh_snap_path
- nueva fn fresh_socket_path
- nueva fn module_schema_hash_is_stable_and_independent_of_load_order
- nueva fn run_server_detects_gap_between_snapshot_and_compacted_log
- nueva fn run_server_refuses_snapshot_with_wrong_schema_hash
- nueva fn seed_caja
- nueva fn send_shutdown
- nueva fn snapshot_then_compact_then_run_server_resumes_correctly
- nueva fn snapshot_write_overwrites_existing_atomically
- nueva fn snapshot_write_recovers_from_stale_tempfile
- nueva fn treasury_module
- nueva fn workspace_root
crates/modules/nakui/core/tests/state_hash.rs
- nueva fn drift_is_detectable_via_hash_diff
- nueva fn fresh_log_path
- nueva fn hash_state_is_stable_across_repeated_calls
- nueva fn live_store_hash_matches_replayed_store_hash
- nueva fn seed_two_cajas
- nueva fn treasury_module
- nueva fn workspace_root
crates/modules/nakui/core/tests/surreal_persist.rs
- nueva fn applied_ops_persist_across_reopens
- nueva fn data_survives_close_and_reopen
- nueva fn fresh_db_path
crates/modules/nakui/core/tests/surreal_store.rs
- nueva fn apply_create_persists_record
- nueva fn apply_delete_removes_record
- nueva fn apply_set_updates_field
- nueva fn caja_data
- nueva fn clear_drops_all_records_across_tables
- nueva fn cross_backend_hash_equals_for_equivalent_data
- nueva fn dry_run_rejects_create_conflict
- nueva fn dry_run_rejects_set_not_found
- nueva fn fresh_log_path
- nueva fn full_wal_flow_against_surreal
- nueva fn iter_and_hash_state_round_trip_against_surreal
- nueva fn reconcile_rebuilds_drifted_surreal_store_from_log
- nueva fn replay_into_memorystore_from_surreal_run_log
- nueva fn seed_then_load_preserves_application_id
- nueva fn treasury_module
- nueva fn verify_log_against_surreal_passes
- nueva fn workspace_root
49 archivos · +11975 −62
- Cargo.lock +2033 −62
- Cargo.toml +5 −0
- crates/modules/nakui/core/Cargo.toml +38 −0
- crates/modules/nakui/core/src/bin/demo.rs +224 −0
- crates/modules/nakui/core/src/bin/inventory_demo.rs +170 −0
- crates/modules/nakui/core/src/bin/nakui.rs +455 −0
- crates/modules/nakui/core/src/bin/sales_demo.rs +176 −0
- crates/modules/nakui/core/src/delta.rs +72 −0
- crates/modules/nakui/core/src/drift.rs +496 −0
- crates/modules/nakui/core/src/event_log.rs +687 −0
- crates/modules/nakui/core/src/executor.rs +667 −0
- crates/modules/nakui/core/src/graph.rs +277 −0
- crates/modules/nakui/core/src/kcl_wrapper.rs +43 −0
- crates/modules/nakui/core/src/lib.rs +11 −0
- crates/modules/nakui/core/src/manifest.rs +306 −0
- crates/modules/nakui/core/src/rhai_executor.rs +103 −0
- crates/modules/nakui/core/src/run.rs +352 −0
- crates/modules/nakui/core/src/store.rs +593 −0
- crates/modules/nakui/core/src/surreal_store.rs +403 −0
- crates/modules/nakui/core/tests/drift.rs +258 −0
- crates/modules/nakui/core/tests/event_log.rs +620 −0
- crates/modules/nakui/core/tests/fixtures/bad_created_record.rhai +19 −0
- crates/modules/nakui/core/tests/fixtures/capability_violation.rhai +9 −0
- crates/modules/nakui/core/tests/fixtures/conservation_violation.rhai +14 −0
- crates/modules/nakui/core/tests/fixtures/delete_primary.rhai +7 −0
- crates/modules/nakui/core/tests/fixtures/entity_mismatch.rhai +10 −0
- crates/modules/nakui/core/tests/graph.rs +355 −0
- crates/modules/nakui/core/tests/inventory.rs +173 −0
- crates/modules/nakui/core/tests/kernel_guards.rs +297 −0
- crates/modules/nakui/core/tests/manifest_validation.rs +282 −0
- crates/modules/nakui/core/tests/run.rs +286 −0
- crates/modules/nakui/core/tests/run_persistent.rs +328 −0
- crates/modules/nakui/core/tests/sales.rs +164 −0
- crates/modules/nakui/core/tests/schema_versioning.rs +444 −0
- crates/modules/nakui/core/tests/snapshot_chain.rs +400 −0
- crates/modules/nakui/core/tests/state_hash.rs +159 −0
- crates/modules/nakui/core/tests/surreal_persist.rs +97 −0
- crates/modules/nakui/core/tests/surreal_store.rs +537 −0
- crates/modules/nakui/modules/inventory/morphisms/recibir_stock.rhai +36 −0
- crates/modules/nakui/modules/inventory/morphisms/transferir_stock.rhai +50 −0
- crates/modules/nakui/modules/inventory/nsmc.json +31 −0
- crates/modules/nakui/modules/inventory/schema.k +34 −0
- crates/modules/nakui/modules/sales/morphisms/vender.rhai +52 −0
- crates/modules/nakui/modules/sales/nsmc.json +21 −0
- crates/modules/nakui/modules/sales/schema.k +16 −0
- crates/modules/nakui/modules/treasury/morphisms/register_cash_move.rhai +46 −0
- crates/modules/nakui/modules/treasury/morphisms/transfer_between_cajas.rhai +53 −0
- crates/modules/nakui/modules/treasury/nsmc.json +31 −0
- crates/modules/nakui/modules/treasury/schema.k +35 −0
diff --git a/Cargo.lock b/Cargo.lock
index 2bc8f33..5aea1c2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,21 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "Inflector"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
+
+[[package]]
+name = "addr"
+version = "0.15.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a93b8a41dbe230ad5087cc721f8d41611de654542180586b315d9f4cf6b72bef"
+dependencies = [
+ "psl-types",
+]
+
[[package]]
name = "adler2"
version = "2.0.1"
@@ -15,7 +30,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
- "generic-array",
+ "generic-array 0.14.7",
]
[[package]]
@@ -44,6 +59,29 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "affinitypool"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2dde2a385b82232b559baeec740c37809051c596f9b56e7da0d0da2c8e8f54f6"
+dependencies = [
+ "async-channel 2.5.0",
+ "num_cpus",
+ "thiserror 1.0.69",
+ "tokio",
+]
+
+[[package]]
+name = "ahash"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
+dependencies = [
+ "getrandom 0.2.17",
+ "once_cell",
+ "version_check",
+]
+
[[package]]
name = "ahash"
version = "0.8.12"
@@ -52,6 +90,7 @@ checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"const-random",
+ "getrandom 0.3.4",
"once_cell",
"version_check",
"zerocopy",
@@ -72,7 +111,7 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685"
dependencies = [
- "as-slice",
+ "as-slice 0.2.1",
]
[[package]]
@@ -84,6 +123,34 @@ dependencies = [
"equator",
]
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "ammonia"
+version = "4.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
+dependencies = [
+ "cssparser",
+ "html5ever",
+ "maplit",
+ "tendril",
+ "url",
+]
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "anstream"
version = "1.0.0"
@@ -134,12 +201,36 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "any_ascii"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90c6333e01ba7235575b6ab53e5af10f1c327927fd97c36462917e289557ea64"
+
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+[[package]]
+name = "approx"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f2a05fd1bd10b2527e20a2cd32d8873d115b8b39fe219ee25f42a8aca6ba278"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "approx"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "ar_archive_writer"
version = "0.5.1"
@@ -196,6 +287,18 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
+[[package]]
+name = "as-slice"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45403b49e3954a4b8428a0ac21a4b7afadccf92bfd96273f1a58cd4812496ae0"
+dependencies = [
+ "generic-array 0.12.4",
+ "generic-array 0.13.3",
+ "generic-array 0.14.7",
+ "stable_deref_trait",
+]
+
[[package]]
name = "as-slice"
version = "0.2.1"
@@ -205,6 +308,15 @@ dependencies = [
"stable_deref_trait",
]
+[[package]]
+name = "ascii-canvas"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6"
+dependencies = [
+ "term",
+]
+
[[package]]
name = "ash"
version = "0.38.0+1.3.281"
@@ -390,6 +502,77 @@ dependencies = [
"once_cell",
]
+[[package]]
+name = "async-graphql"
+version = "7.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1057a9f7ccf2404d94571dec3451ade1cb524790df6f1ada0d19c2a49f6b0f40"
+dependencies = [
+ "async-graphql-derive",
+ "async-graphql-parser",
+ "async-graphql-value",
+ "async-io",
+ "async-trait",
+ "asynk-strim",
+ "base64 0.22.1",
+ "bytes",
+ "fnv",
+ "futures-util",
+ "http",
+ "indexmap 2.14.0",
+ "mime",
+ "multer",
+ "num-traits",
+ "pin-project-lite",
+ "regex",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "static_assertions_next",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "async-graphql-derive"
+version = "7.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e6cbeadc8515e66450fba0985ce722192e28443697799988265d86304d7cc68"
+dependencies = [
+ "Inflector",
+ "async-graphql-parser",
+ "darling",
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "strum 0.27.2",
+ "syn 2.0.117",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "async-graphql-parser"
+version = "7.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e64ef70f77a1c689111e52076da1cd18f91834bcb847de0a9171f83624b07fbf"
+dependencies = [
+ "async-graphql-value",
+ "pest",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "async-graphql-value"
+version = "7.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e3ef112905abea9dea592fc868a6873b10ebd3f983e83308f995d6284e9ba41"
+dependencies = [
+ "bytes",
+ "indexmap 2.14.0",
+ "serde",
+ "serde_json",
+]
+
[[package]]
name = "async-io"
version = "2.6.0"
@@ -521,6 +704,17 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "async_io_stream"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c"
+dependencies = [
+ "futures",
+ "pharos",
+ "rustc_version",
+]
+
[[package]]
name = "async_zip"
version = "0.0.17"
@@ -547,6 +741,16 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "asynk-strim"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52697735bdaac441a29391a9e97102c74c6ef0f9b60a40cf109b1b404e29d2f6"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
[[package]]
name = "atomic"
version = "0.5.3"
@@ -574,7 +778,7 @@ version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9"
dependencies = [
- "base64",
+ "base64 0.22.1",
"http",
"log",
"url",
@@ -645,6 +849,12 @@ dependencies = [
"match-lookup",
]
+[[package]]
+name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
[[package]]
name = "base64"
version = "0.22.1"
@@ -657,6 +867,19 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+[[package]]
+name = "bcrypt"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7"
+dependencies = [
+ "base64 0.22.1",
+ "blowfish",
+ "getrandom 0.2.17",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "bincode"
version = "1.3.3"
@@ -686,15 +909,30 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "bit-set"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
+dependencies = [
+ "bit-vec 0.6.3",
+]
+
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
- "bit-vec",
+ "bit-vec 0.8.0",
]
+[[package]]
+name = "bit-vec"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
+
[[package]]
name = "bit-vec"
version = "0.8.0"
@@ -728,6 +966,18 @@ dependencies = [
"no_std_io2",
]
+[[package]]
+name = "bitvec"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
+dependencies = [
+ "funty",
+ "radium",
+ "tap",
+ "wyz",
+]
+
[[package]]
name = "blade-graphics"
version = "0.7.1"
@@ -821,7 +1071,7 @@ version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
- "generic-array",
+ "generic-array 0.14.7",
]
[[package]]
@@ -830,7 +1080,7 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
- "generic-array",
+ "generic-array 0.14.7",
]
[[package]]
@@ -855,6 +1105,40 @@ dependencies = [
"piper",
]
+[[package]]
+name = "blowfish"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
+dependencies = [
+ "byteorder",
+ "cipher",
+]
+
+[[package]]
+name = "borsh"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a"
+dependencies = [
+ "borsh-derive",
+ "bytes",
+ "cfg_aliases",
+]
+
+[[package]]
+name = "borsh-derive"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59"
+dependencies = [
+ "once_cell",
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "bs58"
version = "0.5.1"
@@ -886,6 +1170,28 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+[[package]]
+name = "bytecheck"
+version = "0.6.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
+dependencies = [
+ "bytecheck_derive",
+ "ptr_meta",
+ "simdutf8",
+]
+
+[[package]]
+name = "bytecheck_derive"
+version = "0.6.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
[[package]]
name = "bytemuck"
version = "1.25.0"
@@ -923,6 +1229,9 @@ name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+dependencies = [
+ "serde",
+]
[[package]]
name = "calloop"
@@ -950,6 +1259,15 @@ dependencies = [
"wayland-client",
]
+[[package]]
+name = "castaway"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
+dependencies = [
+ "rustversion",
+]
+
[[package]]
name = "cbc"
version = "0.1.2"
@@ -966,7 +1284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eadd868a2ce9ca38de7eeafdcec9c7065ef89b42b32f0839278d55f35c54d1ff"
dependencies = [
"heck 0.4.1",
- "indexmap",
+ "indexmap 2.14.0",
"log",
"proc-macro2",
"quote",
@@ -989,6 +1307,63 @@ dependencies = [
"shlex",
]
+[[package]]
+name = "cedar-policy"
+version = "2.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d91e3b10a0f7f2911774d5e49713c4d25753466f9e11d1cd2ec627f8a2dc857"
+dependencies = [
+ "cedar-policy-core",
+ "cedar-policy-validator",
+ "itertools 0.10.5",
+ "lalrpop-util",
+ "ref-cast",
+ "serde",
+ "serde_json",
+ "smol_str",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "cedar-policy-core"
+version = "2.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd2315591c6b7e18f8038f0a0529f254235fd902b6c217aabc04f2459b0d9995"
+dependencies = [
+ "either",
+ "ipnet",
+ "itertools 0.10.5",
+ "lalrpop",
+ "lalrpop-util",
+ "lazy_static",
+ "miette",
+ "regex",
+ "rustc_lexer",
+ "serde",
+ "serde_json",
+ "serde_with",
+ "smol_str",
+ "stacker",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "cedar-policy-validator"
+version = "2.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e756e1b2a5da742ed97e65199ad6d0893e9aa4bd6b34be1de9e70bd1e6adc7df"
+dependencies = [
+ "cedar-policy-core",
+ "itertools 0.10.5",
+ "serde",
+ "serde_json",
+ "serde_with",
+ "smol_str",
+ "stacker",
+ "thiserror 1.0.69",
+ "unicode-security",
+]
+
[[package]]
name = "cexpr"
version = "0.6.0"
@@ -1044,12 +1419,53 @@ dependencies = [
]
[[package]]
-name = "cipher"
-version = "0.4.4"
+name = "chrono"
+version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
- "crypto-common",
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "serde",
+ "wasm-bindgen",
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "ciborium"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
+dependencies = [
+ "ciborium-io",
+ "ciborium-ll",
+ "serde",
+]
+
+[[package]]
+name = "ciborium-io"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
+
+[[package]]
+name = "ciborium-ll"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
+dependencies = [
+ "ciborium-io",
+ "half",
+]
+
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
"inout",
"zeroize",
]
@@ -1182,7 +1598,7 @@ checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81"
dependencies = [
"serde",
"termcolor",
- "unicode-width",
+ "unicode-width 0.2.2",
]
[[package]]
@@ -1524,11 +1940,34 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
- "generic-array",
+ "generic-array 0.14.7",
"rand_core 0.6.4",
"typenum",
]
+[[package]]
+name = "cssparser"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
+dependencies = [
+ "cssparser-macros",
+ "dtoa-short",
+ "itoa",
+ "phf",
+ "smallvec",
+]
+
+[[package]]
+name = "cssparser-macros"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "ctor"
version = "0.4.3"
@@ -1581,6 +2020,53 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "darling"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
+dependencies = [
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "dashmap"
+version = "5.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
+dependencies = [
+ "cfg-if",
+ "hashbrown 0.14.5",
+ "lock_api",
+ "once_cell",
+ "parking_lot_core 0.9.12",
+]
+
[[package]]
name = "data-encoding"
version = "2.11.0"
@@ -1650,6 +2136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
+ "serde_core",
]
[[package]]
@@ -1665,6 +2152,12 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "deunicode"
+version = "1.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
+
[[package]]
name = "digest"
version = "0.10.7"
@@ -1694,6 +2187,16 @@ dependencies = [
"dirs-sys 0.4.1",
]
+[[package]]
+name = "dirs-next"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
+dependencies = [
+ "cfg-if",
+ "dirs-sys-next",
+]
+
[[package]]
name = "dirs-sys"
version = "0.3.7"
@@ -1717,6 +2220,17 @@ dependencies = [
"windows-sys 0.48.0",
]
+[[package]]
+name = "dirs-sys-next"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
+dependencies = [
+ "libc",
+ "redox_users",
+ "winapi",
+]
+
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -1753,6 +2267,22 @@ dependencies = [
"libloading",
]
+[[package]]
+name = "dmp"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb2dfc7a18dffd3ef60a442b72a827126f1557d914620f8fc4d1049916da43c1"
+dependencies = [
+ "trice",
+ "urlencoding",
+]
+
+[[package]]
+name = "double-ended-peekable"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0d05e1c0dbad51b52c38bda7adceef61b9efc2baf04acfe8726a8c4630a6f57"
+
[[package]]
name = "downcast-rs"
version = "1.2.1"
@@ -1765,6 +2295,15 @@ version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
+[[package]]
+name = "dtoa-short"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
+dependencies = [
+ "dtoa",
+]
+
[[package]]
name = "dtor"
version = "0.0.6"
@@ -1804,6 +2343,16 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+[[package]]
+name = "earcutr"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79127ed59a85d7687c409e9978547cffb7dc79675355ed22da6b66fd5f6ead01"
+dependencies = [
+ "itertools 0.11.0",
+ "num-traits",
+]
+
[[package]]
name = "ed25519"
version = "2.2.3"
@@ -1860,6 +2409,15 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
+[[package]]
+name = "ena"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1"
+dependencies = [
+ "log",
+]
+
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -1875,6 +2433,12 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+[[package]]
+name = "endian-type"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d"
+
[[package]]
name = "ente-binfmt-compat"
version = "0.0.1"
@@ -1889,7 +2453,7 @@ name = "ente-brain"
version = "0.0.1"
dependencies = [
"anyhow",
- "base64",
+ "base64 0.22.1",
"bincode",
"ente-card",
"ente-cas",
@@ -2333,6 +2897,19 @@ dependencies = [
"zune-inflate",
]
+[[package]]
+name = "ext-sort"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf5d3b056bcc471d38082b8c453acb6670f7327fd44219b3c411e40834883569"
+dependencies = [
+ "log",
+ "rayon",
+ "rmp-serde",
+ "serde",
+ "tempfile",
+]
+
[[package]]
name = "fallible-iterator"
version = "0.3.0"
@@ -2409,6 +2986,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+[[package]]
+name = "fixedbitset"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
+
[[package]]
name = "flate2"
version = "1.1.9"
@@ -2461,6 +3044,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
[[package]]
name = "font-types"
version = "0.11.3"
@@ -2573,6 +3162,18 @@ dependencies = [
"libc",
]
+[[package]]
+name = "fst"
+version = "0.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ab85b9b05e3978cc9a9cf8fea7f01b494e1a09ed3037e16ba39edc7a29eb61a"
+
+[[package]]
+name = "funty"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
+
[[package]]
name = "futf"
version = "0.1.5"
@@ -2726,6 +3327,15 @@ dependencies = [
"slab",
]
+[[package]]
+name = "fuzzy-matcher"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"
+dependencies = [
+ "thread_local",
+]
+
[[package]]
name = "fxhash"
version = "0.2.1"
@@ -2735,6 +3345,24 @@ dependencies = [
"byteorder",
]
+[[package]]
+name = "generic-array"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd"
+dependencies = [
+ "typenum",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f797e67af32588215eaaab8327027ee8e71b9dd0b2b26996aedf20c030fce309"
+dependencies = [
+ "typenum",
+]
+
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -2745,6 +3373,49 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "geo"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f811f663912a69249fa620dcd2a005db7254529da2d8a0b23942e81f47084501"
+dependencies = [
+ "earcutr",
+ "float_next_after",
+ "geo-types",
+ "geographiclib-rs",
+ "log",
+ "num-traits",
+ "robust",
+ "rstar 0.12.2",
+ "serde",
+ "spade",
+]
+
+[[package]]
+name = "geo-types"
+version = "0.7.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5"
+dependencies = [
+ "approx 0.5.1",
+ "num-traits",
+ "rstar 0.10.0",
+ "rstar 0.11.0",
+ "rstar 0.12.2",
+ "rstar 0.8.4",
+ "rstar 0.9.3",
+ "serde",
+]
+
+[[package]]
+name = "geographiclib-rs"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5a7f08910fd98737a6eda7568e7c5e645093e073328eeef49758cfe8b0489c7"
+dependencies = [
+ "libm",
+]
+
[[package]]
name = "gethostname"
version = "1.1.0"
@@ -2953,7 +3624,7 @@ dependencies = [
"rand 0.9.4",
"raw-window-handle",
"resvg",
- "schemars",
+ "schemars 1.2.1",
"seahash",
"serde",
"serde_json",
@@ -3002,7 +3673,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae39dc6d3d201be97e4bc08d96dbef2bc5b5c3d5734e05786e8cc3043342351c"
dependencies = [
- "indexmap",
+ "indexmap 2.14.0",
"rustc-hash 2.1.2",
]
@@ -3122,7 +3793,7 @@ dependencies = [
"nix 0.29.0",
"regex",
"rust-embed",
- "schemars",
+ "schemars 1.2.1",
"serde",
"serde_json",
"serde_json_lenient",
@@ -3165,7 +3836,7 @@ dependencies = [
"futures-core",
"futures-sink",
"http",
- "indexmap",
+ "indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
@@ -3184,6 +3855,15 @@ dependencies = [
"zerocopy",
]
+[[package]]
+name = "hash32"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4041af86e63ac4298ce40e5cca669066e75b6f1aa3390fe2561ffa5e1d9f4cc"
+dependencies = [
+ "byteorder",
+]
+
[[package]]
name = "hash32"
version = "0.2.1"
@@ -3193,13 +3873,32 @@ dependencies = [
"byteorder",
]
+[[package]]
+name = "hash32"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
+dependencies = [
+ "byteorder",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+dependencies = [
+ "ahash 0.7.8",
+]
+
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
- "ahash",
+ "ahash 0.8.12",
+ "allocator-api2",
]
[[package]]
@@ -3208,7 +3907,20 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
- "foldhash",
+ "allocator-api2",
+ "equivalent",
+ "foldhash 0.1.5",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash 0.2.0",
]
[[package]]
@@ -3235,6 +3947,18 @@ dependencies = [
"hashbrown 0.15.5",
]
+[[package]]
+name = "heapless"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "634bd4d29cbf24424d0a4bfcbf80c6960129dc24424752a7d1d1390607023422"
+dependencies = [
+ "as-slice 0.1.5",
+ "generic-array 0.14.7",
+ "hash32 0.1.1",
+ "stable_deref_trait",
+]
+
[[package]]
name = "heapless"
version = "0.7.17"
@@ -3242,13 +3966,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
dependencies = [
"atomic-polyfill",
- "hash32",
+ "hash32 0.2.1",
"rustc_version",
"serde",
"spin",
"stable_deref_trait",
]
+[[package]]
+name = "heapless"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
+dependencies = [
+ "hash32 0.3.1",
+ "stable_deref_trait",
+]
+
[[package]]
name = "heck"
version = "0.4.1"
@@ -3365,7 +4099,18 @@ dependencies = [
]
[[package]]
-name = "http"
+name = "html5ever"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
+dependencies = [
+ "log",
+ "markup5ever",
+ "match_token",
+]
+
+[[package]]
+name = "http"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
@@ -3403,6 +4148,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+[[package]]
+name = "humantime"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
+
[[package]]
name = "hyper"
version = "1.9.0"
@@ -3460,6 +4211,30 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core 0.62.2",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
[[package]]
name = "icu_collections"
version = "2.2.0"
@@ -3548,6 +4323,12 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
[[package]]
name = "idna"
version = "1.1.0"
@@ -3669,6 +4450,17 @@ version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2"
+[[package]]
+name = "indexmap"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+dependencies = [
+ "autocfg",
+ "hashbrown 0.12.3",
+ "serde",
+]
+
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -3708,7 +4500,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
- "generic-array",
+ "generic-array 0.14.7",
]
[[package]]
@@ -3796,6 +4588,24 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+[[package]]
+name = "itertools"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itertools"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57"
+dependencies = [
+ "either",
+]
+
[[package]]
name = "itertools"
version = "0.13.0"
@@ -3842,6 +4652,21 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "jsonwebtoken"
+version = "9.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
+dependencies = [
+ "base64 0.22.1",
+ "js-sys",
+ "pem",
+ "ring",
+ "serde",
+ "serde_json",
+ "simple_asn1",
+]
+
[[package]]
name = "khronos-egl"
version = "6.0.0"
@@ -3892,6 +4717,37 @@ dependencies = [
"log",
]
+[[package]]
+name = "lalrpop"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55cb077ad656299f160924eb2912aa147d7339ea7d69e1b5517326fdcec3c1ca"
+dependencies = [
+ "ascii-canvas",
+ "bit-set 0.5.3",
+ "ena",
+ "itertools 0.11.0",
+ "lalrpop-util",
+ "petgraph",
+ "pico-args",
+ "regex",
+ "regex-syntax",
+ "string_cache",
+ "term",
+ "tiny-keccak",
+ "unicode-xid",
+ "walkdir",
+]
+
+[[package]]
+name = "lalrpop-util"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553"
+dependencies = [
+ "regex-automata",
+]
+
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -3928,6 +4784,15 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
+[[package]]
+name = "lexicmp"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7378d131ddf24063b32cbd7e91668d183140c4b3906270635a4d633d1068ea5d"
+dependencies = [
+ "any_ascii",
+]
+
[[package]]
name = "libc"
version = "0.2.186"
@@ -4337,6 +5202,18 @@ dependencies = [
"vcpkg",
]
+[[package]]
+name = "linfa-linalg"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56e7562b41c8876d3367897067013bb2884cc78e6893f092ecd26b305176ac82"
+dependencies = [
+ "ndarray",
+ "num-traits",
+ "rand 0.8.6",
+ "thiserror 1.0.69",
+]
+
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
@@ -4383,6 +5260,15 @@ dependencies = [
"imgref",
]
+[[package]]
+name = "lru"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
+dependencies = [
+ "hashbrown 0.15.5",
+]
+
[[package]]
name = "lru-slab"
version = "0.1.2"
@@ -4456,6 +5342,23 @@ dependencies = [
"libc",
]
+[[package]]
+name = "maplit"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
+
+[[package]]
+name = "markup5ever"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
+dependencies = [
+ "log",
+ "tendril",
+ "web_atoms",
+]
+
[[package]]
name = "match-lookup"
version = "0.1.2"
@@ -4467,6 +5370,17 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "match_token"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "matchers"
version = "0.2.0"
@@ -4476,6 +5390,16 @@ dependencies = [
"regex-automata",
]
+[[package]]
+name = "matrixmultiply"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
+dependencies = [
+ "autocfg",
+ "rawpointer",
+]
+
[[package]]
name = "maybe-rayon"
version = "0.1.1"
@@ -4535,6 +5459,29 @@ dependencies = [
"paste",
]
+[[package]]
+name = "miette"
+version = "5.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e"
+dependencies = [
+ "miette-derive",
+ "once_cell",
+ "thiserror 1.0.69",
+ "unicode-width 0.1.14",
+]
+
+[[package]]
+name = "miette-derive"
+version = "5.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "mime"
version = "0.3.17"
@@ -4695,6 +5642,23 @@ dependencies = [
"pxfm",
]
+[[package]]
+name = "multer"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
+dependencies = [
+ "bytes",
+ "encoding_rs",
+ "futures-util",
+ "http",
+ "httparse",
+ "memchr",
+ "mime",
+ "spin",
+ "version_check",
+]
+
[[package]]
name = "multi-stash"
version = "0.2.0"
@@ -4762,14 +5726,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632"
dependencies = [
"arrayvec",
- "bit-set",
+ "bit-set 0.8.0",
"bitflags 2.11.1",
"cfg_aliases",
"codespan-reporting",
"half",
"hashbrown 0.15.5",
"hexf-parse",
- "indexmap",
+ "indexmap 2.14.0",
"log",
"num-traits",
"once_cell",
@@ -4780,6 +5744,21 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "nakui-core"
+version = "0.1.0"
+dependencies = [
+ "petgraph",
+ "rhai",
+ "serde",
+ "serde_json",
+ "sha2",
+ "surrealdb",
+ "thiserror 1.0.69",
+ "tokio",
+ "uuid",
+]
+
[[package]]
name = "nanorand"
version = "0.7.0"
@@ -4789,6 +5768,35 @@ dependencies = [
"getrandom 0.2.17",
]
+[[package]]
+name = "ndarray"
+version = "0.15.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "adb12d4e967ec485a5f71c6311fe28158e9d6f4bc4a447b474184d0f91a8fa32"
+dependencies = [
+ "approx 0.4.0",
+ "matrixmultiply",
+ "num-complex",
+ "num-integer",
+ "num-traits",
+ "rawpointer",
+]
+
+[[package]]
+name = "ndarray-stats"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af5a8477ac96877b5bd1fd67e0c28736c12943aba24eda92b127e036b0c8f400"
+dependencies = [
+ "indexmap 1.9.3",
+ "itertools 0.10.5",
+ "ndarray",
+ "noisy_float",
+ "num-integer",
+ "num-traits",
+ "rand 0.8.6",
+]
+
[[package]]
name = "netlink-packet-core"
version = "0.8.1"
@@ -4843,6 +5851,15 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+[[package]]
+name = "nibble_vec"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43"
+dependencies = [
+ "smallvec",
+]
+
[[package]]
name = "nix"
version = "0.29.0"
@@ -4895,6 +5912,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
+[[package]]
+name = "noisy_float"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c16843be85dd410c6a12251c4eca0dd1d3ee8c5725f746c4d5e0fdcec0a864b2"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "nom"
version = "7.1.3"
@@ -5211,6 +6237,30 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "object_store"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fbfbfff40aeccab00ec8a910b57ca8ecf4319b335c542f2edcd19dd25a1e2a00"
+dependencies = [
+ "async-trait",
+ "bytes",
+ "chrono",
+ "futures",
+ "http",
+ "humantime",
+ "itertools 0.14.0",
+ "parking_lot 0.12.5",
+ "percent-encoding",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "url",
+ "walkdir",
+ "wasm-bindgen-futures",
+ "web-time",
+]
+
[[package]]
name = "oid-registry"
version = "0.8.1"
@@ -5387,6 +6437,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
+[[package]]
+name = "path-clean"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17359afc20d7ab31fdb42bb844c8b3bb1dabd7dcf7e68428492da7f16966fcef"
+
[[package]]
name = "pathdiff"
version = "0.2.3"
@@ -5420,15 +6476,23 @@ checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"hmac",
+ "password-hash",
+ "sha2",
]
+[[package]]
+name = "pdqselect"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ec91767ecc0a0bbe558ce8c9da33c068066c57ecc8bb8477ef8c1ad3ef77c27"
+
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
- "base64",
+ "base64 0.22.1",
"serde_core",
]
@@ -5439,39 +6503,123 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
-name = "pico-args"
-version = "0.5.0"
+name = "pest"
+version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
+checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662"
+dependencies = [
+ "memchr",
+ "ucd-trie",
+]
[[package]]
-name = "pin-project"
-version = "1.1.12"
+name = "petgraph"
+version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9"
+checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
dependencies = [
- "pin-project-internal",
+ "fixedbitset",
+ "indexmap 2.14.0",
]
[[package]]
-name = "pin-project-internal"
-version = "1.1.12"
+name = "pharos"
+version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389"
+checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414"
dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
+ "futures",
+ "rustc_version",
]
[[package]]
-name = "pin-project-lite"
-version = "0.2.17"
+name = "phf"
+version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
+dependencies = [
+ "phf_macros",
+ "phf_shared",
+]
[[package]]
-name = "pin-utils"
+name = "phf_codegen"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
+dependencies = [
+ "phf_shared",
+ "rand 0.8.6",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "unicase",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+dependencies = [
+ "siphasher",
+ "unicase",
+]
+
+[[package]]
+name = "pico-args"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
+
+[[package]]
+name = "pin-project"
+version = "1.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
@@ -5610,7 +6758,7 @@ dependencies = [
"cobs",
"embedded-io 0.4.0",
"embedded-io 0.6.1",
- "heapless",
+ "heapless 0.7.17",
"serde",
]
@@ -5638,6 +6786,12 @@ dependencies = [
"zerocopy",
]
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -5730,6 +6884,12 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "psl-types"
+version = "2.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
+
[[package]]
name = "psm"
version = "0.1.31"
@@ -5740,6 +6900,26 @@ dependencies = [
"cc",
]
+[[package]]
+name = "ptr_meta"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
+dependencies = [
+ "ptr_meta_derive",
+]
+
+[[package]]
+name = "ptr_meta_derive"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
[[package]]
name = "pxfm"
version = "0.1.29"
@@ -5801,6 +6981,30 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "quick_cache"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb55a1aa7668676bb93926cd4e9cdfe60f03bb866553bcca9112554911b6d3dc"
+dependencies = [
+ "ahash 0.8.12",
+ "equivalent",
+ "hashbrown 0.14.5",
+ "parking_lot 0.12.5",
+]
+
+[[package]]
+name = "quick_cache"
+version = "0.6.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a70b1b8b47e31d0498ecbc3c5470bb931399a8bfed1fd79d1717a61ce7f96e3"
+dependencies = [
+ "ahash 0.8.12",
+ "equivalent",
+ "hashbrown 0.16.1",
+ "parking_lot 0.12.5",
+]
+
[[package]]
name = "quinn"
version = "0.11.9"
@@ -5878,6 +7082,23 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+[[package]]
+name = "radium"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
+
+[[package]]
+name = "radix_trie"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd"
+dependencies = [
+ "endian-type",
+ "nibble_vec",
+ "serde",
+]
+
[[package]]
name = "rand"
version = "0.8.6"
@@ -6011,6 +7232,12 @@ dependencies = [
"raw-window-handle",
]
+[[package]]
+name = "rawpointer"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
+
[[package]]
name = "rayon"
version = "1.12.0"
@@ -6054,6 +7281,12 @@ dependencies = [
"font-types",
]
+[[package]]
+name = "reblessive"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbc4a4ea2a66a41a1152c4b3d86e8954dc087bdf33af35446e6e176db4e73c8c"
+
[[package]]
name = "redox_syscall"
version = "0.2.16"
@@ -6141,6 +7374,15 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+[[package]]
+name = "rend"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
+dependencies = [
+ "bytecheck",
+]
+
[[package]]
name = "resolv-conf"
version = "0.7.6"
@@ -6161,6 +7403,52 @@ dependencies = [
"usvg",
]
+[[package]]
+name = "revision"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22f53179a035f881adad8c4d58a2c599c6b4a8325b989c68d178d7a34d1b1e4c"
+dependencies = [
+ "revision-derive 0.10.0",
+]
+
+[[package]]
+name = "revision"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54b8ee532f15b2f0811eb1a50adf10d036e14a6cdae8d99893e7f3b921cb227d"
+dependencies = [
+ "chrono",
+ "geo",
+ "regex",
+ "revision-derive 0.11.0",
+ "roaring",
+ "rust_decimal",
+ "uuid",
+]
+
+[[package]]
+name = "revision-derive"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f0ec466e5d8dca9965eb6871879677bef5590cf7525ad96cae14376efb75073"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "revision-derive"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3415e1bc838c36f9a0a2ac60c0fa0851c72297685e66592c44870d82834dfa2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "rgb"
version = "0.8.53"
@@ -6170,6 +7458,35 @@ dependencies = [
"bytemuck",
]
+[[package]]
+name = "rhai"
+version = "1.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f9ef5dabe4c0b43d8f1187dc6beb67b53fe607fff7e30c5eb7f71b814b8c2c1"
+dependencies = [
+ "ahash 0.8.12",
+ "bitflags 2.11.1",
+ "num-traits",
+ "once_cell",
+ "rhai_codegen",
+ "serde",
+ "smallvec",
+ "smartstring",
+ "thin-vec",
+ "web-time",
+]
+
+[[package]]
+name = "rhai_codegen"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4322a2a4e8cf30771dd9f27f7f37ca9ac8fe812dddd811096a98483080dabe6"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "ring"
version = "0.17.14"
@@ -6184,6 +7501,80 @@ dependencies = [
"windows-sys 0.52.0",
]
+[[package]]
+name = "rkyv"
+version = "0.7.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1"
+dependencies = [
+ "bitvec",
+ "bytecheck",
+ "bytes",
+ "hashbrown 0.12.3",
+ "ptr_meta",
+ "rend",
+ "rkyv_derive",
+ "seahash",
+ "tinyvec",
+ "uuid",
+]
+
+[[package]]
+name = "rkyv_derive"
+version = "0.7.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "rmp"
+version = "0.8.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "rmp-serde"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
+dependencies = [
+ "rmp",
+ "serde",
+]
+
+[[package]]
+name = "rmpv"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417"
+dependencies = [
+ "rmp",
+]
+
+[[package]]
+name = "roaring"
+version = "0.10.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19e8d2cfa184d94d0726d650a9f4a1be7f9b76ac9fdb954219878dc00c1c1e7b"
+dependencies = [
+ "bytemuck",
+ "byteorder",
+ "serde",
+]
+
+[[package]]
+name = "robust"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839"
+
[[package]]
name = "roxmltree"
version = "0.20.0"
@@ -6201,6 +7592,67 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "rstar"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a45c0e8804d37e4d97e55c6f258bc9ad9c5ee7b07437009dd152d764949a27c"
+dependencies = [
+ "heapless 0.6.1",
+ "num-traits",
+ "pdqselect",
+ "serde",
+ "smallvec",
+]
+
+[[package]]
+name = "rstar"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b40f1bfe5acdab44bc63e6699c28b74f75ec43afb59f3eda01e145aff86a25fa"
+dependencies = [
+ "heapless 0.7.17",
+ "num-traits",
+ "serde",
+ "smallvec",
+]
+
+[[package]]
+name = "rstar"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f39465655a1e3d8ae79c6d9e007f4953bfc5d55297602df9dc38f9ae9f1359a"
+dependencies = [
+ "heapless 0.7.17",
+ "num-traits",
+ "serde",
+ "smallvec",
+]
+
+[[package]]
+name = "rstar"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73111312eb7a2287d229f06c00ff35b51ddee180f017ab6dec1f69d62ac098d6"
+dependencies = [
+ "heapless 0.7.17",
+ "num-traits",
+ "serde",
+ "smallvec",
+]
+
+[[package]]
+name = "rstar"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb"
+dependencies = [
+ "heapless 0.8.0",
+ "num-traits",
+ "serde",
+ "smallvec",
+]
+
[[package]]
name = "rtnetlink"
version = "0.20.0"
@@ -6278,6 +7730,33 @@ dependencies = [
"walkdir",
]
+[[package]]
+name = "rust-stemmers"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54"
+dependencies = [
+ "serde",
+ "serde_derive",
+]
+
+[[package]]
+name = "rust_decimal"
+version = "1.42.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995"
+dependencies = [
+ "arrayvec",
+ "borsh",
+ "bytes",
+ "num-traits",
+ "rand 0.8.6",
+ "rkyv",
+ "serde",
+ "serde_json",
+ "wasm-bindgen",
+]
+
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -6290,6 +7769,15 @@ version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
+[[package]]
+name = "rustc_lexer"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c86aae0c77166108c01305ee1a36a1e77289d7dc6ca0a3cd91ff4992de2d16a5"
+dependencies = [
+ "unicode-xid",
+]
+
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -6448,6 +7936,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+[[package]]
+name = "salsa20"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
+dependencies = [
+ "cipher",
+]
+
[[package]]
name = "same-file"
version = "1.0.6"
@@ -6466,6 +7963,18 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "schemars"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
[[package]]
name = "schemars"
version = "1.2.1"
@@ -6473,7 +7982,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
dependencies = [
"dyn-clone",
- "indexmap",
+ "indexmap 2.14.0",
"ref-cast",
"schemars_derive",
"serde",
@@ -6527,6 +8036,18 @@ dependencies = [
"once_cell",
]
+[[package]]
+name = "scrypt"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
+dependencies = [
+ "password-hash",
+ "pbkdf2",
+ "salsa20",
+ "sha2",
+]
+
[[package]]
name = "seahash"
version = "4.1.0"
@@ -6567,6 +8088,16 @@ name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "send_wrapper"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
[[package]]
name = "serde"
@@ -6587,6 +8118,15 @@ dependencies = [
"serde",
]
+[[package]]
+name = "serde-content"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3753ca04f350fa92d00b6146a3555e63c55388c9ef2e11e09bce2ff1c0b509c6"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "serde_core"
version = "1.0.228"
@@ -6633,7 +8173,7 @@ version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
- "indexmap",
+ "indexmap 2.14.0",
"itoa",
"memchr",
"serde",
@@ -6647,7 +8187,7 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e033097bf0d2b59a62b42c18ebbb797503839b26afdda2c4e1415cb6c813540"
dependencies = [
- "indexmap",
+ "indexmap 2.14.0",
"itoa",
"memchr",
"ryu",
@@ -6695,6 +8235,37 @@ dependencies = [
"serde",
]
+[[package]]
+name = "serde_with"
+version = "3.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19"
+dependencies = [
+ "base64 0.22.1",
+ "chrono",
+ "hex",
+ "indexmap 1.9.3",
+ "indexmap 2.14.0",
+ "schemars 0.9.0",
+ "schemars 1.2.1",
+ "serde_core",
+ "serde_json",
+ "serde_with_macros",
+ "time",
+]
+
+[[package]]
+name = "serde_with_macros"
+version = "3.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "sha1"
version = "0.10.6"
@@ -6772,6 +8343,24 @@ dependencies = [
"quote",
]
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
+[[package]]
+name = "simple_asn1"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
+dependencies = [
+ "num-bigint",
+ "num-traits",
+ "thiserror 2.0.18",
+ "time",
+]
+
[[package]]
name = "simplecss"
version = "0.2.2"
@@ -6833,6 +8422,21 @@ name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "smartstring"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
+dependencies = [
+ "autocfg",
+ "serde",
+ "static_assertions",
+ "version_check",
+]
[[package]]
name = "smol"
@@ -6856,6 +8460,15 @@ name = "smol_str"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "snap"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b"
[[package]]
name = "snow"
@@ -6894,6 +8507,18 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "spade"
+version = "2.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa"
+dependencies = [
+ "hashbrown 0.16.1",
+ "num-traits",
+ "robust",
+ "smallvec",
+]
+
[[package]]
name = "spin"
version = "0.9.8"
@@ -6968,6 +8593,24 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+[[package]]
+name = "static_assertions_next"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7beae5182595e9a8b683fa98c4317f956c9a2dec3b9716990d20023cc60c766"
+
+[[package]]
+name = "storekey"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43c42833834a5d23b344f71d87114e0cc9994766a5c42938f4b50e7b2aef85b2"
+dependencies = [
+ "byteorder",
+ "memchr",
+ "serde",
+ "thiserror 1.0.69",
+]
+
[[package]]
name = "streaming-iterator"
version = "0.1.9"
@@ -6993,6 +8636,31 @@ dependencies = [
"serde",
]
+[[package]]
+name = "string_cache"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot 0.12.5",
+ "phf_shared",
+ "precomputed-hash",
+ "serde",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+]
+
[[package]]
name = "strsim"
version = "0.11.1"
@@ -7048,6 +8716,155 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+[[package]]
+name = "surrealdb"
+version = "2.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3429154a8b5a98ca39100ba45ef49ae046fb1d0869dff78d78a2670b1b278982"
+dependencies = [
+ "arrayvec",
+ "async-channel 2.5.0",
+ "bincode",
+ "chrono",
+ "dmp",
+ "futures",
+ "geo",
+ "getrandom 0.3.4",
+ "indexmap 2.14.0",
+ "path-clean",
+ "pharos",
+ "reblessive",
+ "revision 0.11.0",
+ "ring",
+ "rust_decimal",
+ "rustls-pki-types",
+ "semver",
+ "serde",
+ "serde-content",
+ "serde_json",
+ "surrealdb-core",
+ "thiserror 1.0.69",
+ "tokio",
+ "tokio-util",
+ "tracing",
+ "url",
+ "uuid",
+ "wasm-bindgen-futures",
+ "wasmtimer",
+ "ws_stream_wasm",
+]
+
+[[package]]
+name = "surrealdb-core"
+version = "2.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba423d9e7e665e4c735a1d4669c3a067135e4a574edf88af215f7f2b815e70ed"
+dependencies = [
+ "addr",
+ "affinitypool",
+ "ahash 0.8.12",
+ "ammonia",
+ "any_ascii",
+ "argon2",
+ "async-channel 2.5.0",
+ "async-executor",
+ "async-graphql",
+ "base64 0.21.7",
+ "bcrypt",
+ "bincode",
+ "blake3",
+ "bytes",
+ "castaway",
+ "cedar-policy",
+ "chrono",
+ "ciborium",
+ "dashmap",
+ "deunicode",
+ "dmp",
+ "ext-sort",
+ "fst",
+ "futures",
+ "fuzzy-matcher",
+ "geo",
+ "geo-types",
+ "getrandom 0.3.4",
+ "hashbrown 0.14.5",
+ "hex",
+ "http",
+ "ipnet",
+ "jsonwebtoken",
+ "lexicmp",
+ "linfa-linalg",
+ "md-5",
+ "ndarray",
+ "ndarray-stats",
+ "num-traits",
+ "num_cpus",
+ "object_store",
+ "parking_lot 0.12.5",
+ "pbkdf2",
+ "pharos",
+ "phf",
+ "pin-project-lite",
+ "quick_cache 0.5.2",
+ "radix_trie",
+ "rand 0.8.6",
+ "rayon",
+ "reblessive",
+ "regex",
+ "revision 0.11.0",
+ "ring",
+ "rmpv",
+ "roaring",
+ "rust-stemmers",
+ "rust_decimal",
+ "scrypt",
+ "semver",
+ "serde",
+ "serde-content",
+ "serde_json",
+ "sha1",
+ "sha2",
+ "snap",
+ "storekey",
+ "strsim",
+ "subtle",
+ "surrealkv",
+ "sysinfo 0.33.1",
+ "tempfile",
+ "thiserror 1.0.69",
+ "tokio",
+ "tracing",
+ "trice",
+ "ulid",
+ "unicase",
+ "url",
+ "uuid",
+ "vart 0.8.1",
+ "wasm-bindgen-futures",
+ "wasmtimer",
+ "ws_stream_wasm",
+]
+
+[[package]]
+name = "surrealkv"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08a5041979bdff8599a1d5f6cb7365acb9a79664e2a84e5c4fddac2b3969f7d1"
+dependencies = [
+ "ahash 0.8.12",
+ "bytes",
+ "chrono",
+ "crc32fast",
+ "double-ended-peekable",
+ "getrandom 0.2.17",
+ "lru",
+ "parking_lot 0.12.5",
+ "quick_cache 0.6.21",
+ "revision 0.10.0",
+ "vart 0.9.3",
+]
+
[[package]]
name = "sval"
version = "2.19.0"
@@ -7218,6 +9035,20 @@ dependencies = [
"windows 0.57.0",
]
+[[package]]
+name = "sysinfo"
+version = "0.33.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+ "memchr",
+ "ntapi",
+ "rayon",
+ "windows 0.57.0",
+]
+
[[package]]
name = "system-configuration"
version = "0.6.1"
@@ -7286,6 +9117,12 @@ dependencies = [
"objc",
]
+[[package]]
+name = "tap"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
+
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -7310,6 +9147,17 @@ dependencies = [
"utf-8",
]
+[[package]]
+name = "term"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f"
+dependencies = [
+ "dirs-next",
+ "rustversion",
+ "winapi",
+]
+
[[package]]
name = "termcolor"
version = "1.4.1"
@@ -7319,6 +9167,15 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "thin-vec"
+version = "0.2.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -7556,7 +9413,7 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [
- "indexmap",
+ "indexmap 2.14.0",
"serde_core",
"serde_spanned 1.1.1",
"toml_datetime 1.1.1+spec-1.1.0",
@@ -7589,7 +9446,7 @@ version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
- "indexmap",
+ "indexmap 2.14.0",
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
@@ -7603,7 +9460,7 @@ version = "0.25.11+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
dependencies = [
- "indexmap",
+ "indexmap 2.14.0",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"winnow 1.0.2",
@@ -7747,6 +9604,17 @@ dependencies = [
"tree-sitter-language",
]
+[[package]]
+name = "trice"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3aaab10ae9fac0b10f392752bf56f0fd20845f39037fec931e8537b105b515a"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+ "web-sys",
+]
+
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -7786,6 +9654,12 @@ version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
+[[package]]
+name = "ucd-trie"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
+
[[package]]
name = "uds_windows"
version = "1.2.1"
@@ -7868,6 +9742,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
+[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
[[package]]
name = "unicode-properties"
version = "0.1.4"
@@ -7880,6 +9763,16 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
+[[package]]
+name = "unicode-security"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e4ddba1535dd35ed8b61c52166b7155d7f4e4b8847cec6f48e71dc66d8b5e50"
+dependencies = [
+ "unicode-normalization",
+ "unicode-script",
+]
+
[[package]]
name = "unicode-segmentation"
version = "1.13.2"
@@ -7892,6 +9785,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
[[package]]
name = "unicode-width"
version = "0.2.2"
@@ -7945,13 +9844,19 @@ dependencies = [
"serde_derive",
]
+[[package]]
+name = "urlencoding"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
+
[[package]]
name = "usvg"
version = "0.45.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef"
dependencies = [
- "base64",
+ "base64 0.22.1",
"data-url",
"flate2",
"fontdb 0.23.0",
@@ -8056,6 +9961,18 @@ dependencies = [
"sval_serde",
]
+[[package]]
+name = "vart"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87782b74f898179396e93c0efabb38de0d58d50bbd47eae00c71b3a1144dbbae"
+
+[[package]]
+name = "vart"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1982d899e57d646498709735f16e9224cf1e8680676ad687f930cf8b5b555ae"
+
[[package]]
name = "vcpkg"
version = "0.2.15"
@@ -8146,6 +10063,7 @@ dependencies = [
"cfg-if",
"once_cell",
"rustversion",
+ "serde",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
@@ -8219,7 +10137,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
- "indexmap",
+ "indexmap 2.14.0",
"wasm-encoder 0.244.0",
"wasmparser 0.244.0",
]
@@ -8288,7 +10206,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185"
dependencies = [
"bitflags 2.11.1",
- "indexmap",
+ "indexmap 2.14.0",
]
[[package]]
@@ -8299,7 +10217,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.11.1",
"hashbrown 0.15.5",
- "indexmap",
+ "indexmap 2.14.0",
"semver",
]
@@ -8310,10 +10228,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa4439c5eee9df71ee0c6efb37f63b1fcb1fec38f85f5142c54e7ed05d33091a"
dependencies = [
"bitflags 2.11.1",
- "indexmap",
+ "indexmap 2.14.0",
"semver",
]
+[[package]]
+name = "wasmtimer"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7ed9d8b15c7fb594d72bfb4b5a276f3d2029333cd93a932f376f5937f6f80ee"
+dependencies = [
+ "futures",
+ "js-sys",
+ "parking_lot 0.12.5",
+ "pin-utils",
+ "wasm-bindgen",
+]
+
[[package]]
name = "wast"
version = "248.0.0"
@@ -8323,7 +10254,7 @@ dependencies = [
"bumpalo",
"leb128fmt",
"memchr",
- "unicode-width",
+ "unicode-width 0.2.2",
"wasm-encoder 0.248.0",
]
@@ -8453,6 +10384,18 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "web_atoms"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
+dependencies = [
+ "phf",
+ "phf_codegen",
+ "string_cache",
+ "string_cache_codegen",
+]
+
[[package]]
name = "weezl"
version = "0.1.12"
@@ -9123,7 +11066,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck 0.5.0",
- "indexmap",
+ "indexmap 2.14.0",
"prettyplease",
"syn 2.0.117",
"wasm-metadata",
@@ -9154,7 +11097,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.11.1",
- "indexmap",
+ "indexmap 2.14.0",
"log",
"serde",
"serde_derive",
@@ -9173,7 +11116,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
- "indexmap",
+ "indexmap 2.14.0",
"log",
"semver",
"serde",
@@ -9189,6 +11132,34 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+[[package]]
+name = "ws_stream_wasm"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc"
+dependencies = [
+ "async_io_stream",
+ "futures",
+ "js-sys",
+ "log",
+ "pharos",
+ "rustc_version",
+ "send_wrapper",
+ "thiserror 2.0.18",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "wyz"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
+dependencies = [
+ "tap",
+]
+
[[package]]
name = "x11"
version = "2.21.0"
@@ -9768,7 +11739,7 @@ version = "0.12.15-zed"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac2d05756ff48539950c3282ad7acf3817ad3f08797c205ad1c34a2ce03b9970"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
@@ -9826,7 +11797,7 @@ dependencies = [
"rand 0.8.6",
"screencapturekit",
"screencapturekit-sys",
- "sysinfo",
+ "sysinfo 0.31.4",
"tao-core-video-sys",
"windows 0.61.3",
"windows-capture",
@@ -9840,7 +11811,7 @@ version = "0.4.0-zed"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0b46ed118eba34d9ba53d94ddc0b665e0e06a2cf874cfa2dd5dec278148642"
dependencies = [
- "ahash",
+ "ahash 0.8.12",
"hashbrown 0.14.5",
"log",
"x11rb",
diff --git a/Cargo.toml b/Cargo.toml
index a8ca317..196b0d2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -53,6 +53,11 @@ members = [
"crates/modules/ui_engine/widgets/tiled",
"crates/modules/ui_engine/widgets/text_input",
+ # ============================================================
+ # modules/nakui/ — ERP matemático (nakui absorbido)
+ # ============================================================
+ "crates/modules/nakui/core",
+
# ============================================================
# apps/ — apps que consumen el protocolo (yahweh modules+shell)
# ============================================================
diff --git a/crates/modules/nakui/core/Cargo.toml b/crates/modules/nakui/core/Cargo.toml
new file mode 100644
index 0000000..3d4b869
--- /dev/null
+++ b/crates/modules/nakui/core/Cargo.toml
@@ -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"
diff --git a/crates/modules/nakui/core/src/bin/demo.rs b/crates/modules/nakui/core/src/bin/demo.rs
new file mode 100644
index 0000000..93c9254
--- /dev/null
+++ b/crates/modules/nakui/core/src/bin/demo.rs
@@ -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);
+}
diff --git a/crates/modules/nakui/core/src/bin/inventory_demo.rs b/crates/modules/nakui/core/src/bin/inventory_demo.rs
new file mode 100644
index 0000000..606a556
--- /dev/null
+++ b/crates/modules/nakui/core/src/bin/inventory_demo.rs
@@ -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);
+}
diff --git a/crates/modules/nakui/core/src/bin/nakui.rs b/crates/modules/nakui/core/src/bin/nakui.rs
new file mode 100644
index 0000000..6ee5714
--- /dev/null
+++ b/crates/modules/nakui/core/src/bin/nakui.rs
@@ -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))),
+ }
+}
diff --git a/crates/modules/nakui/core/src/bin/sales_demo.rs b/crates/modules/nakui/core/src/bin/sales_demo.rs
new file mode 100644
index 0000000..d7a3946
--- /dev/null
+++ b/crates/modules/nakui/core/src/bin/sales_demo.rs
@@ -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);
+}
diff --git a/crates/modules/nakui/core/src/delta.rs b/crates/modules/nakui/core/src/delta.rs
new file mode 100644
index 0000000..35f3faf
--- /dev/null
+++ b/crates/modules/nakui/core/src/delta.rs
@@ -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
+}
diff --git a/crates/modules/nakui/core/src/drift.rs b/crates/modules/nakui/core/src/drift.rs
new file mode 100644
index 0000000..884ec6f
--- /dev/null
+++ b/crates/modules/nakui/core/src/drift.rs
@@ -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());
+ }
+}
diff --git a/crates/modules/nakui/core/src/event_log.rs b/crates/modules/nakui/core/src/event_log.rs
new file mode 100644
index 0000000..1a3d2f5
--- /dev/null
+++ b/crates/modules/nakui/core/src/event_log.rs
@@ -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(())
+}
diff --git a/crates/modules/nakui/core/src/executor.rs b/crates/modules/nakui/core/src/executor.rs
new file mode 100644
index 0000000..07627b7
--- /dev/null
+++ b/crates/modules/nakui/core/src/executor.rs
@@ -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;");
+ }
+}
diff --git a/crates/modules/nakui/core/src/graph.rs b/crates/modules/nakui/core/src/graph.rs
new file mode 100644
index 0000000..21dece5
--- /dev/null
+++ b/crates/modules/nakui/core/src/graph.rs
@@ -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();
+ }
+}
diff --git a/crates/modules/nakui/core/src/kcl_wrapper.rs b/crates/modules/nakui/core/src/kcl_wrapper.rs
new file mode 100644
index 0000000..94e890a
--- /dev/null
+++ b/crates/modules/nakui/core/src/kcl_wrapper.rs
@@ -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))
+ }
+}
diff --git a/crates/modules/nakui/core/src/lib.rs b/crates/modules/nakui/core/src/lib.rs
new file mode 100644
index 0000000..e2353de
--- /dev/null
+++ b/crates/modules/nakui/core/src/lib.rs
@@ -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;
diff --git a/crates/modules/nakui/core/src/manifest.rs b/crates/modules/nakui/core/src/manifest.rs
new file mode 100644
index 0000000..dda56e6
--- /dev/null
+++ b/crates/modules/nakui/core/src/manifest.rs
@@ -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"]
+ );
+ }
+}
diff --git a/crates/modules/nakui/core/src/rhai_executor.rs b/crates/modules/nakui/core/src/rhai_executor.rs
new file mode 100644
index 0000000..be41439
--- /dev/null
+++ b/crates/modules/nakui/core/src/rhai_executor.rs
@@ -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)
+ }
+}
diff --git a/crates/modules/nakui/core/src/run.rs b/crates/modules/nakui/core/src/run.rs
new file mode 100644
index 0000000..672b309
--- /dev/null
+++ b/crates/modules/nakui/core/src/run.rs
@@ -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
+}
+
diff --git a/crates/modules/nakui/core/src/store.rs b/crates/modules/nakui/core/src/store.rs
new file mode 100644
index 0000000..ef10b68
--- /dev/null
+++ b/crates/modules/nakui/core/src/store.rs
@@ -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()
+ }
+}
diff --git a/crates/modules/nakui/core/src/surreal_store.rs b/crates/modules/nakui/core/src/surreal_store.rs
new file mode 100644
index 0000000..c233e6e
--- /dev/null
+++ b/crates/modules/nakui/core/src/surreal_store.rs
@@ -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())
+ }
+}
diff --git a/crates/modules/nakui/core/tests/drift.rs b/crates/modules/nakui/core/tests/drift.rs
new file mode 100644
index 0000000..5c0c10a
--- /dev/null
+++ b/crates/modules/nakui/core/tests/drift.rs
@@ -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);
+}
diff --git a/crates/modules/nakui/core/tests/event_log.rs b/crates/modules/nakui/core/tests/event_log.rs
new file mode 100644
index 0000000..d943c74
--- /dev/null
+++ b/crates/modules/nakui/core/tests/event_log.rs
@@ -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);
+}
diff --git a/crates/modules/nakui/core/tests/fixtures/bad_created_record.rhai b/crates/modules/nakui/core/tests/fixtures/bad_created_record.rhai
new file mode 100644
index 0000000..4fec923
--- /dev/null
+++ b/crates/modules/nakui/core/tests/fixtures/bad_created_record.rhai
@@ -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",
+ },
+ },
+]
diff --git a/crates/modules/nakui/core/tests/fixtures/capability_violation.rhai b/crates/modules/nakui/core/tests/fixtures/capability_violation.rhai
new file mode 100644
index 0000000..696a44f
--- /dev/null
+++ b/crates/modules/nakui/core/tests/fixtures/capability_violation.rhai
@@ -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,
+ },
+]
diff --git a/crates/modules/nakui/core/tests/fixtures/conservation_violation.rhai b/crates/modules/nakui/core/tests/fixtures/conservation_violation.rhai
new file mode 100644
index 0000000..19084b5
--- /dev/null
+++ b/crates/modules/nakui/core/tests/fixtures/conservation_violation.rhai
@@ -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,
+ },
+]
diff --git a/crates/modules/nakui/core/tests/fixtures/delete_primary.rhai b/crates/modules/nakui/core/tests/fixtures/delete_primary.rhai
new file mode 100644
index 0000000..87ab2a2
--- /dev/null
+++ b/crates/modules/nakui/core/tests/fixtures/delete_primary.rhai
@@ -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 },
+]
diff --git a/crates/modules/nakui/core/tests/fixtures/entity_mismatch.rhai b/crates/modules/nakui/core/tests/fixtures/entity_mismatch.rhai
new file mode 100644
index 0000000..331f7d2
--- /dev/null
+++ b/crates/modules/nakui/core/tests/fixtures/entity_mismatch.rhai
@@ -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,
+ },
+]
diff --git a/crates/modules/nakui/core/tests/graph.rs b/crates/modules/nakui/core/tests/graph.rs
new file mode 100644
index 0000000..da676a0
--- /dev/null
+++ b/crates/modules/nakui/core/tests/graph.rs
@@ -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);
+}
diff --git a/crates/modules/nakui/core/tests/inventory.rs b/crates/modules/nakui/core/tests/inventory.rs
new file mode 100644
index 0000000..96d4d26
--- /dev/null
+++ b/crates/modules/nakui/core/tests/inventory.rs
@@ -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"
+ );
+}
diff --git a/crates/modules/nakui/core/tests/kernel_guards.rs b/crates/modules/nakui/core/tests/kernel_guards.rs
new file mode 100644
index 0000000..7f4f876
--- /dev/null
+++ b/crates/modules/nakui/core/tests/kernel_guards.rs
@@ -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"
+ );
+}
diff --git a/crates/modules/nakui/core/tests/manifest_validation.rs b/crates/modules/nakui/core/tests/manifest_validation.rs
new file mode 100644
index 0000000..2b4b1bd
--- /dev/null
+++ b/crates/modules/nakui/core/tests/manifest_validation.rs
@@ -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);
+}
diff --git a/crates/modules/nakui/core/tests/run.rs b/crates/modules/nakui/core/tests/run.rs
new file mode 100644
index 0000000..94f7e8b
--- /dev/null
+++ b/crates/modules/nakui/core/tests/run.rs
@@ -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);
+}
diff --git a/crates/modules/nakui/core/tests/run_persistent.rs b/crates/modules/nakui/core/tests/run_persistent.rs
new file mode 100644
index 0000000..63bcfa5
--- /dev/null
+++ b/crates/modules/nakui/core/tests/run_persistent.rs
@@ -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);
+}
diff --git a/crates/modules/nakui/core/tests/sales.rs b/crates/modules/nakui/core/tests/sales.rs
new file mode 100644
index 0000000..a9e5e49
--- /dev/null
+++ b/crates/modules/nakui/core/tests/sales.rs
@@ -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 = stock−1, 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"
+ );
+}
diff --git a/crates/modules/nakui/core/tests/schema_versioning.rs b/crates/modules/nakui/core/tests/schema_versioning.rs
new file mode 100644
index 0000000..b8a5542
--- /dev/null
+++ b/crates/modules/nakui/core/tests/schema_versioning.rs
@@ -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);
+}
diff --git a/crates/modules/nakui/core/tests/snapshot_chain.rs b/crates/modules/nakui/core/tests/snapshot_chain.rs
new file mode 100644
index 0000000..afecc30
--- /dev/null
+++ b/crates/modules/nakui/core/tests/snapshot_chain.rs
@@ -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"}));
+}
diff --git a/crates/modules/nakui/core/tests/state_hash.rs b/crates/modules/nakui/core/tests/state_hash.rs
new file mode 100644
index 0000000..19a3566
--- /dev/null
+++ b/crates/modules/nakui/core/tests/state_hash.rs
@@ -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");
+}
diff --git a/crates/modules/nakui/core/tests/surreal_persist.rs b/crates/modules/nakui/core/tests/surreal_persist.rs
new file mode 100644
index 0000000..3963b1a
--- /dev/null
+++ b/crates/modules/nakui/core/tests/surreal_persist.rs
@@ -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);
+}
diff --git a/crates/modules/nakui/core/tests/surreal_store.rs b/crates/modules/nakui/core/tests/surreal_store.rs
new file mode 100644
index 0000000..da12b88
--- /dev/null
+++ b/crates/modules/nakui/core/tests/surreal_store.rs
@@ -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, c
El parche sigue, pero se cortó acá: pedí el commit por git para verlo entero.