feat: schema projections, command transactions, quantum-secure encryption

This commit is contained in:
Will Anderson
2026-04-27 18:26:46 -05:00
parent 69410a6908
commit 192528543f
27 changed files with 5460 additions and 4 deletions
+346 -4
View File
@@ -2,6 +2,41 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures 0.2.17",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -29,6 +64,18 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arrayref"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "async-trait"
version = "0.1.89"
@@ -65,7 +112,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"axum-core 0.4.5",
"bytes",
"futures-util",
"http 1.4.0",
@@ -74,7 +121,7 @@ dependencies = [
"hyper",
"hyper-util",
"itoa",
"matchit",
"matchit 0.7.3",
"memchr",
"mime",
"percent-encoding",
@@ -92,6 +139,34 @@ dependencies = [
"tracing",
]
[[package]]
name = "axum"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core 0.5.6",
"bytes",
"futures-util",
"http 1.4.0",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit 0.8.4",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"sync_wrapper",
"tokio",
"tower 0.5.3",
"tower-layer",
"tower-service",
]
[[package]]
name = "axum-core"
version = "0.4.5"
@@ -113,6 +188,24 @@ dependencies = [
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http 1.4.0",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
]
[[package]]
name = "axum-test"
version = "14.10.0"
@@ -122,7 +215,7 @@ dependencies = [
"anyhow",
"async-trait",
"auto-future",
"axum",
"axum 0.7.9",
"bytes",
"cookie",
"http 1.4.0",
@@ -169,6 +262,29 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "blake3"
version = "1.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
dependencies = [
"arrayref",
"arrayvec",
"cc",
"cfg-if",
"constant_time_eq",
"cpufeatures 0.3.0",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.20.2"
@@ -209,6 +325,16 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "combine"
version = "4.6.7"
@@ -219,6 +345,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "constant_time_eq"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
[[package]]
name = "cookie"
version = "0.18.1"
@@ -255,6 +387,24 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -289,6 +439,26 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core",
"typenum",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "deranged"
version = "0.5.8"
@@ -304,6 +474,16 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
@@ -352,6 +532,21 @@ dependencies = [
"uuid",
]
[[package]]
name = "engram-crypto"
version = "0.1.0"
dependencies = [
"aes-gcm",
"base64",
"blake3",
"rand",
"serde",
"serde_json",
"tempfile",
"thiserror 1.0.69",
"uuid",
]
[[package]]
name = "engram-ffi"
version = "0.1.0"
@@ -379,17 +574,36 @@ dependencies = [
"engram-core",
]
[[package]]
name = "engram-projection"
version = "0.1.0"
dependencies = [
"base64",
"engram-core",
"serde",
"serde_json",
"tempfile",
"thiserror 1.0.69",
"uuid",
]
[[package]]
name = "engram-server"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"axum 0.7.9",
"axum-test",
"engram-core",
"engram-crypto",
"engram-projection",
"engram-sync",
"engram-tx",
"mime_guess",
"rust-embed",
"serde",
"serde_json",
"sled",
"tempfile",
"thiserror 1.0.69",
"tokio",
@@ -415,6 +629,19 @@ dependencies = [
"uuid",
]
[[package]]
name = "engram-tx"
version = "0.1.0"
dependencies = [
"engram-core",
"serde",
"serde_json",
"sled",
"tempfile",
"thiserror 1.0.69",
"uuid",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -557,6 +784,16 @@ dependencies = [
"byteorder",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -581,6 +818,16 @@ dependencies = [
"wasip3",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "h2"
version = "0.4.13"
@@ -906,6 +1153,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "instant"
version = "0.1.13"
@@ -1078,6 +1334,12 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "memchr"
version = "2.8.0"
@@ -1168,6 +1430,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl"
version = "0.10.78"
@@ -1307,6 +1575,18 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -1537,6 +1817,41 @@ dependencies = [
"smallvec",
]
[[package]]
name = "rust-embed"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27"
dependencies = [
"axum 0.8.9",
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa"
dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
"syn",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
dependencies = [
"sha2",
"walkdir",
]
[[package]]
name = "rust-multipart-rfc7578_2"
version = "0.6.1"
@@ -1739,6 +2054,17 @@ dependencies = [
"serde",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -2185,6 +2511,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "unicase"
version = "2.9.0"
@@ -2203,6 +2535,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "untrusted"
version = "0.9.0"
+3
View File
@@ -7,6 +7,9 @@ members = [
"crates/engram-migrate",
"crates/engram-sync",
"crates/engram-server",
"crates/engram-projection",
"crates/engram-tx",
"crates/engram-crypto",
# engram-wasm is in bindings/ and compiled separately via wasm-pack
# (wasm targets can't be in the same workspace build as native targets)
]
+34
View File
@@ -0,0 +1,34 @@
[package]
name = "engram-crypto"
version = "0.1.0"
edition = "2021"
description = "Quantum-secure encryption at rest for Engram — AES-256-GCM with PQ upgrade path"
license = "MIT"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
thiserror = "1"
# AES-256-GCM symmetric encryption (quantum-resistant at 256-bit key length)
aes-gcm = "0.10"
# BLAKE3 for key derivation (fast, cryptographically strong)
blake3 = "1"
# Random number generation
rand = "0.8"
# Base64 encoding for serialization (used in EncryptedContent serialization)
base64 = "0.22"
# TODO: Upgrade to post-quantum KEM/signature once crates stabilize.
# Target: ml-kem (CRYSTALS-Kyber / NIST ML-KEM) and ml-dsa (CRYSTALS-Dilithium / NIST ML-DSA).
# As of 2025, the `ml-kem` and `ml-dsa` crates are available on crates.io but not yet
# production-stable for all platforms. The algorithm registry structure below is designed
# so that the upgrade is a drop-in: add the PQ crate, implement the KemAlgorithm variant,
# and new writes use the new algorithm while old records continue to decrypt via the registry.
#
# Uncomment when ready:
# ml-kem = "0.2" # CRYSTALS-Kyber (NIST ML-KEM 768/1024)
# ml-dsa = "0.1" # CRYSTALS-Dilithium (NIST ML-DSA)
[dev-dependencies]
tempfile = "3"
@@ -0,0 +1,87 @@
/// Algorithm registry types — the versioning layer for crypto algorithm rotation.
///
/// Each encrypted record carries an `algorithm_id`. The registry maps these IDs
/// to the parameters needed to decrypt. When you rotate algorithms, old records
/// keep their ID and decrypt using the historical version. New records use the
/// new active algorithm.
use serde::{Deserialize, Serialize};
/// Key Encapsulation Mechanism algorithms.
///
/// # Current
/// - `Aes256GcmDirect`: AES-256-GCM with a directly-provided 256-bit key.
/// Quantum-resistant at 256-bit (Grover halves to 128-bit effective security).
///
/// # Planned (post-quantum upgrade)
/// - `MlKem768`: CRYSTALS-Kyber 768 (NIST ML-KEM Level 3 — 128-bit PQ security)
/// - `MlKem1024`: CRYSTALS-Kyber 1024 (NIST ML-KEM Level 5 — 256-bit PQ security)
/// - `ClassicRsa4096`: RSA-4096 OAEP fallback (NOT quantum-resistant — for compat only)
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum KemAlgorithm {
/// AES-256-GCM with direct key (current default, quantum-resistant at 256-bit)
Aes256GcmDirect,
// TODO: uncomment when ml-kem crate stabilizes
// MlKem768,
// MlKem1024,
// ClassicRsa4096,
}
impl KemAlgorithm {
pub fn id(&self) -> &'static str {
match self {
KemAlgorithm::Aes256GcmDirect => "aes256gcm-direct-v1",
}
}
}
/// Signature algorithms for authenticating ciphertext.
///
/// # Current
/// - `Blake3Mac`: BLAKE3 keyed hash as a MAC (message authentication code).
/// Not a signature in the asymmetric sense, but provides authenticity.
///
/// # Planned (post-quantum upgrade)
/// - `MlDsa44` / `MlDsa65` / `MlDsa87`: CRYSTALS-Dilithium (NIST ML-DSA)
/// - `SphincsSha256128f`: SPHINCS+ stateless hash-based signature
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SigAlgorithm {
/// BLAKE3 keyed MAC (current default)
Blake3Mac,
// TODO: uncomment when ml-dsa crate stabilizes
// MlDsa44, // NIST Security Level 2 (128-bit)
// MlDsa65, // NIST Security Level 3 (192-bit)
// MlDsa87, // NIST Security Level 5 (256-bit)
// SphincsSha256128f, // Stateless hash-based, conservative security
}
impl SigAlgorithm {
pub fn id(&self) -> &'static str {
match self {
SigAlgorithm::Blake3Mac => "blake3-mac-v1",
}
}
}
/// A versioned algorithm configuration entry.
///
/// Historical versions are kept in the registry so that old ciphertexts can
/// always be decrypted even after algorithm rotation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlgorithmVersion {
/// Unique string ID stored alongside every ciphertext.
pub id: String,
/// The KEM algorithm used for key derivation/encapsulation.
pub kem: KemAlgorithm,
/// The signature algorithm used for ciphertext authentication.
pub sig: SigAlgorithm,
/// Unix milliseconds when this version became active.
pub activated_at: i64,
/// Unix milliseconds when this version was superseded (None = still active).
pub retired_at: Option<i64>,
}
impl AlgorithmVersion {
pub fn is_active(&self) -> bool {
self.retired_at.is_none()
}
}
+340
View File
@@ -0,0 +1,340 @@
/// CryptoEngine — encrypt and decrypt node content with algorithm versioning.
///
/// # Security Model
///
/// - **Symmetric encryption**: AES-256-GCM (authenticated encryption, quantum-resistant)
/// - **Key derivation**: BLAKE3 KDF — stretches the master key into per-operation keys
/// - **Authentication**: BLAKE3 keyed MAC over (algorithm_id || nonce || ciphertext)
/// - **Nonces**: 96-bit random nonce per encryption (from OS CSPRNG via `rand`)
///
/// # AES-256-GCM and Quantum Resistance
///
/// AES-256 is considered quantum-resistant: Grover's algorithm provides at most
/// a quadratic speedup, reducing 256-bit security to ~128-bit effective security
/// against quantum adversaries. 128-bit quantum security is currently considered
/// sufficient. The algorithm_id in EncryptedContent ensures an upgrade to
/// ML-KEM/Kyber is a transparent drop-in when the crates stabilize.
use aes_gcm::{
aead::{Aead, AeadCore, KeyInit, OsRng},
Aes256Gcm, Key, Nonce,
};
use serde::{Deserialize, Serialize};
use crate::error::{CryptoError, CryptoResult};
use crate::registry::AlgorithmRegistry;
/// An encrypted content blob, self-describing with its algorithm version.
///
/// The `algorithm_id` field allows any version to decrypt any record,
/// even after algorithm rotation. This is the key to migration-free upgrades.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EncryptedContent {
/// Which algorithm version encrypted this record.
/// Maps to an entry in `AlgorithmRegistry::versions`.
pub algorithm_id: String,
/// The AES-256-GCM ciphertext (includes the GCM auth tag).
pub ciphertext: Vec<u8>,
/// In a PQ scheme: the KEM-encapsulated symmetric key.
/// In the current AES-direct scheme: empty (key is derived from master key + context).
pub encapsulated_key: Vec<u8>,
/// 96-bit random AES-GCM nonce.
pub nonce: Vec<u8>,
/// BLAKE3 MAC over (algorithm_id || nonce || ciphertext).
/// In a PQ scheme: this would be a Dilithium/ML-DSA signature.
pub signature: Vec<u8>,
}
impl EncryptedContent {
/// Serialize to a compact JSON string for storage.
pub fn to_bytes(&self) -> CryptoResult<Vec<u8>> {
serde_json::to_vec(self).map_err(|e| CryptoError::Serialization(e.to_string()))
}
/// Deserialize from bytes (JSON).
pub fn from_bytes(bytes: &[u8]) -> CryptoResult<Self> {
serde_json::from_slice(bytes).map_err(|e| CryptoError::DecryptionFailed(e.to_string()))
}
}
/// The encryption engine.
///
/// One engine instance per process (or per-request for stateless usage).
/// The engine holds the master key and the algorithm registry.
pub struct CryptoEngine {
/// Master key bytes — 32 bytes for AES-256.
/// In a PQ scheme: this would be a keypair (public/private).
master_key: [u8; 32],
/// Algorithm registry — tracks active and historical versions.
pub registry: AlgorithmRegistry,
}
impl CryptoEngine {
/// Create an engine from a 32-byte master key (AES-256 requires 256-bit key).
pub fn from_key(key: &[u8]) -> CryptoResult<Self> {
if key.len() < 32 {
return Err(CryptoError::InvalidKeyLength {
expected: 32,
got: key.len(),
});
}
let mut master_key = [0u8; 32];
master_key.copy_from_slice(&key[..32]);
Ok(Self {
master_key,
registry: AlgorithmRegistry::default_registry(),
})
}
/// Create an engine from an environment variable `ENGRAM_ENCRYPTION_KEY`.
///
/// Returns `None` if the variable is not set (dev mode — plaintext storage).
/// Returns an error if the variable is set but the key is too short.
pub fn from_env() -> CryptoResult<Option<Self>> {
match std::env::var("ENGRAM_ENCRYPTION_KEY") {
Ok(key_str) => {
let key_bytes = key_str.as_bytes();
// Derive a 32-byte key from whatever the user provided
let derived = derive_key(key_bytes, b"engram-master-key");
Ok(Some(Self::from_key(&derived)?))
}
Err(std::env::VarError::NotPresent) => Ok(None),
Err(e) => Err(CryptoError::KeyDerivation(e.to_string())),
}
}
// ── Encrypt ───────────────────────────────────────────────────────────────
/// Encrypt `plaintext` using the active algorithm.
///
/// Returns an `EncryptedContent` that is self-describing: it carries its
/// `algorithm_id`, so decryption never needs out-of-band version tracking.
pub fn encrypt(&self, plaintext: &[u8]) -> CryptoResult<EncryptedContent> {
let algorithm_id = self.registry.active_id().to_string();
// Derive a per-operation encryption key from the master key
let enc_key_bytes = derive_key(&self.master_key, b"encrypt");
let key = Key::<Aes256Gcm>::from_slice(&enc_key_bytes);
let cipher = Aes256Gcm::new(key);
// Random 96-bit nonce
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
// Encrypt
let ciphertext = cipher
.encrypt(&nonce, plaintext)
.map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?;
// MAC: BLAKE3 keyed hash over (algorithm_id || nonce || ciphertext)
let mac_key = derive_key(&self.master_key, b"mac");
let signature = compute_mac(&mac_key, &algorithm_id, nonce.as_slice(), &ciphertext);
Ok(EncryptedContent {
algorithm_id,
ciphertext,
encapsulated_key: vec![], // unused in AES-direct mode
nonce: nonce.to_vec(),
signature,
})
}
// ── Decrypt ───────────────────────────────────────────────────────────────
/// Decrypt an `EncryptedContent`, dispatching to the correct algorithm version.
pub fn decrypt(&self, content: &EncryptedContent) -> CryptoResult<Vec<u8>> {
// Look up the algorithm version that produced this ciphertext
let _version = self.registry.get_version(&content.algorithm_id)?;
// Verify MAC before decrypting (fail-fast on tampering)
let mac_key = derive_key(&self.master_key, b"mac");
let expected = compute_mac(&mac_key, &content.algorithm_id, &content.nonce, &content.ciphertext);
if expected != content.signature {
return Err(CryptoError::SignatureInvalid);
}
// Decrypt
let enc_key_bytes = derive_key(&self.master_key, b"encrypt");
let key = Key::<Aes256Gcm>::from_slice(&enc_key_bytes);
let cipher = Aes256Gcm::new(key);
if content.nonce.len() != 12 {
return Err(CryptoError::DecryptionFailed(format!(
"invalid nonce length: {}",
content.nonce.len()
)));
}
let nonce = Nonce::from_slice(&content.nonce);
let plaintext = cipher
.decrypt(nonce, content.ciphertext.as_slice())
.map_err(|e| CryptoError::DecryptionFailed(e.to_string()))?;
Ok(plaintext)
}
// ── Signature verification ─────────────────────────────────────────────────
/// Verify the MAC/signature on an encrypted content blob.
pub fn verify_signature(&self, content: &EncryptedContent) -> CryptoResult<bool> {
let mac_key = derive_key(&self.master_key, b"mac");
let expected = compute_mac(&mac_key, &content.algorithm_id, &content.nonce, &content.ciphertext);
Ok(expected == content.signature)
}
// ── Algorithm rotation ────────────────────────────────────────────────────
/// Rotate to a new KEM algorithm.
///
/// After rotation, new encryptions use the new algorithm.
/// Old records retain their `algorithm_id` and decrypt via the historical registry.
pub fn rotate_algorithm(&mut self, new_kem: crate::algorithm::KemAlgorithm) -> CryptoResult<()> {
self.registry.rotate_kem(new_kem)
}
}
// ── Key derivation ────────────────────────────────────────────────────────────
/// Derive a 32-byte sub-key from the master key and a context string.
/// Uses BLAKE3's keyed hash for domain separation.
fn derive_key(master: &[u8], context: &[u8]) -> [u8; 32] {
// BLAKE3 derive_key: master is the key material, context is the domain
let mut hasher = blake3::Hasher::new_keyed(
&padded_32(master),
);
hasher.update(context);
let hash = hasher.finalize();
*hash.as_bytes()
}
/// Pad or truncate bytes to exactly 32 bytes.
fn padded_32(bytes: &[u8]) -> [u8; 32] {
let mut out = [0u8; 32];
let len = bytes.len().min(32);
out[..len].copy_from_slice(&bytes[..len]);
out
}
/// Compute a BLAKE3 keyed MAC over (algorithm_id || nonce || ciphertext).
fn compute_mac(mac_key: &[u8; 32], algorithm_id: &str, nonce: &[u8], ciphertext: &[u8]) -> Vec<u8> {
let mut hasher = blake3::Hasher::new_keyed(mac_key);
hasher.update(algorithm_id.as_bytes());
hasher.update(nonce);
hasher.update(ciphertext);
hasher.finalize().as_bytes().to_vec()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_engine() -> CryptoEngine {
let key = b"test-master-key-must-be-32-bytes";
CryptoEngine::from_key(key).unwrap()
}
#[test]
fn test_encrypt_decrypt_roundtrip() {
let engine = make_engine();
let plaintext = b"sensitive memory content - do not store unencrypted";
let enc = engine.encrypt(plaintext).unwrap();
let dec = engine.decrypt(&enc).unwrap();
assert_eq!(dec, plaintext);
}
#[test]
fn test_nonce_is_random() {
let engine = make_engine();
let enc1 = engine.encrypt(b"same plaintext").unwrap();
let enc2 = engine.encrypt(b"same plaintext").unwrap();
// Nonces must differ (probabilistic — collision probability 1/2^96)
assert_ne!(enc1.nonce, enc2.nonce);
// Ciphertexts must differ (different nonces → different ciphertexts)
assert_ne!(enc1.ciphertext, enc2.ciphertext);
}
#[test]
fn test_tampered_ciphertext_rejected() {
let engine = make_engine();
let mut enc = engine.encrypt(b"original").unwrap();
// Flip a byte in the ciphertext
if let Some(b) = enc.ciphertext.first_mut() {
*b ^= 0xFF;
}
// Decryption should fail due to GCM auth tag verification
let result = engine.decrypt(&enc);
assert!(result.is_err());
}
#[test]
fn test_tampered_mac_rejected() {
let engine = make_engine();
let mut enc = engine.encrypt(b"original").unwrap();
// Flip a byte in the signature
if let Some(b) = enc.signature.first_mut() {
*b ^= 0xFF;
}
let result = engine.decrypt(&enc);
assert!(result.is_err());
}
#[test]
fn test_verify_signature() {
let engine = make_engine();
let enc = engine.encrypt(b"content").unwrap();
assert!(engine.verify_signature(&enc).unwrap());
let mut tampered = enc.clone();
tampered.signature[0] ^= 0x01;
assert!(!engine.verify_signature(&tampered).unwrap());
}
#[test]
fn test_algorithm_id_stored() {
let engine = make_engine();
let enc = engine.encrypt(b"data").unwrap();
assert_eq!(enc.algorithm_id, "aes256gcm-direct-v1");
}
#[test]
fn test_serialization_roundtrip() {
let engine = make_engine();
let enc = engine.encrypt(b"serialize me").unwrap();
let bytes = enc.to_bytes().unwrap();
let restored = EncryptedContent::from_bytes(&bytes).unwrap();
let dec = engine.decrypt(&restored).unwrap();
assert_eq!(dec, b"serialize me");
}
#[test]
fn test_short_key_rejected() {
let short_key = b"too-short";
let result = CryptoEngine::from_key(short_key);
assert!(result.is_err());
}
#[test]
fn test_empty_plaintext() {
let engine = make_engine();
let enc = engine.encrypt(b"").unwrap();
let dec = engine.decrypt(&enc).unwrap();
assert_eq!(dec, b"");
}
#[test]
fn test_large_plaintext() {
let engine = make_engine();
let plaintext = vec![0xABu8; 1_000_000]; // 1 MB
let enc = engine.encrypt(&plaintext).unwrap();
let dec = engine.decrypt(&enc).unwrap();
assert_eq!(dec, plaintext);
}
#[test]
fn test_unknown_algorithm_rejected() {
let engine = make_engine();
let mut enc = engine.encrypt(b"data").unwrap();
enc.algorithm_id = "unknown-algo-v99".to_string();
let result = engine.decrypt(&enc);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), CryptoError::UnknownAlgorithm(_)));
}
}
+30
View File
@@ -0,0 +1,30 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum CryptoError {
#[error("Encryption failed: {0}")]
EncryptionFailed(String),
#[error("Decryption failed: {0}")]
DecryptionFailed(String),
#[error("Signature verification failed")]
SignatureInvalid,
#[error("Unknown algorithm ID: {0}")]
UnknownAlgorithm(String),
#[error("Key derivation failed: {0}")]
KeyDerivation(String),
#[error("Invalid key length: expected {expected}, got {got}")]
InvalidKeyLength { expected: usize, got: usize },
#[error("Algorithm not available: {0}")]
AlgorithmUnavailable(String),
#[error("Serialization error: {0}")]
Serialization(String),
}
pub type CryptoResult<T> = Result<T, CryptoError>;
+43
View File
@@ -0,0 +1,43 @@
/// Engram Crypto — quantum-secure encryption at rest.
///
/// # Current Implementation
///
/// Uses AES-256-GCM for symmetric encryption with BLAKE3 for key derivation.
/// AES-256 is already quantum-resistant (Grover's algorithm halves the key space
/// from 2^256 to 2^128, which remains computationally infeasible).
///
/// # Post-Quantum Upgrade Path
///
/// The `AlgorithmRegistry` stores an `algorithm_id` alongside every ciphertext.
/// When ML-KEM (CRYSTALS-Kyber) and ML-DSA (CRYSTALS-Dilithium) crates stabilize,
/// the upgrade is:
/// 1. Add `KemAlgorithm::MlKem768` / `MlKem1024` variants
/// 2. Implement `CryptoEngine::encrypt()` for the new algorithm
/// 3. Set it as the active algorithm in the registry
/// 4. Old records continue to decrypt via their stored `algorithm_id`
/// 5. Background re-encryption rotates old records to the new algorithm
///
/// No data migration required — the registry handles version negotiation.
///
/// # Usage
///
/// ```rust,no_run
/// use engram_crypto::{CryptoEngine, AlgorithmRegistry};
///
/// let key = b"an-example-32-byte-key!!12345678";
/// let engine = CryptoEngine::from_key(key).unwrap();
///
/// let plaintext = b"sensitive memory content";
/// let encrypted = engine.encrypt(plaintext).unwrap();
/// let decrypted = engine.decrypt(&encrypted).unwrap();
/// assert_eq!(plaintext, decrypted.as_slice());
/// ```
pub mod algorithm;
pub mod engine;
pub mod error;
pub mod registry;
pub use algorithm::{AlgorithmVersion, KemAlgorithm, SigAlgorithm};
pub use engine::{CryptoEngine, EncryptedContent};
pub use error::CryptoError;
pub use registry::AlgorithmRegistry;
@@ -0,0 +1,95 @@
/// Algorithm registry — tracks active and historical algorithm versions.
use std::collections::HashMap;
use crate::algorithm::{AlgorithmVersion, KemAlgorithm, SigAlgorithm};
use crate::error::{CryptoError, CryptoResult};
/// Maintains the set of algorithm versions known to this node.
///
/// The active version is used for all new encryptions.
/// Historical versions remain so old ciphertexts can always be decrypted.
pub struct AlgorithmRegistry {
/// The currently active algorithm version.
pub active_kem: KemAlgorithm,
pub active_sig: SigAlgorithm,
/// All known versions (active + historical), keyed by algorithm ID.
pub versions: HashMap<String, AlgorithmVersion>,
}
impl AlgorithmRegistry {
/// Create a registry with the default algorithm (AES-256-GCM + BLAKE3 MAC).
pub fn default_registry() -> Self {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64;
let default_version = AlgorithmVersion {
id: "aes256gcm-direct-v1".to_string(),
kem: KemAlgorithm::Aes256GcmDirect,
sig: SigAlgorithm::Blake3Mac,
activated_at: now,
retired_at: None,
};
let mut versions = HashMap::new();
versions.insert(default_version.id.clone(), default_version);
Self {
active_kem: KemAlgorithm::Aes256GcmDirect,
active_sig: SigAlgorithm::Blake3Mac,
versions,
}
}
/// Get the active algorithm ID (used as the `algorithm_id` in new ciphertexts).
pub fn active_id(&self) -> &str {
self.active_kem.id()
}
/// Look up a version by its ID (for decryption of historical records).
pub fn get_version(&self, id: &str) -> CryptoResult<&AlgorithmVersion> {
self.versions
.get(id)
.ok_or_else(|| CryptoError::UnknownAlgorithm(id.to_string()))
}
/// Rotate to a new KEM algorithm.
///
/// The current active version is marked as retired. A new version entry is
/// added and becomes active. Old records retain their algorithm_id and can
/// still be decrypted via `get_version()`.
///
/// Background re-encryption can then update old records at leisure.
pub fn rotate_kem(&mut self, new_kem: KemAlgorithm) -> CryptoResult<()> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64;
// Retire current active version
if let Some(current) = self.versions.get_mut(self.active_kem.id()) {
current.retired_at = Some(now);
}
let new_id = new_kem.id().to_string();
let new_version = AlgorithmVersion {
id: new_id.clone(),
kem: new_kem.clone(),
sig: self.active_sig.clone(),
activated_at: now,
retired_at: None,
};
self.versions.insert(new_id, new_version);
self.active_kem = new_kem;
Ok(())
}
/// List all versions, active and historical.
pub fn list_versions(&self) -> Vec<&AlgorithmVersion> {
let mut vs: Vec<&AlgorithmVersion> = self.versions.values().collect();
vs.sort_by_key(|v| v.activated_at);
vs
}
}
@@ -0,0 +1,17 @@
[package]
name = "engram-projection"
version = "0.1.0"
edition = "2021"
description = "Schema/projection layer for Engram — schema-free views over the activation surface"
license = "MIT"
[dependencies]
engram-core = { path = "../engram-core" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
thiserror = "1"
base64 = "0.22"
[dev-dependencies]
tempfile = "3"
@@ -0,0 +1,462 @@
/// Projection engine — maps activated nodes through a ProjectionSchema.
///
/// The engine is stateless: it takes a schema and a result set, and returns
/// the projected view. No mutation of the underlying graph occurs.
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use engram_core::types::{ActivatedNode, NodeType};
use serde_json::{json, Value};
use std::collections::HashMap;
use uuid::Uuid;
use crate::error::ProjectionResult;
use crate::schema::{
FieldMapping, FieldSource, NodeFilter, ProjectedRow, ProjectionResult as PResult,
ProjectionSchema, ProjectionType,
};
/// Stateless projection executor.
pub struct ProjectionEngine;
impl ProjectionEngine {
/// Apply a projection schema to a set of activated nodes.
///
/// Returns a `ProjectionResult` containing the shaped output.
/// The activation result set is not modified.
pub fn project(
schema: &ProjectionSchema,
activated: &[ActivatedNode],
) -> ProjectionResult<PResult> {
// Step 1: filter to in-scope nodes
let in_scope: Vec<&ActivatedNode> = activated
.iter()
.filter(|a| matches_filter(a, &schema.node_filter))
.collect();
let nodes_in_scope = in_scope.len();
match schema.projection_type {
ProjectionType::Relational | ProjectionType::Document => {
let rows = in_scope
.iter()
.map(|a| project_row(a, &schema.field_mappings))
.collect::<ProjectionResult<Vec<_>>>()?;
Ok(PResult {
schema_name: schema.name.clone(),
nodes_in_scope,
rows,
key_value: None,
wide_column: None,
})
}
ProjectionType::KeyValue => {
let mut kv: HashMap<String, Value> = HashMap::new();
for a in &in_scope {
let key = a.node.id.to_string();
let val = String::from_utf8_lossy(&a.node.content).to_string();
kv.insert(key, Value::String(val));
}
Ok(PResult {
schema_name: schema.name.clone(),
nodes_in_scope,
rows: vec![],
key_value: Some(kv),
wide_column: None,
})
}
ProjectionType::WideColumn => {
let mut wc: HashMap<String, HashMap<String, Value>> = HashMap::new();
for a in &in_scope {
let id = a.node.id.to_string();
let mut cols = HashMap::new();
for mapping in &schema.field_mappings {
let val = extract_field(a, &mapping.source)
.unwrap_or_else(|| mapping.default.clone().unwrap_or(Value::Null));
cols.insert(mapping.field_name.clone(), val);
}
wc.insert(id, cols);
}
Ok(PResult {
schema_name: schema.name.clone(),
nodes_in_scope,
rows: vec![],
key_value: None,
wide_column: Some(wc),
})
}
}
}
}
// ── Node filter evaluation ────────────────────────────────────────────────────
fn matches_filter(a: &ActivatedNode, filter: &NodeFilter) -> bool {
match filter {
NodeFilter::All => true,
NodeFilter::ByType(types) => {
let node_type_str = node_type_str(&a.node.node_type);
types.iter().any(|t| t == node_type_str)
}
NodeFilter::ByTier(tiers) => tiers.iter().any(|t| t == &a.node.tier),
NodeFilter::ByTag(tags) => {
// Tags are searched in content (treated as UTF-8) as a simple substring match.
// This is intentionally lenient — callers may embed tag metadata in content.
let content_str = String::from_utf8_lossy(&a.node.content);
tags.iter().any(|tag| content_str.contains(tag.as_str()))
}
NodeFilter::ByActivationThreshold(threshold) => a.activation_strength >= *threshold,
NodeFilter::BySalience(threshold) => a.node.salience >= *threshold,
NodeFilter::Combined(filters) => filters.iter().all(|f| matches_filter(a, f)),
NodeFilter::Any(filters) => filters.iter().any(|f| matches_filter(a, f)),
}
}
// ── Row projection ────────────────────────────────────────────────────────────
fn project_row(a: &ActivatedNode, mappings: &[FieldMapping]) -> ProjectionResult<ProjectedRow> {
let mut fields = HashMap::new();
for mapping in mappings {
let val = extract_field(a, &mapping.source)
.unwrap_or_else(|| mapping.default.clone().unwrap_or(Value::Null));
fields.insert(mapping.field_name.clone(), val);
}
Ok(ProjectedRow {
node_id: a.node.id,
fields,
})
}
// ── Field extraction ──────────────────────────────────────────────────────────
fn extract_field(a: &ActivatedNode, source: &FieldSource) -> Option<Value> {
match source {
FieldSource::NodeId => Some(Value::String(a.node.id.to_string())),
FieldSource::NodeType => Some(Value::String(node_type_str(&a.node.node_type).to_string())),
FieldSource::Tier => Some(Value::String(tier_str(&a.node.tier).to_string())),
FieldSource::Salience => Some(json!(a.node.salience)),
FieldSource::Importance => Some(json!(a.node.importance)),
FieldSource::ActivationStrength => Some(json!(a.activation_strength)),
FieldSource::Hops => Some(json!(a.hops)),
FieldSource::CreatedAt => Some(json!(a.node.created_at)),
FieldSource::LastActivated => Some(json!(a.node.last_activated)),
FieldSource::ActivationCount => Some(json!(a.node.activation_count)),
FieldSource::ContentRaw => {
Some(Value::String(String::from_utf8_lossy(&a.node.content).to_string()))
}
FieldSource::ContentBase64 => Some(Value::String(B64.encode(&a.node.content))),
FieldSource::ContentJsonPath(path) => {
// Parse content as JSON, then traverse the dot-path
let content_str = std::str::from_utf8(&a.node.content).ok()?;
let doc: Value = serde_json::from_str(content_str).ok()?;
traverse_json_path(&doc, path).cloned()
}
FieldSource::Literal(v) => Some(v.clone()),
}
}
/// Traverse a dot-separated JSON path.
/// E.g., "user.name" on `{"user": {"name": "Alice"}}` returns `"Alice"`.
fn traverse_json_path<'a>(doc: &'a Value, path: &str) -> Option<&'a Value> {
let mut current = doc;
for segment in path.split('.') {
current = match current {
Value::Object(map) => map.get(segment)?,
Value::Array(arr) => {
let idx: usize = segment.parse().ok()?;
arr.get(idx)?
}
_ => return None,
};
}
Some(current)
}
// ── String helpers ────────────────────────────────────────────────────────────
fn node_type_str(t: &NodeType) -> &'static str {
match t {
NodeType::Memory => "Memory",
NodeType::Concept => "Concept",
NodeType::Event => "Event",
NodeType::Entity => "Entity",
NodeType::Process => "Process",
NodeType::InternalState => "InternalState",
}
}
fn tier_str(t: &engram_core::types::MemoryTier) -> &'static str {
use engram_core::types::MemoryTier;
match t {
MemoryTier::Working => "Working",
MemoryTier::Episodic => "Episodic",
MemoryTier::Semantic => "Semantic",
MemoryTier::Procedural => "Procedural",
}
}
/// Extract node_id from a projected row (used for display / keying).
pub fn row_id(row: &ProjectedRow) -> Uuid {
row.node_id
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::{FieldMapping, FieldSource, NodeFilter, ProjectionSchema, ProjectionType};
use engram_core::types::{ActivatedNode, MemoryTier, Node, NodeType};
fn make_node(content: &str, tier: MemoryTier, importance: f32) -> Node {
Node::new(
NodeType::Memory,
vec![1.0, 0.0],
content.as_bytes().to_vec(),
tier,
importance,
)
}
fn make_activated(node: Node, strength: f32) -> ActivatedNode {
ActivatedNode {
node,
activation_strength: strength,
hops: 1,
}
}
#[test]
fn test_relational_projection_basic() {
let node = make_node("hello world", MemoryTier::Semantic, 0.8);
let activated = vec![make_activated(node, 0.9)];
let schema = ProjectionSchema {
name: "test".into(),
description: None,
projection_type: ProjectionType::Relational,
node_filter: NodeFilter::All,
field_mappings: vec![
FieldMapping {
field_name: "content".into(),
source: FieldSource::ContentRaw,
default: None,
},
FieldMapping {
field_name: "tier".into(),
source: FieldSource::Tier,
default: None,
},
FieldMapping {
field_name: "strength".into(),
source: FieldSource::ActivationStrength,
default: None,
},
],
};
let result = ProjectionEngine::project(&schema, &activated).unwrap();
assert_eq!(result.nodes_in_scope, 1);
assert_eq!(result.rows.len(), 1);
assert_eq!(result.rows[0].fields["content"], Value::String("hello world".into()));
assert_eq!(result.rows[0].fields["tier"], Value::String("Semantic".into()));
}
#[test]
fn test_key_value_projection() {
let node = make_node("test content", MemoryTier::Working, 0.5);
let activated = vec![make_activated(node, 0.7)];
let schema = ProjectionSchema {
name: "kv".into(),
description: None,
projection_type: ProjectionType::KeyValue,
node_filter: NodeFilter::All,
field_mappings: vec![],
};
let result = ProjectionEngine::project(&schema, &activated).unwrap();
assert_eq!(result.nodes_in_scope, 1);
let kv = result.key_value.unwrap();
assert_eq!(kv.len(), 1);
let content = kv.values().next().unwrap();
assert_eq!(content, &Value::String("test content".into()));
}
#[test]
fn test_filter_by_activation_threshold() {
let n1 = make_activated(make_node("high", MemoryTier::Semantic, 0.9), 0.8);
let n2 = make_activated(make_node("low", MemoryTier::Episodic, 0.3), 0.1);
let schema = ProjectionSchema {
name: "filtered".into(),
description: None,
projection_type: ProjectionType::Relational,
node_filter: NodeFilter::ByActivationThreshold(0.5),
field_mappings: vec![FieldMapping {
field_name: "content".into(),
source: FieldSource::ContentRaw,
default: None,
}],
};
let result = ProjectionEngine::project(&schema, &[n1, n2]).unwrap();
assert_eq!(result.nodes_in_scope, 1);
assert_eq!(result.rows[0].fields["content"], Value::String("high".into()));
}
#[test]
fn test_filter_by_tier() {
let n1 = make_activated(make_node("semantic", MemoryTier::Semantic, 0.9), 0.5);
let n2 = make_activated(make_node("working", MemoryTier::Working, 0.5), 0.5);
let schema = ProjectionSchema {
name: "tier_filter".into(),
description: None,
projection_type: ProjectionType::Relational,
node_filter: NodeFilter::ByTier(vec![MemoryTier::Semantic]),
field_mappings: vec![FieldMapping {
field_name: "content".into(),
source: FieldSource::ContentRaw,
default: None,
}],
};
let result = ProjectionEngine::project(&schema, &[n1, n2]).unwrap();
assert_eq!(result.nodes_in_scope, 1);
assert_eq!(result.rows[0].fields["content"], Value::String("semantic".into()));
}
#[test]
fn test_json_path_extraction() {
let content = r#"{"user": {"name": "Alice", "age": 30}}"#;
let node = make_node(content, MemoryTier::Semantic, 0.8);
let activated = vec![make_activated(node, 0.9)];
let schema = ProjectionSchema {
name: "json_path".into(),
description: None,
projection_type: ProjectionType::Relational,
node_filter: NodeFilter::All,
field_mappings: vec![
FieldMapping {
field_name: "name".into(),
source: FieldSource::ContentJsonPath("user.name".into()),
default: None,
},
FieldMapping {
field_name: "age".into(),
source: FieldSource::ContentJsonPath("user.age".into()),
default: None,
},
],
};
let result = ProjectionEngine::project(&schema, &activated).unwrap();
assert_eq!(result.rows[0].fields["name"], Value::String("Alice".into()));
assert_eq!(result.rows[0].fields["age"], json!(30));
}
#[test]
fn test_wide_column_projection() {
let n1 = make_activated(make_node("col_content", MemoryTier::Procedural, 0.6), 0.5);
let schema = ProjectionSchema {
name: "wide".into(),
description: None,
projection_type: ProjectionType::WideColumn,
node_filter: NodeFilter::All,
field_mappings: vec![
FieldMapping {
field_name: "raw".into(),
source: FieldSource::ContentRaw,
default: None,
},
FieldMapping {
field_name: "tier".into(),
source: FieldSource::Tier,
default: None,
},
],
};
let result = ProjectionEngine::project(&schema, &[n1]).unwrap();
assert_eq!(result.nodes_in_scope, 1);
let wc = result.wide_column.unwrap();
assert_eq!(wc.len(), 1);
let cols = wc.values().next().unwrap();
assert_eq!(cols["raw"], Value::String("col_content".into()));
assert_eq!(cols["tier"], Value::String("Procedural".into()));
}
#[test]
fn test_combined_filter() {
let n1 = make_activated(make_node("tag:important semantic", MemoryTier::Semantic, 0.9), 0.8);
let n2 = make_activated(make_node("no tag", MemoryTier::Semantic, 0.9), 0.8);
let n3 = make_activated(make_node("tag:important working", MemoryTier::Working, 0.3), 0.8);
let filter = NodeFilter::Combined(vec![
NodeFilter::ByTier(vec![MemoryTier::Semantic]),
NodeFilter::ByTag(vec!["tag:important".into()]),
]);
let schema = ProjectionSchema {
name: "combined".into(),
description: None,
projection_type: ProjectionType::Relational,
node_filter: filter,
field_mappings: vec![FieldMapping {
field_name: "content".into(),
source: FieldSource::ContentRaw,
default: None,
}],
};
let result = ProjectionEngine::project(&schema, &[n1, n2, n3]).unwrap();
assert_eq!(result.nodes_in_scope, 1);
assert_eq!(
result.rows[0].fields["content"],
Value::String("tag:important semantic".into())
);
}
#[test]
fn test_literal_field() {
let node = make_node("any", MemoryTier::Working, 0.5);
let activated = vec![make_activated(node, 0.5)];
let schema = ProjectionSchema {
name: "literal".into(),
description: None,
projection_type: ProjectionType::Relational,
node_filter: NodeFilter::All,
field_mappings: vec![FieldMapping {
field_name: "schema_version".into(),
source: FieldSource::Literal(json!("v1")),
default: None,
}],
};
let result = ProjectionEngine::project(&schema, &activated).unwrap();
assert_eq!(result.rows[0].fields["schema_version"], Value::String("v1".into()));
}
}
@@ -0,0 +1,24 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ProjectionError {
#[error("Projection not found: {0}")]
NotFound(String),
#[error("Projection already exists: {0}")]
AlreadyExists(String),
#[error("Field mapping error: {0}")]
FieldMapping(String),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Engram error: {0}")]
Engram(#[from] engram_core::EngramError),
#[error("Invalid projection schema: {0}")]
InvalidSchema(String),
}
pub type ProjectionResult<T> = Result<T, ProjectionError>;
@@ -0,0 +1,33 @@
/// Engram Projection Layer — schema-as-a-view over the activation surface.
///
/// # The Core Insight
///
/// Engram has no schema. A node has: embedding (semantic identity), content
/// (arbitrary bytes), metadata via tier/type, and salience. Schema is a
/// *projection* — a view imposed on the activation surface at query time.
///
/// The same Engram graph can surface as relational rows, JSON documents,
/// wide-column families, or key-value pairs depending on how you project it.
/// Migrations are free because there is nothing to migrate — you just update
/// the projection.
///
/// # How It Works
///
/// 1. Register a `ProjectionSchema` that describes which nodes are in scope
/// and how to map their fields.
/// 2. At query time, run spreading activation (or use an existing result set).
/// 3. Apply the projection to map `ActivatedNode`s into the projected view.
///
/// The projection is purely a read-time transform. It never modifies the graph.
pub mod engine;
pub mod error;
pub mod registry;
pub mod schema;
pub use engine::ProjectionEngine;
pub use error::ProjectionError;
pub use registry::ProjectionRegistry;
pub use schema::{
FieldMapping, FieldSource, NodeFilter, ProjectedRow, ProjectionResult, ProjectionSchema,
ProjectionType,
};
@@ -0,0 +1,133 @@
/// In-memory registry of named projection schemas.
///
/// The registry is the store of all registered projections. In a running server,
/// one registry instance is shared (behind a Mutex or RwLock). Projections are
/// looked up by name to execute queries.
use std::collections::HashMap;
use crate::error::{ProjectionError, ProjectionResult};
use crate::schema::ProjectionSchema;
/// Holds all registered `ProjectionSchema`s, keyed by name.
#[derive(Default)]
pub struct ProjectionRegistry {
schemas: HashMap<String, ProjectionSchema>,
}
impl ProjectionRegistry {
pub fn new() -> Self {
Self {
schemas: HashMap::new(),
}
}
/// Register a new schema. Fails if one with the same name already exists.
pub fn register(&mut self, schema: ProjectionSchema) -> ProjectionResult<()> {
if self.schemas.contains_key(&schema.name) {
return Err(ProjectionError::AlreadyExists(schema.name.clone()));
}
if schema.name.is_empty() {
return Err(ProjectionError::InvalidSchema("name must not be empty".into()));
}
self.schemas.insert(schema.name.clone(), schema);
Ok(())
}
/// Replace an existing schema (upsert). Creates if not present.
pub fn upsert(&mut self, schema: ProjectionSchema) -> ProjectionResult<()> {
if schema.name.is_empty() {
return Err(ProjectionError::InvalidSchema("name must not be empty".into()));
}
self.schemas.insert(schema.name.clone(), schema);
Ok(())
}
/// Retrieve a schema by name.
pub fn get(&self, name: &str) -> ProjectionResult<&ProjectionSchema> {
self.schemas
.get(name)
.ok_or_else(|| ProjectionError::NotFound(name.to_string()))
}
/// List all schema names.
pub fn list(&self) -> Vec<&ProjectionSchema> {
let mut schemas: Vec<&ProjectionSchema> = self.schemas.values().collect();
schemas.sort_by(|a, b| a.name.cmp(&b.name));
schemas
}
/// Remove a schema by name. Returns true if it existed.
pub fn remove(&mut self, name: &str) -> bool {
self.schemas.remove(name).is_some()
}
/// Number of registered schemas.
pub fn len(&self) -> usize {
self.schemas.len()
}
/// True if no schemas are registered.
pub fn is_empty(&self) -> bool {
self.schemas.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::{NodeFilter, ProjectionType};
fn make_schema(name: &str) -> ProjectionSchema {
ProjectionSchema {
name: name.to_string(),
description: None,
projection_type: ProjectionType::Relational,
node_filter: NodeFilter::All,
field_mappings: vec![],
}
}
#[test]
fn test_register_and_get() {
let mut reg = ProjectionRegistry::new();
reg.register(make_schema("users")).unwrap();
let s = reg.get("users").unwrap();
assert_eq!(s.name, "users");
}
#[test]
fn test_register_duplicate_fails() {
let mut reg = ProjectionRegistry::new();
reg.register(make_schema("events")).unwrap();
assert!(reg.register(make_schema("events")).is_err());
}
#[test]
fn test_upsert_replaces() {
let mut reg = ProjectionRegistry::new();
reg.register(make_schema("s1")).unwrap();
let mut updated = make_schema("s1");
updated.description = Some("updated".into());
reg.upsert(updated).unwrap();
assert_eq!(reg.get("s1").unwrap().description.as_deref(), Some("updated"));
}
#[test]
fn test_list_sorted() {
let mut reg = ProjectionRegistry::new();
reg.register(make_schema("zoo")).unwrap();
reg.register(make_schema("alpha")).unwrap();
reg.register(make_schema("mango")).unwrap();
let names: Vec<&str> = reg.list().iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["alpha", "mango", "zoo"]);
}
#[test]
fn test_remove() {
let mut reg = ProjectionRegistry::new();
reg.register(make_schema("temp")).unwrap();
assert!(reg.remove("temp"));
assert!(!reg.remove("temp"));
assert!(reg.get("temp").is_err());
}
}
@@ -0,0 +1,133 @@
/// Schema types for the projection layer.
///
/// A `ProjectionSchema` defines a named view over the Engram graph.
/// It specifies which nodes are in scope and how to extract fields from them.
use engram_core::types::MemoryTier;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
/// How the projection presents data to the caller.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProjectionType {
/// Nodes as rows; edges become foreign key references.
/// Each row is a flat map of field_name → value.
Relational,
/// Nodes as JSON documents.
/// Fields are nested under a "fields" key; metadata at the top level.
Document,
/// Nodes as column families (node_id → column_name → value).
/// Suitable for wide, sparse schemas.
WideColumn,
/// Simple node_id → content mapping.
/// Ignores field mappings; raw content bytes as base64.
KeyValue,
}
/// Which nodes from the activation result set fall within this projection's scope.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "value")]
pub enum NodeFilter {
/// Include nodes whose node_type matches any of the given strings.
ByType(Vec<String>),
/// Include nodes in any of the given memory tiers.
ByTier(Vec<MemoryTier>),
/// Include nodes whose tier name contains any of the given tag strings
/// (stored in node metadata via content prefix convention).
ByTag(Vec<String>),
/// Include nodes with activation strength >= threshold.
ByActivationThreshold(f32),
/// Include nodes whose salience >= threshold.
BySalience(f32),
/// All of the sub-filters must match (AND).
Combined(Vec<NodeFilter>),
/// Any sub-filter matches (OR).
Any(Vec<NodeFilter>),
/// Pass all nodes through without filtering.
All,
}
/// Where to source a projected field's value.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "value")]
pub enum FieldSource {
/// Extract a value from the node's content, interpreted as JSON, using a dot-path.
/// E.g., "user.name" extracts `{ "user": { "name": "Alice" } }["user"]["name"]`.
ContentJsonPath(String),
/// The node's content, raw, as a UTF-8 string (lossy).
ContentRaw,
/// The node's content as a base64-encoded string.
ContentBase64,
/// The node's unique identifier.
NodeId,
/// The node's type as a string.
NodeType,
/// The node's memory tier as a string.
Tier,
/// The node's current salience score.
Salience,
/// The node's importance (caller-set, stable).
Importance,
/// The activation strength at this node (from spreading activation).
ActivationStrength,
/// The hop count from the nearest seed node.
Hops,
/// The node's creation timestamp (Unix ms).
CreatedAt,
/// The node's last-activated timestamp (Unix ms).
LastActivated,
/// The node's activation count.
ActivationCount,
/// A literal constant value.
Literal(Value),
}
/// One field in a projected row.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldMapping {
/// The name of this field in the projected output.
pub field_name: String,
/// Where to get the value from.
pub source: FieldSource,
/// If the source fails to produce a value, use this fallback. Null means omit.
pub default: Option<Value>,
}
/// A complete schema definition.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectionSchema {
/// Unique name for this projection.
pub name: String,
/// Human-readable description.
pub description: Option<String>,
/// How results are shaped.
pub projection_type: ProjectionType,
/// Which nodes from the activation result are included.
pub node_filter: NodeFilter,
/// How to extract fields from each included node.
pub field_mappings: Vec<FieldMapping>,
}
/// One projected node in a Relational or Document projection.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectedRow {
/// The source node's UUID (always included).
pub node_id: uuid::Uuid,
/// Extracted fields as ordered map field_name → value.
pub fields: HashMap<String, Value>,
}
/// The output of running a projection over an activation result set.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectionResult {
/// The schema that produced this result.
pub schema_name: String,
/// How many nodes from the activation set were in scope.
pub nodes_in_scope: usize,
/// The projected rows.
pub rows: Vec<ProjectedRow>,
/// For KeyValue projection: node_id (string) → content.
pub key_value: Option<HashMap<String, Value>>,
/// For WideColumn projection: node_id → column_name → value.
pub wide_column: Option<HashMap<String, HashMap<String, Value>>>,
}
+4
View File
@@ -12,6 +12,10 @@ path = "src/main.rs"
[dependencies]
engram-core = { path = "../engram-core" }
engram-sync = { path = "../engram-sync" }
engram-projection = { path = "../engram-projection" }
engram-tx = { path = "../engram-tx" }
engram-crypto = { path = "../engram-crypto" }
sled = "0.34"
axum = { version = "0.7", features = ["json"] }
tokio = { version = "1", features = ["full"] }
tower = "0.4"
+38
View File
@@ -36,7 +36,9 @@ use axum::{
Router,
};
use engram_core::EngramDb;
use engram_projection::registry::ProjectionRegistry;
use engram_sync::{SyncConfig, SyncEngine};
use engram_tx::TransactionEngine;
use mime_guess::from_path;
use rust_embed::RustEmbed;
use tokio::time::interval;
@@ -109,6 +111,18 @@ async fn main() -> anyhow::Result<()> {
let db = EngramDb::open(&PathBuf::from(&db_path))?;
let db = Arc::new(Mutex::new(db));
// Transaction engine (separate sled db alongside the main db)
let tx_log_path = format!("{}-tx-log", db_path);
let tx_log_db = sled::open(&tx_log_path)?;
let tx_engine = Arc::new(Mutex::new(TransactionEngine::new(
db.clone(),
tx_log_db,
Some(uuid::Uuid::new_v4()),
)));
// Projection registry
let projection_registry = Arc::new(Mutex::new(ProjectionRegistry::new()));
info!("Database opened at {}", db_path);
// Sync engine — wrapped in tokio::sync::Mutex so it can be held across .await
@@ -164,6 +178,8 @@ async fn main() -> anyhow::Result<()> {
db: db.clone(),
sync_engine: sync_engine.clone(),
api_key: api_key.clone(),
projection_registry,
tx_engine,
});
// Protected sync/swarm routes (auth middleware applied)
@@ -192,6 +208,26 @@ async fn main() -> anyhow::Result<()> {
.route("/decay", post(routes::core::decay))
.route("/consolidate", post(routes::core::consolidate));
// Projection routes (no auth)
let projection_routes = Router::new()
.route("/projections", post(routes::projection::register_projection))
.route("/projections", get(routes::projection::list_projections))
.route(
"/projections/{name}/schema",
get(routes::projection::get_projection_schema),
)
.route(
"/projections/{name}/query",
post(routes::projection::query_projection),
);
// Transaction routes (no auth — add auth layer if needed)
let tx_routes = Router::new()
.route("/tx/apply", post(routes::tx::tx_apply))
.route("/tx/rollback/{command_id}", post(routes::tx::tx_rollback))
.route("/tx/history", get(routes::tx::tx_history))
.route("/tx/chain/{command_id}", get(routes::tx::tx_causal_chain));
let studio_routes = Router::new()
.route("/", get(serve_studio_index))
.route("/studio", get(serve_studio_index))
@@ -201,6 +237,8 @@ async fn main() -> anyhow::Result<()> {
.merge(studio_routes)
.merge(core_routes)
.merge(sync_routes)
.merge(projection_routes)
.merge(tx_routes)
.layer(CorsLayer::permissive())
.with_state(state);
@@ -1,3 +1,5 @@
pub mod core;
pub mod projection;
pub mod sync;
pub mod swarm;
pub mod tx;
@@ -0,0 +1,121 @@
/// Projection API routes.
///
/// POST /projections — register a projection schema
/// GET /projections — list all registered schemas
/// POST /projections/{name}/query — run activation then project results
/// GET /projections/{name}/schema — get a schema definition
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use engram_projection::{
engine::ProjectionEngine,
schema::{ProjectionResult, ProjectionSchema},
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
use crate::state::AppState;
// ── POST /projections ─────────────────────────────────────────────────────────
pub async fn register_projection(
State(state): State<Arc<AppState>>,
Json(schema): Json<ProjectionSchema>,
) -> Result<StatusCode, StatusCode> {
let mut reg = state
.projection_registry
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
reg.upsert(schema)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::CREATED)
}
// ── GET /projections ──────────────────────────────────────────────────────────
#[derive(Serialize)]
pub struct ListProjectionsResponse {
pub schemas: Vec<ProjectionSchema>,
}
pub async fn list_projections(
State(state): State<Arc<AppState>>,
) -> Result<Json<ListProjectionsResponse>, StatusCode> {
let reg = state
.projection_registry
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let schemas = reg.list().into_iter().cloned().collect();
Ok(Json(ListProjectionsResponse { schemas }))
}
// ── GET /projections/{name}/schema ────────────────────────────────────────────
pub async fn get_projection_schema(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
) -> Result<Json<ProjectionSchema>, StatusCode> {
let reg = state
.projection_registry
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
match reg.get(&name) {
Ok(schema) => Ok(Json(schema.clone())),
Err(_) => Err(StatusCode::NOT_FOUND),
}
}
// ── POST /projections/{name}/query ────────────────────────────────────────────
#[derive(Deserialize)]
pub struct ProjectionQueryRequest {
/// Seed node IDs for spreading activation.
pub seeds: Vec<Uuid>,
/// Query embedding for spreading activation.
pub query_embedding: Vec<f32>,
#[serde(default = "default_depth")]
pub max_depth: u8,
#[serde(default = "default_limit")]
pub limit: usize,
}
fn default_depth() -> u8 {
3
}
fn default_limit() -> usize {
20
}
pub async fn query_projection(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
Json(req): Json<ProjectionQueryRequest>,
) -> Result<Json<ProjectionResult>, StatusCode> {
// Load schema
let schema = {
let reg = state
.projection_registry
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
match reg.get(&name) {
Ok(s) => s.clone(),
Err(_) => return Err(StatusCode::NOT_FOUND),
}
};
// Run spreading activation
let activated = {
let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
db.activate(&req.seeds, &req.query_embedding, req.max_depth, req.limit)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
};
// Apply projection
let result = ProjectionEngine::project(&schema, &activated)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(result))
}
@@ -0,0 +1,109 @@
/// Transaction API routes.
///
/// POST /tx/apply — apply a command
/// POST /tx/rollback/{id} — roll back a command
/// GET /tx/history?since={ms} — command history
/// GET /tx/chain/{id} — causal chain for a command
use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use engram_tx::{Command, CommandResult};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
use crate::state::AppState;
// ── POST /tx/apply ────────────────────────────────────────────────────────────
pub async fn tx_apply(
State(state): State<Arc<AppState>>,
Json(cmd): Json<Command>,
) -> Result<Json<CommandResult>, StatusCode> {
let mut engine = state
.tx_engine
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let result = engine
.apply(cmd)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(result))
}
// ── POST /tx/rollback/{command_id} ───────────────────────────────────────────
#[derive(Serialize)]
pub struct RollbackResponse {
pub rollback_command_id: Uuid,
pub status: String,
}
pub async fn tx_rollback(
State(state): State<Arc<AppState>>,
Path(command_id): Path<Uuid>,
) -> Result<Json<RollbackResponse>, StatusCode> {
let mut engine = state
.tx_engine
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let rb = engine
.rollback(command_id)
.map_err(|e| {
tracing::warn!("rollback failed: {}", e);
StatusCode::BAD_REQUEST
})?;
Ok(Json(RollbackResponse {
rollback_command_id: rb.id,
status: format!("{:?}", rb.status),
}))
}
// ── GET /tx/history ───────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct HistoryParams {
pub since: Option<i64>,
}
#[derive(Serialize)]
pub struct HistoryResponse {
pub commands: Vec<Command>,
}
pub async fn tx_history(
State(state): State<Arc<AppState>>,
Query(params): Query<HistoryParams>,
) -> Result<Json<HistoryResponse>, StatusCode> {
let engine = state
.tx_engine
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let since = params.since.unwrap_or(0);
let commands = engine
.history(since)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(HistoryResponse { commands }))
}
// ── GET /tx/chain/{command_id} ────────────────────────────────────────────────
#[derive(Serialize)]
pub struct CausalChainResponse {
pub chain: Vec<Command>,
}
pub async fn tx_causal_chain(
State(state): State<Arc<AppState>>,
Path(command_id): Path<Uuid>,
) -> Result<Json<CausalChainResponse>, StatusCode> {
let engine = state
.tx_engine
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let chain = engine
.causal_chain(command_id)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(CausalChainResponse { chain }))
}
+6
View File
@@ -1,6 +1,8 @@
/// Shared application state for all request handlers.
use engram_core::EngramDb;
use engram_projection::registry::ProjectionRegistry;
use engram_sync::SyncEngine;
use engram_tx::TransactionEngine;
use std::sync::{Arc, Mutex};
pub struct AppState {
@@ -10,4 +12,8 @@ pub struct AppState {
pub sync_engine: Arc<tokio::sync::Mutex<SyncEngine>>,
/// API key used to authenticate incoming sync requests
pub api_key: String,
/// Projection registry — named schema views over the activation surface
pub projection_registry: Arc<Mutex<ProjectionRegistry>>,
/// Transaction engine — append-only command log with rollback support
pub tx_engine: Arc<Mutex<TransactionEngine>>,
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "engram-tx"
version = "0.1.0"
edition = "2021"
description = "Command pattern + rollback-of-rollback transaction engine for Engram"
license = "MIT"
[dependencies]
engram-core = { path = "../engram-core" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
thiserror = "1"
sled = "0.34"
[dev-dependencies]
tempfile = "3"
+166
View File
@@ -0,0 +1,166 @@
/// Command — the first-class mutation unit of Engram's transaction system.
///
/// A command is an immutable record of intent. Once created, its ID, type,
/// idempotency key, and causal parent never change. Status and conflict fields
/// are the only mutable parts, updated as the command moves through its lifecycle.
use engram_core::types::MemoryTier;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
/// The operation a command performs.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum CommandType {
/// Create a new node. `payload` contains the node's fields.
CreateNode,
/// Update an existing node's content/metadata. `payload` contains the new fields.
UpdateNode,
/// Delete a node by UUID.
DeleteNode,
/// Create an edge between two nodes.
CreateEdge,
/// Delete an edge by its from/to pair.
DeleteEdge,
/// Update a node's salience score.
UpdateSalience,
/// Batch import of many nodes/edges.
BulkImport,
/// Roll back a previously applied command. The UUID is the target command's ID.
Rollback(Uuid),
}
/// Lifecycle status of a command.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum CommandStatus {
/// Created but not yet applied.
Pending,
/// Successfully applied to the database.
Applied,
/// Rolled back (a subsequent Rollback command was applied).
RolledBack,
/// Applied but conflicted with another command; conflict details in the log.
Conflicted,
}
/// A command in Engram's append-only mutation log.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Command {
/// Stable unique identifier for this command.
pub id: Uuid,
/// The operation this command performs.
pub command_type: CommandType,
/// The forward operation data — what to do.
pub payload: Value,
/// The inverse operation data — computed eagerly at command creation time.
/// This is the "undo" data, available even if the graph has changed since.
pub inverse_payload: Value,
/// Dedup key. If a command with this idempotency key has already been applied,
/// the new command is a no-op. Prevents double-application in distributed sync.
pub idempotency_key: String,
/// The command that caused this one, if any (forms the causal DAG).
pub causal_parent: Option<Uuid>,
/// Unix milliseconds when this command was created.
pub timestamp_ms: i64,
/// Current lifecycle status.
pub status: CommandStatus,
/// Which peer originated this command (for conflict resolution).
pub peer_id: Option<Uuid>,
/// If conflicted, a human-readable description of the conflict.
pub conflict_note: Option<String>,
}
impl Command {
/// Create a new pending command.
pub fn new(
command_type: CommandType,
payload: Value,
inverse_payload: Value,
idempotency_key: impl Into<String>,
causal_parent: Option<Uuid>,
peer_id: Option<Uuid>,
) -> Self {
Self {
id: Uuid::new_v4(),
command_type,
payload,
inverse_payload,
idempotency_key: idempotency_key.into(),
causal_parent,
timestamp_ms: engram_core::types::now_ms(),
status: CommandStatus::Pending,
peer_id,
conflict_note: None,
}
}
}
/// The result of successfully applying a command.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandResult {
/// The command that was applied.
pub command_id: Uuid,
/// The command's new status.
pub status: CommandStatus,
/// Any entity UUID produced by the operation (e.g., the new node's UUID).
pub produced_id: Option<Uuid>,
/// Whether this was a no-op due to idempotency.
pub was_idempotent: bool,
}
// ── Payload schema helpers ────────────────────────────────────────────────────
/// Payload for CreateNode commands.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateNodePayload {
pub node_id: Uuid,
pub node_type: String,
pub embedding: Vec<f32>,
pub content: Vec<u8>,
pub tier: MemoryTier,
pub importance: f32,
}
/// Payload for UpdateNode commands.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateNodePayload {
pub node_id: Uuid,
pub new_content: Option<Vec<u8>>,
pub new_importance: Option<f32>,
pub new_tier: Option<MemoryTier>,
}
/// Payload for DeleteNode commands.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteNodePayload {
pub node_id: Uuid,
/// The full node is saved at command-creation time so rollback can restore it.
pub snapshot: Value,
}
/// Payload for CreateEdge commands.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateEdgePayload {
pub edge_id: Uuid,
pub from_id: Uuid,
pub to_id: Uuid,
pub relation: String,
pub weight: f32,
}
/// Payload for DeleteEdge commands.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteEdgePayload {
pub edge_id: Uuid,
pub from_id: Uuid,
pub to_id: Uuid,
/// Full edge snapshot for rollback.
pub snapshot: Value,
}
/// Payload for UpdateSalience commands.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateSaliencePayload {
pub node_id: Uuid,
pub new_salience: f32,
pub old_salience: f32,
}
+710
View File
@@ -0,0 +1,710 @@
/// TransactionEngine — applies commands to EngramDb and manages rollback.
///
/// The engine wraps an `EngramDb` and a `CommandLog`. All mutations go through
/// `apply()`. Rollbacks create new inverse commands. Rolling back a rollback
/// re-applies the original — full undo/redo with causal tracking.
use engram_core::types::{Edge, Node, NodeType, RelationType};
use engram_core::EngramDb;
use serde_json::Value;
use uuid::Uuid;
use crate::command::{
Command, CommandResult, CommandStatus, CommandType, CreateEdgePayload, CreateNodePayload,
DeleteEdgePayload, DeleteNodePayload, UpdateNodePayload, UpdateSaliencePayload,
};
use crate::error::{TxError, TxResult};
use crate::log::CommandLog;
pub struct TransactionEngine {
db: std::sync::Arc<std::sync::Mutex<EngramDb>>,
log: CommandLog,
/// Our peer ID for conflict resolution.
peer_id: Option<Uuid>,
}
impl TransactionEngine {
/// Create a new engine backed by the given database and a sled store for the log.
pub fn new(
db: std::sync::Arc<std::sync::Mutex<EngramDb>>,
log_db: sled::Db,
peer_id: Option<Uuid>,
) -> Self {
Self {
db,
log: CommandLog::open(log_db),
peer_id,
}
}
// ── Public API ────────────────────────────────────────────────────────────
/// Apply a command to the database.
///
/// Idempotency: if a command with the same `idempotency_key` has already
/// been applied, this is a no-op and returns the original command's result.
pub fn apply(&mut self, mut cmd: Command) -> TxResult<CommandResult> {
// Idempotency check
if let Some(existing_id) = self.log.check_idempotency(&cmd.idempotency_key)? {
return Ok(CommandResult {
command_id: existing_id,
status: CommandStatus::Applied,
produced_id: None,
was_idempotent: true,
});
}
// Execute the operation
let produced_id = self.execute(&cmd)?;
cmd.status = CommandStatus::Applied;
self.log.write(&cmd)?;
Ok(CommandResult {
command_id: cmd.id,
status: CommandStatus::Applied,
produced_id,
was_idempotent: false,
})
}
/// Roll back a previously applied command.
///
/// Creates and applies a new `Rollback(target_id)` command whose payload
/// is the inverse of the target command. The target command's status is
/// updated to `RolledBack`.
///
/// Returns the new rollback command (useful for tracking / further rollback).
pub fn rollback(&mut self, target_id: Uuid) -> TxResult<Command> {
let target = self.log.require(target_id)?;
if target.status == CommandStatus::RolledBack {
return Err(TxError::InvalidStatus(format!(
"command {} is already rolled back",
target_id
)));
}
if target.status == CommandStatus::Pending {
return Err(TxError::InvalidStatus(
"cannot roll back a pending command".into(),
));
}
// The rollback's payload is the original command's inverse_payload.
// The rollback's inverse_payload is the original command's payload.
// This enables rollback-of-rollback to re-apply the original.
let rollback_key = format!("rollback:{}", target_id);
let rollback_cmd = Command::new(
CommandType::Rollback(target_id),
target.inverse_payload.clone(),
target.payload.clone(),
rollback_key,
Some(target_id),
self.peer_id,
);
// Execute the inverse operation
self.execute_inverse(&target)?;
// Mark the original command as rolled back
let mut updated_target = target;
updated_target.status = CommandStatus::RolledBack;
self.log.write(&updated_target)?;
// Persist the rollback command as Applied
let mut rb = rollback_cmd;
rb.status = CommandStatus::Applied;
self.log.write(&rb)?;
Ok(rb)
}
/// Roll back a rollback — re-applying the original command.
///
/// This is "undo the undo". The rollback_id must be a command of type
/// `Rollback(original_id)`. Rolling it back re-applies `original_id`.
pub fn rollback_rollback(&mut self, rollback_id: Uuid) -> TxResult<Command> {
let rb_cmd = self.log.require(rollback_id)?;
// Verify this IS a rollback command
let original_id = match &rb_cmd.command_type {
CommandType::Rollback(orig) => *orig,
_ => {
return Err(TxError::Invalid(format!(
"command {} is not a Rollback command",
rollback_id
)));
}
};
if rb_cmd.status == CommandStatus::RolledBack {
return Err(TxError::InvalidStatus(
"this rollback has itself already been rolled back".into(),
));
}
// The rollback_rollback's payload is rb_cmd.inverse_payload (= original payload)
// Its inverse is rb_cmd.payload (= original's inverse_payload)
let key = format!("rollback:{}", rollback_id);
let rr_cmd = Command::new(
CommandType::Rollback(rollback_id),
rb_cmd.inverse_payload.clone(),
rb_cmd.payload.clone(),
key,
Some(rollback_id),
self.peer_id,
);
// Re-apply the original command by executing the original's payload
let original = self.log.require(original_id)?;
self.execute(&original)?;
// Mark original as Applied again
let mut orig = original;
orig.status = CommandStatus::Applied;
self.log.write(&orig)?;
// Mark rollback as RolledBack
let mut rb = rb_cmd;
rb.status = CommandStatus::RolledBack;
self.log.write(&rb)?;
// Persist the new re-apply command
let mut rr = rr_cmd;
rr.status = CommandStatus::Applied;
self.log.write(&rr)?;
Ok(rr)
}
/// All commands since a given Unix millisecond timestamp.
pub fn history(&self, since_ms: i64) -> TxResult<Vec<Command>> {
self.log.since(since_ms)
}
/// The causal chain for a given command (root-first).
pub fn causal_chain(&self, command_id: Uuid) -> TxResult<Vec<Command>> {
self.log.causal_chain(command_id)
}
/// Access the command log directly (for server routes).
pub fn log(&self) -> &CommandLog {
&self.log
}
// ── Command execution ─────────────────────────────────────────────────────
fn execute(&self, cmd: &Command) -> TxResult<Option<Uuid>> {
match &cmd.command_type {
CommandType::CreateNode => self.exec_create_node(&cmd.payload),
CommandType::UpdateNode => {
self.exec_update_node(&cmd.payload)?;
Ok(None)
}
CommandType::DeleteNode => {
self.exec_delete_node(&cmd.payload)?;
Ok(None)
}
CommandType::CreateEdge => {
self.exec_create_edge(&cmd.payload)?;
Ok(None)
}
CommandType::DeleteEdge => {
self.exec_delete_edge(&cmd.payload)?;
Ok(None)
}
CommandType::UpdateSalience => {
self.exec_update_salience(&cmd.payload)?;
Ok(None)
}
CommandType::BulkImport => {
self.exec_bulk_import(&cmd.payload)?;
Ok(None)
}
CommandType::Rollback(_) => {
// Rollback commands are executed via execute_inverse on the target
Ok(None)
}
}
}
fn execute_inverse(&self, cmd: &Command) -> TxResult<()> {
// The inverse is described by inverse_payload, which mirrors the command type
match &cmd.command_type {
CommandType::CreateNode => {
// Inverse of CreateNode is DeleteNode
let p: CreateNodePayload = serde_json::from_value(cmd.payload.clone())?;
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
db.delete_node(p.node_id)?;
}
CommandType::DeleteNode => {
// Inverse of DeleteNode is re-creating the node from snapshot
let p: DeleteNodePayload = serde_json::from_value(cmd.payload.clone())?;
let node: Node = serde_json::from_value(p.snapshot)?;
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
db.put_node(node)?;
}
CommandType::UpdateNode => {
// Inverse is applying the inverse_payload (prior state)
self.exec_update_node(&cmd.inverse_payload)?;
}
CommandType::CreateEdge => {
// Inverse of CreateEdge is DeleteEdge
let p: CreateEdgePayload = serde_json::from_value(cmd.payload.clone())?;
self.delete_edge_by_pair(p.from_id, p.to_id)?;
}
CommandType::DeleteEdge => {
// Inverse of DeleteEdge is re-creating the edge from snapshot
let p: DeleteEdgePayload = serde_json::from_value(cmd.payload.clone())?;
let edge: Edge = serde_json::from_value(p.snapshot)?;
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
db.put_edge(edge)?;
}
CommandType::UpdateSalience => {
// Restore old salience
let p: UpdateSaliencePayload = serde_json::from_value(cmd.payload.clone())?;
let restore = UpdateSaliencePayload {
node_id: p.node_id,
new_salience: p.old_salience,
old_salience: p.new_salience,
};
self.exec_update_salience(&serde_json::to_value(restore)?)?;
}
CommandType::BulkImport | CommandType::Rollback(_) => {
// BulkImport rollback would need to individually undo each item.
// For now, we store the inverse_payload as instructions and log the gap.
// TODO: implement fine-grained BulkImport rollback
}
}
Ok(())
}
// ── Operation implementations ─────────────────────────────────────────────
fn exec_create_node(&self, payload: &Value) -> TxResult<Option<Uuid>> {
let p: CreateNodePayload = serde_json::from_value(payload.clone())?;
let node_type = parse_node_type(&p.node_type)?;
let tier = p.tier;
let node = Node::new(node_type, p.embedding, p.content, tier, p.importance)
.with_id(p.node_id);
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
db.put_node(node)?;
Ok(Some(p.node_id))
}
fn exec_update_node(&self, payload: &Value) -> TxResult<()> {
let p: UpdateNodePayload = serde_json::from_value(payload.clone())?;
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
let mut node = db
.get_node(p.node_id)?
.ok_or(TxError::NotFound(p.node_id))?;
if let Some(content) = p.new_content {
node.content = content;
}
if let Some(importance) = p.new_importance {
node.importance = importance.clamp(0.0, 1.0);
}
if let Some(tier) = p.new_tier {
node.tier = tier;
}
db.put_node(node)?;
Ok(())
}
fn exec_delete_node(&self, payload: &Value) -> TxResult<()> {
let p: DeleteNodePayload = serde_json::from_value(payload.clone())?;
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
db.delete_node(p.node_id)?;
Ok(())
}
fn exec_create_edge(&self, payload: &Value) -> TxResult<()> {
let p: CreateEdgePayload = serde_json::from_value(payload.clone())?;
let relation = parse_relation(&p.relation)?;
let edge = Edge::new(p.from_id, p.to_id, relation, p.weight);
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
db.put_edge(edge)?;
Ok(())
}
fn exec_delete_edge(&self, payload: &Value) -> TxResult<()> {
let p: DeleteEdgePayload = serde_json::from_value(payload.clone())?;
self.delete_edge_by_pair(p.from_id, p.to_id)?;
Ok(())
}
fn exec_update_salience(&self, payload: &Value) -> TxResult<()> {
let p: UpdateSaliencePayload = serde_json::from_value(payload.clone())?;
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
let mut node = db
.get_node(p.node_id)?
.ok_or(TxError::NotFound(p.node_id))?;
node.salience = p.new_salience;
db.put_node(node)?;
Ok(())
}
fn exec_bulk_import(&self, payload: &Value) -> TxResult<()> {
let nodes_val = payload.get("nodes").and_then(|v| v.as_array()).cloned().unwrap_or_default();
let edges_val = payload.get("edges").and_then(|v| v.as_array()).cloned().unwrap_or_default();
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
for nv in nodes_val {
let node: Node = serde_json::from_value(nv)?;
db.put_node(node)?;
}
for ev in edges_val {
let edge: Edge = serde_json::from_value(ev)?;
db.put_edge(edge)?;
}
Ok(())
}
fn delete_edge_by_pair(&self, from_id: Uuid, to_id: Uuid) -> TxResult<()> {
// sled stores edges at "edges:from:{from}:{to}" — we remove both directions
let db = self.db.lock().map_err(|_| TxError::Invalid("lock poisoned".into()))?;
// We can't directly call into storage here without re-exposing internals,
// so we use the public scan-and-check approach via get_edges_from
let edges = db.get_edges_from(from_id)?;
for edge in edges {
if edge.to_id == to_id {
// Re-insert a tombstone isn't directly supported — we use the
// internal sled key to delete. Since we don't have direct sled
// access through EngramDb's public API, we rely on the fact that
// put_edge with weight=0 effectively nullifies it, but for proper
// deletion we need access to the underlying store.
//
// We work around this by storing a zero-weight edge with weight=-1
// as a sentinel, or by exposing delete_edge. Since delete_node is
// already exposed, we add a helper. For now, we store the edge with
// weight 0 (marking it inactive) until a delete_edge API is added.
//
// TODO: add db.delete_edge() to engram-core public API.
// For now: overwrite with weight 0 to effectively disable it.
let mut tombstone = edge;
tombstone.weight = 0.0;
db.put_edge(tombstone)?;
break;
}
}
Ok(())
}
}
// ── Command builder helpers ───────────────────────────────────────────────────
/// Build a CreateNode command with eagerly-computed inverse.
pub fn build_create_node_cmd(
node: &Node,
idempotency_key: impl Into<String>,
peer_id: Option<Uuid>,
) -> Command {
let payload = serde_json::to_value(CreateNodePayload {
node_id: node.id,
node_type: node_type_to_str(&node.node_type).to_string(),
embedding: node.embedding.clone(),
content: node.content.clone(),
tier: node.tier.clone(),
importance: node.importance,
})
.unwrap_or(Value::Null);
// Inverse: delete the node we're about to create
let inverse = serde_json::to_value(DeleteNodePayload {
node_id: node.id,
snapshot: serde_json::to_value(node).unwrap_or(Value::Null),
})
.unwrap_or(Value::Null);
Command::new(
CommandType::CreateNode,
payload,
inverse,
idempotency_key,
None,
peer_id,
)
}
/// Build a DeleteNode command with eagerly-computed inverse (snapshot).
pub fn build_delete_node_cmd(
node: &Node,
idempotency_key: impl Into<String>,
peer_id: Option<Uuid>,
) -> Command {
let snapshot = serde_json::to_value(node).unwrap_or(Value::Null);
let payload = serde_json::to_value(DeleteNodePayload {
node_id: node.id,
snapshot: snapshot.clone(),
})
.unwrap_or(Value::Null);
// Inverse: re-create the node from snapshot
let inverse = serde_json::to_value(CreateNodePayload {
node_id: node.id,
node_type: node_type_to_str(&node.node_type).to_string(),
embedding: node.embedding.clone(),
content: node.content.clone(),
tier: node.tier.clone(),
importance: node.importance,
})
.unwrap_or(Value::Null);
Command::new(
CommandType::DeleteNode,
payload,
inverse,
idempotency_key,
None,
peer_id,
)
}
/// Build a CreateEdge command.
pub fn build_create_edge_cmd(
edge: &Edge,
idempotency_key: impl Into<String>,
peer_id: Option<Uuid>,
) -> Command {
let relation_str = relation_to_str(&edge.relation).to_string();
let payload = serde_json::to_value(CreateEdgePayload {
edge_id: edge.id,
from_id: edge.from_id,
to_id: edge.to_id,
relation: relation_str.clone(),
weight: edge.weight,
})
.unwrap_or(Value::Null);
// Inverse: delete the edge
let snapshot = serde_json::to_value(edge).unwrap_or(Value::Null);
let inverse = serde_json::to_value(DeleteEdgePayload {
edge_id: edge.id,
from_id: edge.from_id,
to_id: edge.to_id,
snapshot,
})
.unwrap_or(Value::Null);
Command::new(
CommandType::CreateEdge,
payload,
inverse,
idempotency_key,
None,
peer_id,
)
}
// ── Type string helpers ───────────────────────────────────────────────────────
fn parse_node_type(s: &str) -> TxResult<NodeType> {
match s {
"Memory" => Ok(NodeType::Memory),
"Concept" => Ok(NodeType::Concept),
"Event" => Ok(NodeType::Event),
"Entity" => Ok(NodeType::Entity),
"Process" => Ok(NodeType::Process),
"InternalState" => Ok(NodeType::InternalState),
_ => Err(TxError::Invalid(format!("unknown node type: {}", s))),
}
}
fn node_type_to_str(t: &NodeType) -> &'static str {
match t {
NodeType::Memory => "Memory",
NodeType::Concept => "Concept",
NodeType::Event => "Event",
NodeType::Entity => "Entity",
NodeType::Process => "Process",
NodeType::InternalState => "InternalState",
}
}
fn parse_relation(s: &str) -> TxResult<RelationType> {
match s {
"Supersedes" => Ok(RelationType::Supersedes),
"Causes" => Ok(RelationType::Causes),
"Contains" => Ok(RelationType::Contains),
"References" => Ok(RelationType::References),
"Contradicts" => Ok(RelationType::Contradicts),
"Exemplifies" => Ok(RelationType::Exemplifies),
"Activates" => Ok(RelationType::Activates),
"TemporallyPrecedes" => Ok(RelationType::TemporallyPrecedes),
_ => Err(TxError::Invalid(format!("unknown relation: {}", s))),
}
}
fn relation_to_str(r: &RelationType) -> &'static str {
match r {
RelationType::Supersedes => "Supersedes",
RelationType::Causes => "Causes",
RelationType::Contains => "Contains",
RelationType::References => "References",
RelationType::Contradicts => "Contradicts",
RelationType::Exemplifies => "Exemplifies",
RelationType::Activates => "Activates",
RelationType::TemporallyPrecedes => "TemporallyPrecedes",
}
}
#[cfg(test)]
mod tests {
use super::*;
use engram_core::types::{MemoryTier, Node, NodeType};
use tempfile::TempDir;
fn make_engine() -> (TransactionEngine, TempDir, TempDir) {
let db_dir = TempDir::new().unwrap();
let log_dir = TempDir::new().unwrap();
let db = engram_core::EngramDb::open(db_dir.path()).unwrap();
let db = std::sync::Arc::new(std::sync::Mutex::new(db));
let log_db = sled::open(log_dir.path()).unwrap();
let engine = TransactionEngine::new(db, log_db, None);
(engine, db_dir, log_dir)
}
fn make_node() -> Node {
Node::new(
NodeType::Memory,
vec![1.0, 0.0],
b"test content".to_vec(),
MemoryTier::Semantic,
0.8,
)
}
#[test]
fn test_apply_create_node() {
let (mut engine, _db_dir, _log_dir) = make_engine();
let node = make_node();
let cmd = build_create_node_cmd(&node, "create-test-1", None);
let result = engine.apply(cmd).unwrap();
assert_eq!(result.status, CommandStatus::Applied);
assert!(!result.was_idempotent);
// Node should now exist
let db = engine.db.lock().unwrap();
let found = db.get_node(node.id).unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().content, b"test content");
}
#[test]
fn test_idempotency() {
let (mut engine, _db_dir, _log_dir) = make_engine();
let node = make_node();
let cmd1 = build_create_node_cmd(&node, "idem-key-42", None);
let cmd2 = build_create_node_cmd(&node, "idem-key-42", None);
let r1 = engine.apply(cmd1).unwrap();
let r2 = engine.apply(cmd2).unwrap();
assert!(!r1.was_idempotent);
assert!(r2.was_idempotent);
}
#[test]
fn test_rollback_delete_node() {
let (mut engine, _db_dir, _log_dir) = make_engine();
let node = make_node();
let node_id = node.id;
// First apply create
let create_cmd = build_create_node_cmd(&node, "create-rb-1", None);
let result = engine.apply(create_cmd).unwrap();
// Roll back the creation — should delete the node
let rb = engine.rollback(result.command_id).unwrap();
assert_eq!(rb.status, CommandStatus::Applied);
let db = engine.db.lock().unwrap();
let found = db.get_node(node_id).unwrap();
assert!(found.is_none());
}
#[test]
fn test_rollback_of_rollback() {
let (mut engine, _db_dir, _log_dir) = make_engine();
let node = make_node();
let node_id = node.id;
// Create the node
let create_cmd = build_create_node_cmd(&node, "create-rorb-1", None);
let create_result = engine.apply(create_cmd).unwrap();
// Roll back (delete)
let rb = engine.rollback(create_result.command_id).unwrap();
// Verify it's gone
{
let db = engine.db.lock().unwrap();
assert!(db.get_node(node_id).unwrap().is_none());
}
// Roll back the rollback (re-create)
let _rr = engine.rollback_rollback(rb.id).unwrap();
// Node should be back
let db = engine.db.lock().unwrap();
let found = db.get_node(node_id).unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().content, b"test content");
}
#[test]
fn test_history() {
let (mut engine, _db_dir, _log_dir) = make_engine();
let node = make_node();
let cmd = build_create_node_cmd(&node, "hist-1", None);
engine.apply(cmd).unwrap();
let history = engine.history(0).unwrap();
assert!(!history.is_empty());
assert!(history.iter().any(|c| matches!(c.command_type, CommandType::CreateNode)));
}
#[test]
fn test_causal_chain() {
let (mut engine, _db_dir, _log_dir) = make_engine();
let node = make_node();
let cmd = build_create_node_cmd(&node, "causal-1", None);
let result = engine.apply(cmd).unwrap();
// Roll back (creates a child command with causal_parent = create_cmd.id)
let rb = engine.rollback(result.command_id).unwrap();
let chain = engine.causal_chain(rb.id).unwrap();
// Chain should be [create_cmd, rollback_cmd]
assert_eq!(chain.len(), 2);
assert!(matches!(chain[0].command_type, CommandType::CreateNode));
assert!(matches!(chain[1].command_type, CommandType::Rollback(_)));
}
#[test]
fn test_create_edge_command() {
let (mut engine, _db_dir, _log_dir) = make_engine();
// Create two nodes first
let n1 = make_node();
let n2 = Node::new(
NodeType::Concept,
vec![0.0, 1.0],
b"concept".to_vec(),
MemoryTier::Semantic,
0.5,
);
engine.apply(build_create_node_cmd(&n1, "edge-n1", None)).unwrap();
engine.apply(build_create_node_cmd(&n2, "edge-n2", None)).unwrap();
// Create edge
let edge = Edge::new(n1.id, n2.id, RelationType::References, 0.7);
let edge_cmd = build_create_edge_cmd(&edge, "edge-create-1", None);
let result = engine.apply(edge_cmd).unwrap();
assert_eq!(result.status, CommandStatus::Applied);
// Verify edge exists
let db = engine.db.lock().unwrap();
let edges = db.get_edges_from(n1.id).unwrap();
assert!(!edges.is_empty());
}
}
+31
View File
@@ -0,0 +1,31 @@
use thiserror::Error;
use uuid::Uuid;
#[derive(Debug, Error)]
pub enum TxError {
#[error("Command not found: {0}")]
NotFound(Uuid),
#[error("Command already applied (idempotency key: {0})")]
AlreadyApplied(String),
#[error("Cannot roll back a command in status {0:?}")]
InvalidStatus(String),
#[error("Conflict: {0}")]
Conflict(String),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Storage error: {0}")]
Storage(#[from] sled::Error),
#[error("Engram error: {0}")]
Engram(#[from] engram_core::EngramError),
#[error("Invalid command: {0}")]
Invalid(String),
}
pub type TxResult<T> = Result<T, TxError>;
+29
View File
@@ -0,0 +1,29 @@
/// Engram Transaction Engine — Command pattern with rollback-of-rollback.
///
/// # The Central Insight
///
/// Every mutation to Engram is a Command — a first-class object with:
/// - The operation and its inverse (computed eagerly at command time)
/// - An idempotency key (same key = same command, applied once)
/// - A causal parent (which command caused this one)
/// - A timestamp and originating peer ID
///
/// Commands form a DAG of causality. You can roll back any command. You can
/// roll back a rollback (undo an undo — re-applying the original). The
/// command log is append-only: history is never rewritten.
///
/// # Rollback-of-Rollback
///
/// When you roll back command X, a new `Rollback(X)` command is created and
/// applied. Its `inverse_payload` is X's original `payload`. If you then roll
/// back the rollback, a new `Rollback(rollback_id)` is created, whose effect
/// is to re-apply X. This is a full undo/redo system with causal lineage.
pub mod command;
pub mod engine;
pub mod error;
pub mod log;
pub use command::{Command, CommandResult, CommandStatus, CommandType};
pub use engine::TransactionEngine;
pub use error::TxError;
pub use log::CommandLog;
+151
View File
@@ -0,0 +1,151 @@
/// CommandLog — append-only log of all commands, stored in sled.
///
/// Key schema:
/// cmd:{uuid} → JSON-encoded Command (JSON used because Command contains serde_json::Value)
/// idem:{key} → uuid bytes (idempotency index)
/// cmd_ts:{ms}:{uuid} → uuid bytes (time-ordered scan index)
use sled::Db;
use uuid::Uuid;
use crate::command::Command;
use crate::error::{TxError, TxResult};
pub struct CommandLog {
db: Db,
}
impl CommandLog {
pub fn open(db: Db) -> Self {
Self { db }
}
// ── Write ─────────────────────────────────────────────────────────────────
/// Append a command to the log. Overwrites if the UUID already exists
/// (used for status updates after application).
pub fn write(&self, cmd: &Command) -> TxResult<()> {
let key = cmd_key(cmd.id);
// Use JSON (not bincode) because Command contains serde_json::Value,
// which bincode cannot deserialize (DeserializeAnyNotSupported).
let val = serde_json::to_vec(cmd)?;
self.db.insert(key, val)?;
// Idempotency index: idem:{key} → uuid
let idem_key = idem_key(&cmd.idempotency_key);
self.db.insert(idem_key, cmd.id.as_bytes().to_vec())?;
// Time index: cmd_ts:{ms:016x}:{uuid} → uuid
let ts_key = ts_key(cmd.timestamp_ms, cmd.id);
self.db.insert(ts_key, cmd.id.as_bytes().to_vec())?;
Ok(())
}
// ── Read ──────────────────────────────────────────────────────────────────
pub fn get(&self, id: Uuid) -> TxResult<Option<Command>> {
match self.db.get(cmd_key(id))? {
Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
None => Ok(None),
}
}
pub fn require(&self, id: Uuid) -> TxResult<Command> {
self.get(id)?.ok_or(TxError::NotFound(id))
}
/// Check whether an idempotency key has already been applied.
/// Returns the command UUID if it exists.
pub fn check_idempotency(&self, key: &str) -> TxResult<Option<Uuid>> {
match self.db.get(idem_key(key))? {
Some(bytes) => {
let arr: [u8; 16] = bytes[..16]
.try_into()
.map_err(|_| TxError::Invalid("bad uuid bytes in idem index".into()))?;
Ok(Some(Uuid::from_bytes(arr)))
}
None => Ok(None),
}
}
/// Load all commands created at or after `since_ms`, ordered by timestamp.
pub fn since(&self, since_ms: i64) -> TxResult<Vec<Command>> {
let prefix = format!("cmd_ts:{:016x}:", since_ms);
let mut cmds = Vec::new();
for result in self.db.range(prefix.as_bytes()..) {
let (k, _v) = result?;
// Check the key starts with "cmd_ts:"
if !k.starts_with(b"cmd_ts:") {
break;
}
// Extract uuid from key: cmd_ts:{ms}:{uuid}
let key_str = std::str::from_utf8(&k)
.map_err(|e| TxError::Invalid(e.to_string()))?;
let parts: Vec<&str> = key_str.splitn(3, ':').collect();
if parts.len() < 3 {
continue;
}
// parts[1] = ms (hex), parts[2] = uuid
let ts_hex = parts[1];
let ts = i64::from_str_radix(ts_hex, 16)
.map_err(|e| TxError::Invalid(e.to_string()))?;
if ts < since_ms {
continue;
}
let id: Uuid = parts[2]
.parse()
.map_err(|e: uuid::Error| TxError::Invalid(e.to_string()))?;
if let Some(cmd) = self.get(id)? {
cmds.push(cmd);
}
}
Ok(cmds)
}
/// Load all commands in the store.
pub fn all(&self) -> TxResult<Vec<Command>> {
self.since(0)
}
/// Collect the causal chain leading to a given command (inclusive).
/// Walks `causal_parent` links back to the root.
pub fn causal_chain(&self, id: Uuid) -> TxResult<Vec<Command>> {
let mut chain = Vec::new();
let mut current_id = Some(id);
let mut visited = std::collections::HashSet::new();
while let Some(cid) = current_id {
if visited.contains(&cid) {
break; // cycle guard
}
visited.insert(cid);
match self.get(cid)? {
Some(cmd) => {
current_id = cmd.causal_parent;
chain.push(cmd);
}
None => break,
}
}
// Return root-first (reverse of walk order)
chain.reverse();
Ok(chain)
}
}
// ── Key constructors ──────────────────────────────────────────────────────────
fn cmd_key(id: Uuid) -> Vec<u8> {
format!("cmd:{}", id).into_bytes()
}
fn idem_key(key: &str) -> Vec<u8> {
format!("idem:{}", key).into_bytes()
}
fn ts_key(ts: i64, id: Uuid) -> Vec<u8> {
// Zero-padded hex timestamp for lexicographic ordering
format!("cmd_ts:{:016x}:{}", ts, id).into_bytes()
}
File diff suppressed because it is too large Load Diff