diff --git a/.gitignore b/.gitignore index 055bee4..2233b9d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,12 @@ target/ **/*.rs.bk *.pdb +# Stray build artifacts. Two 3.6 MB Linux ELF executables with debug info were +# committed at the repository root before this entry existed; both were produced +# by an ad-hoc build, referenced by nothing, and carried in every clone. +/rust_out +/tmp + # Coverage output /coverage/ *.profraw diff --git a/Cargo.lock b/Cargo.lock index d401f71..b14eeb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1808,6 +1808,7 @@ dependencies = [ "serde", "serde_json", "tinymemory-api", + "tinymemory-conformance", "tokio", ] @@ -1828,6 +1829,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "tinymemory-conformance" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "serde_json", + "tinymemory-api", + "tokio", +] + [[package]] name = "tinymemory-core" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a04e829..a8d1bd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] -members = [".", "api", "core", "adapters/tinycortex", "adapters/remote"] -default-members = [".", "api", "core", "adapters/tinycortex", "adapters/remote"] +members = [".", "api", "core", "adapters/tinycortex", "adapters/remote", "conformance"] +default-members = [".", "api", "core", "adapters/tinycortex", "adapters/remote", "conformance"] # `vendor/` holds engine submodules (tinycortex, tinybus, tinyagents), each of # which is its own workspace with its own lockfile. Same exclusion # `vendor/tinycortex` uses for its own nested vendor directory. @@ -72,6 +72,10 @@ serde = { version = "1", features = ["derive"] } [dev-dependencies] # The mandatory-family tests are async. tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +# The reference driver and the behavioural suite, for the workspace-level +# integration tests. A dev-dependency only: the facade must not carry a test +# harness into a consumer's dependency graph. +tinymemory-conformance = { path = "conformance" } [features] default = [] diff --git a/README.md b/README.md index 6f911dc..941c18b 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,30 @@ src/ │ binds as, and the fail-closed external-driver gate └── mandatory/ the three mandatory capability families, composed once over the `Memory` storage trait +core/ tinymemory-core — the substance: ingestion, the summary + tree, chunk storage, entities, the graph, the diff + ledger, goals, tool-memory, and the Composio sync layer. + The largest crate here by a wide margin, and the one a + real host actually depends on. Unlike `api/` it is not + dependency-light: today it links the TinyCortex engine, + a bundled SQLite, and an HTTP stack unconditionally. adapters/ ├── tinycortex/ the TinyCortex engine seen through the contract └── remote/ native HTTP dialects for Supermemory, Mem0, and Cognee +crates/ +└── tinymemory-module/ the TinyBus loadable-module driver. Excluded from the + workspace on purpose — see the note in `Cargo.toml`. vendor/ ├── tinycortex/ the engine, pinned as a submodule +├── tinyagents/ pinned TinyAgents submodule └── tinybus/ pinned TinyBus submodule ``` +Run `git submodule update --init --recursive` after cloning. Nothing in the +workspace builds without it — `core` names `tinyagents` and `tinycortex` by +path through `vendor/`, so an uninitialized checkout fails at manifest +resolution rather than at compile time, which reads as a confusing error. + ## The contract `MemoryProvider` is an object-safe trait with **three mandatory** capability diff --git a/adapters/remote/Cargo.toml b/adapters/remote/Cargo.toml index 870bd70..d78ff7e 100644 --- a/adapters/remote/Cargo.toml +++ b/adapters/remote/Cargo.toml @@ -3,7 +3,7 @@ name = "tinymemory-remote" publish = false version = "0.1.0" edition = "2021" -rust-version = "1.85" +rust-version = "1.96" license = "MIT" description = "HTTP adapters for self-hosted Supermemory, Mem0, and Cognee" repository = "https://github.com/tinyhumansai/tinymemory" diff --git a/adapters/tinycortex/Cargo.toml b/adapters/tinycortex/Cargo.toml index dc04faf..01bf042 100644 --- a/adapters/tinycortex/Cargo.toml +++ b/adapters/tinycortex/Cargo.toml @@ -6,7 +6,7 @@ name = "tinymemory-tinycortex" publish = false version = "0.1.0" edition = "2021" -rust-version = "1.85" +rust-version = "1.96" license = "MIT" description = "TinyCortex engine adapter for the TinyMemory contract" repository = "https://github.com/tinyhumansai/tinymemory" diff --git a/api/Cargo.toml b/api/Cargo.toml index 673c26e..3d5f32d 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -6,6 +6,7 @@ name = "tinymemory-api" publish = false version = "0.1.1" edition = "2021" +rust-version = "1.96" license = "MIT" repository = "https://github.com/tinyhumansai/tinymemory" description = "Stable public contracts for the TinyMemory memory system" diff --git a/conformance/Cargo.toml b/conformance/Cargo.toml new file mode 100644 index 0000000..809620e --- /dev/null +++ b/conformance/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "tinymemory-conformance" +publish = false +version = "0.1.0" +edition = "2021" +rust-version = "1.96" +license = "MIT" +description = "Behavioural conformance suite every MemoryProvider driver must pass" +repository = "https://github.com/tinyhumansai/tinymemory" + +[dependencies] +# The contract under test. This crate deliberately depends on NOTHING else of +# substance: a conformance suite that pulled in an engine would be unable to +# prove that a driver is interchangeable, because it would already have chosen +# one. In particular it must not reach `tinymemory-core`, which links a bundled +# SQLite and the embedded engine unconditionally (issue #18 §D). +tinymemory-api = { path = "../api" } +# `MemoryProvider` and its families are object-safe async traits. +async-trait = "0.1" +# `ExportRecord::payload` is a `serde_json::Value`, so the portability +# assertions have to construct and compare one. +serde_json = "1" +# The reference driver maps a poisoned lock onto `MemoryError::Other`, which is +# `#[from] anyhow::Error`. +anyhow = "1" + +[dev-dependencies] +# The suite's own tests drive it against the reference drivers. +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } + +[lints.rust] +unsafe_code = "forbid" +missing_docs = "warn" +unreachable_pub = "warn" + +[lints.clippy] +all = { level = "warn", priority = -1 } +unwrap_used = "warn" +expect_used = "warn" diff --git a/conformance/src/lib.rs b/conformance/src/lib.rs new file mode 100644 index 0000000..7bfca70 --- /dev/null +++ b/conformance/src/lib.rs @@ -0,0 +1,51 @@ +//! Behavioural conformance for `MemoryProvider` drivers. +//! +//! TinyMemory's premise is that an engine can be swapped without the host +//! learning anything new. [`audit_provider`](tinymemory_api::provider::audit_provider) +//! checks that a driver's advertised capabilities match its reachable +//! accessors, which proves the *shape* is honest. Nothing checked that two +//! drivers answer the same question the same way — and that is the claim the +//! premise actually rests on. +//! +//! This crate is that check. Hand [`assert_provider`] any bound driver and it +//! drives the contract: the mandatory three families, upsert semantics on +//! `(namespace, key)`, namespace isolation, provenance preservation, recall +//! limits, export pagination, and import round-tripping. +//! +//! ```no_run +//! use std::sync::Arc; +//! use tinymemory_conformance::{assert_provider, InMemoryProvider}; +//! +//! # async fn run() { +//! assert_provider(Arc::new(InMemoryProvider::new())).await; +//! # } +//! ``` +//! +//! # What it deliberately does not depend on +//! +//! Only `tinymemory-api`. A conformance suite that pulled in an engine could +//! not prove interchangeability, because it would already have chosen one — and +//! reaching `tinymemory-core` would drag in a bundled SQLite and the embedded +//! engine besides (issue #18 §D). +//! +//! # Provenance is the sharp one +//! +//! [`assert_taint_is_preserved`] is not a formality. A driver that reads back +//! `Internal` for content stored as `ExternalSync` has laundered external +//! content into internal-trust content, and every policy gate keyed on taint is +//! then silently wrong. That failure is invisible until something acts on it. + +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +pub mod reference; +pub mod suite; + +pub use reference::{InMemoryProvider, REFERENCE_DRIVER_ID}; +pub use suite::{ + assert_awkward_content_round_trips, assert_capability_audit, assert_export_cursor_terminates, + assert_export_import_round_trip, assert_forget_is_idempotent, assert_list_filters_narrow, + assert_namespaces_are_isolated, assert_provider, assert_recall_respects_limit_and_namespace, + assert_store_get_round_trip, assert_taint_is_preserved, + assert_upsert_replaces_rather_than_duplicates, +}; diff --git a/conformance/src/reference/mod.rs b/conformance/src/reference/mod.rs new file mode 100644 index 0000000..16c70ce --- /dev/null +++ b/conformance/src/reference/mod.rs @@ -0,0 +1,304 @@ +//! An in-memory reference driver. +//! +//! This is the driver the suite is calibrated against: the simplest thing that +//! upholds the contract, with no storage engine, no network, and no +//! configuration. It exists for two reasons. +//! +//! First, a conformance suite needs a known-good subject. An assertion that +//! only ever runs against real engines cannot distinguish "the engine is wrong" +//! from "the assertion is wrong"; running it against a driver whose behaviour is +//! obvious by inspection separates those. +//! +//! Second, it documents the contract by example. Everything here is the +//! minimum a driver must do — the `(namespace, key)` upsert, the fail-closed +//! taint handling, the cursor that terminates on `None` rather than on an empty +//! page — so a new engine author has something short to read. +//! +//! It advertises exactly the three mandatory families and leaves every optional +//! accessor at `None`, which is the honest answer for a store with no tree, no +//! graph, and no ingestion pipeline. + +use std::collections::BTreeMap; +use std::sync::Mutex; + +use async_trait::async_trait; +use tinymemory_api::capabilities::Capabilities; +use tinymemory_api::error::MemoryError; +use tinymemory_api::health::MemoryHealth; +use tinymemory_api::provider::{ + ExportPage, ExportRecord, ImportOutcome, MemoryCore, MemoryPortability, MemoryProvider, + MemoryRecall, SourceScope, +}; +use tinymemory_api::recall::OwnedRecallOpts; +use tinymemory_api::types::{MemoryCategory, MemoryEntry, MemoryTaint, NamespaceSummary}; + +/// The driver id this reference binds under. +pub const REFERENCE_DRIVER_ID: &str = "reference"; + +/// How many records one [`MemoryPortability::export_page`] call returns when the +/// caller asks for more than this. Deliberately small so the suite's pagination +/// assertion has something to paginate over without building a large fixture. +const MAX_PAGE: usize = 64; + +/// The row map, keyed by the `(namespace, key)` pair the contract upserts on. +type Rows = BTreeMap<(String, String), MemoryEntry>; + +/// A held lock over [`Rows`]. +type RowGuard<'a> = std::sync::MutexGuard<'a, Rows>; + +/// An in-memory [`MemoryProvider`], keyed exactly as the contract specifies. +#[derive(Debug, Default)] +pub struct InMemoryProvider { + rows: Mutex, +} + +impl InMemoryProvider { + /// Builds an empty reference driver. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Locks the row map, mapping a poisoned lock onto a contract error. + /// + /// A poisoned lock means a previous caller panicked mid-write. Returning an + /// error rather than propagating the panic keeps the driver's failure mode + /// inside the contract, which is what the suite asserts of every driver. + fn rows(&self) -> Result, MemoryError> { + self.rows + .lock() + .map_err(|_| MemoryError::Other(anyhow_poisoned())) + } +} + +/// The one place this crate builds an `anyhow::Error`, so the dependency stays +/// visible rather than scattered. +fn anyhow_poisoned() -> anyhow::Error { + anyhow::anyhow!("reference driver lock poisoned by a panicking caller") +} + +#[async_trait] +impl MemoryCore for InMemoryProvider { + async fn store( + &self, + namespace: &str, + key: &str, + content: &str, + category: MemoryCategory, + session_id: Option<&str>, + taint: MemoryTaint, + ) -> Result<(), MemoryError> { + // Upsert on `(namespace, key)` — the contract's words. A second store at + // the same pair replaces content, category and session, and does not + // create a duplicate. + self.rows()?.insert( + (namespace.to_string(), key.to_string()), + MemoryEntry { + id: format!("{namespace}::{key}"), + key: key.to_string(), + content: content.to_string(), + namespace: Some(namespace.to_string()), + category, + timestamp: "1970-01-01T00:00:00Z".to_string(), + session_id: session_id.map(str::to_owned), + score: None, + // Persisted as given. A driver that re-stamped this would + // launder external content into internal-trust content, which + // is the failure the parameter exists to prevent. + taint, + }, + ); + Ok(()) + } + + async fn get(&self, namespace: &str, key: &str) -> Result, MemoryError> { + Ok(self + .rows()? + .get(&(namespace.to_string(), key.to_string())) + .cloned()) + } + + async fn forget(&self, namespace: &str, key: &str) -> Result { + Ok(self + .rows()? + .remove(&(namespace.to_string(), key.to_string())) + .is_some()) + } + + async fn list( + &self, + namespace: Option<&str>, + category: Option<&MemoryCategory>, + session_id: Option<&str>, + ) -> Result, MemoryError> { + Ok(self + .rows()? + .values() + .filter(|e| namespace.is_none_or(|ns| e.namespace.as_deref() == Some(ns))) + .filter(|e| category.is_none_or(|c| &e.category == c)) + .filter(|e| session_id.is_none_or(|s| e.session_id.as_deref() == Some(s))) + .cloned() + .collect()) + } + + async fn namespaces(&self) -> Result, MemoryError> { + let rows = self.rows()?; + let mut counts: BTreeMap = BTreeMap::new(); + for entry in rows.values() { + if let Some(ns) = entry.namespace.as_deref() { + *counts.entry(ns.to_string()).or_default() += 1; + } + } + Ok(counts + .into_iter() + .map(|(namespace, count)| NamespaceSummary { + namespace, + count, + last_updated: None, + }) + .collect()) + } +} + +#[async_trait] +impl MemoryRecall for InMemoryProvider { + async fn recall( + &self, + query: &str, + limit: usize, + opts: &OwnedRecallOpts, + _scope: Option<&SourceScope>, + ) -> Result, MemoryError> { + let needle = query.to_lowercase(); + Ok(self + .rows()? + .values() + .filter(|e| { + opts.namespace + .as_deref() + .is_none_or(|ns| e.namespace.as_deref() == Some(ns)) + }) + .filter(|e| e.content.to_lowercase().contains(&needle)) + .take(limit) + .cloned() + .collect()) + } +} + +#[async_trait] +impl MemoryPortability for InMemoryProvider { + async fn export_page( + &self, + cursor: Option<&str>, + limit: usize, + ) -> Result { + // The cursor is an offset rendered as a decimal string. A cursor this + // driver did not issue is `Invalid`, not a silent restart from zero — + // silently restarting would make a resumed export duplicate everything + // it had already written. + let offset: usize = match cursor { + None => 0, + Some(raw) => raw + .parse() + .map_err(|_| MemoryError::Invalid(format!("unknown export cursor: {raw}")))?, + }; + let rows = self.rows()?; + let take = limit.clamp(1, MAX_PAGE); + let records: Vec = rows + .values() + .skip(offset) + .take(take) + .map(|e| ExportRecord { + kind: "entry".to_string(), + id: e.id.clone(), + namespace: e.namespace.clone(), + taint: e.taint, + payload: serde_json::json!({ + "key": e.key, + "content": e.content, + "category": e.category.to_string(), + "session_id": e.session_id, + }), + }) + .collect(); + let consumed = offset + records.len(); + // `None` terminates, not an empty page — the contract is explicit that + // an empty `records` is not the terminator. + let next_cursor = (consumed < rows.len()).then(|| consumed.to_string()); + Ok(ExportPage { + records, + next_cursor, + }) + } + + async fn import_records( + &self, + records: Vec, + ) -> Result { + let mut outcome = ImportOutcome::default(); + for record in records { + let (Some(namespace), Some(key)) = ( + record.namespace.clone(), + record + .payload + .get("key") + .and_then(serde_json::Value::as_str) + .map(str::to_owned), + ) else { + // Per-record rejection is reported, not returned as an error: a + // migration must not abort a whole restore over one bad row. + outcome.failed += 1; + outcome + .errors + .push(format!("record {} lacks a namespace or key", record.id)); + continue; + }; + let content = record + .payload + .get("content") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + let category = record + .payload + .get("category") + .and_then(serde_json::Value::as_str) + .and_then(|c| c.parse().ok()) + .unwrap_or(MemoryCategory::Core); + let session_id = record + .payload + .get("session_id") + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + // `record.taint` verbatim — see the note on `store`. + self.store( + &namespace, + &key, + &content, + category, + session_id.as_deref(), + record.taint, + ) + .await?; + outcome.imported += 1; + } + Ok(outcome) + } +} + +#[async_trait] +impl MemoryProvider for InMemoryProvider { + fn driver_id(&self) -> &str { + REFERENCE_DRIVER_ID + } + + fn capabilities(&self) -> Capabilities { + // Exactly what is reachable. Advertising more would fail + // `audit_provider`, which is itself one of the suite's assertions. + Capabilities::mandatory() + } + + async fn health(&self) -> MemoryHealth { + MemoryHealth::Ready + } +} diff --git a/conformance/src/suite/mod.rs b/conformance/src/suite/mod.rs new file mode 100644 index 0000000..194770c --- /dev/null +++ b/conformance/src/suite/mod.rs @@ -0,0 +1,643 @@ +//! The behavioural assertions every driver must satisfy. +//! +//! [`assert_provider`] is the entry point: hand it any bound +//! [`MemoryProvider`] and it drives the contract. Each sub-assertion is also +//! public, so a driver that is mid-implementation can run the parts it claims +//! to support and get a useful failure rather than an unrelated one. +//! +//! # What this is for +//! +//! `audit_provider` already checks that a driver's advertised capabilities +//! match its reachable accessors. That is a structural check: it proves the +//! shape is honest, not that the behaviour is. Nothing before this module +//! checked that two drivers answer the same question the same way, which is +//! precisely the claim "swap the engine" rests on. +//! +//! # Conventions +//! +//! Every assertion namespaces its fixtures under a unique prefix and cleans up +//! after itself, so the suite can run against a driver that already holds data +//! and against a shared live service. Assertions panic with a message naming +//! the driver, because a conformance failure is a bug report and the driver id +//! is the first thing its author needs. + +use std::sync::Arc; + +use tinymemory_api::capabilities::Capability; +use tinymemory_api::provider::{audit_provider, ExportRecord, MemoryProvider}; +use tinymemory_api::recall::OwnedRecallOpts; +use tinymemory_api::types::{MemoryCategory, MemoryTaint}; + +/// Runs every assertion in the suite. +/// +/// # Panics +/// +/// Panics on the first violation, naming the driver and what it did instead. +pub async fn assert_provider(provider: Arc) { + let p = provider.as_ref(); + + // Every driver, retaining or not. + assert_capability_audit(p); + assert_forget_is_idempotent(p).await; + assert_namespaces_are_isolated(p).await; + assert_export_cursor_terminates(p).await; + + // The contract permits a driver that accepts writes and discards them — + // `NullMemoryProvider` is exactly that, and it is a legitimate binding for a + // deployment that wants the ports wired and nothing retained. There is no + // capability that declares it, so the suite probes for it rather than + // assuming, and reports which half it ran. + // + // This is deliberately a probe and not a flag the caller passes: a driver + // that *intends* to retain and silently does not is the failure mode worth + // catching, and a caller-supplied flag would let it through. + if !retains_writes(p).await { + return; + } + + assert_store_get_round_trip(p).await; + assert_upsert_replaces_rather_than_duplicates(p).await; + assert_list_filters_narrow(p).await; + assert_taint_is_preserved(p).await; + assert_recall_respects_limit_and_namespace(p).await; + assert_export_import_round_trip(p).await; + assert_awkward_content_round_trips(p).await; +} + +/// Whether this driver reads back what it stores. +/// +/// `false` means `/dev/null` semantics, which the contract allows. The storage +/// assertions are vacuous for such a driver and [`assert_provider`] skips them; +/// the contract-shape assertions still apply and are not skipped. +/// +/// # Panics +/// +/// Panics if the probe itself errors — accepting a write and then failing the +/// read is a fault, distinct from accepting a write and discarding it. +pub async fn retains_writes(provider: &dyn MemoryProvider) -> bool { + let who = provider.driver_id(); + let ns = ns(provider, "probe"); + provider + .store( + &ns, + "probe", + "probe", + MemoryCategory::Core, + None, + MemoryTaint::Internal, + ) + .await + .unwrap_or_else(|e| panic!("{who}: store failed during the retention probe: {e}")); + let seen = provider + .get(&ns, "probe") + .await + .unwrap_or_else(|e| panic!("{who}: get failed during the retention probe: {e}")) + .is_some(); + cleanup(provider, &ns, &["probe"]).await; + seen +} + +/// The advertised capability set equals the reachable one. +/// +/// # Panics +/// +/// Panics when a driver advertises a family it cannot serve, or serves one it +/// does not advertise. +pub fn assert_capability_audit(provider: &dyn MemoryProvider) { + if let Err(audit) = audit_provider(provider) { + panic!( + "driver `{}` failed its capability audit: {audit}", + provider.driver_id() + ); + } + // The three mandatory families are not optional, whatever else is claimed. + let caps = provider.capabilities(); + for mandatory in Capability::MANDATORY { + assert!( + caps.contains(mandatory), + "driver `{}` does not advertise the mandatory `{}` family", + provider.driver_id(), + mandatory.as_str() + ); + } +} + +/// A stored entry comes back with its fields intact. +/// +/// # Panics +/// +/// Panics on any field that does not survive the round trip. +pub async fn assert_store_get_round_trip(provider: &dyn MemoryProvider) { + let ns = ns(provider, "round-trip"); + provider + .store( + &ns, + "k1", + "the quick brown fox", + MemoryCategory::Core, + Some("session-1"), + MemoryTaint::Internal, + ) + .await + .unwrap_or_else(|e| panic!("{}: store failed: {e}", provider.driver_id())); + + let got = provider + .get(&ns, "k1") + .await + .unwrap_or_else(|e| panic!("{}: get failed: {e}", provider.driver_id())) + .unwrap_or_else(|| panic!("{}: stored entry was not returned", provider.driver_id())); + + let who = provider.driver_id(); + assert_eq!(got.key, "k1", "{who}: key not preserved"); + assert_eq!( + got.content, "the quick brown fox", + "{who}: content not preserved" + ); + assert_eq!( + got.namespace.as_deref(), + Some(ns.as_str()), + "{who}: namespace not preserved" + ); + assert_eq!( + got.category, + MemoryCategory::Core, + "{who}: category not preserved" + ); + assert_eq!( + got.session_id.as_deref(), + Some("session-1"), + "{who}: session not preserved" + ); + + // A key that was never stored is `Ok(None)`, never an error. + let missing = provider + .get(&ns, "never-stored") + .await + .unwrap_or_else(|e| panic!("{who}: get of a missing key errored instead of Ok(None): {e}")); + assert!( + missing.is_none(), + "{who}: get returned an entry for a key never stored" + ); + + cleanup(provider, &ns, &["k1"]).await; +} + +/// Storing twice at one `(namespace, key)` replaces rather than duplicates. +/// +/// # Panics +/// +/// Panics when the second store creates a second row or fails to replace. +pub async fn assert_upsert_replaces_rather_than_duplicates(provider: &dyn MemoryProvider) { + let who = provider.driver_id(); + let ns = ns(provider, "upsert"); + for content in ["first", "second"] { + provider + .store( + &ns, + "same-key", + content, + MemoryCategory::Core, + None, + MemoryTaint::Internal, + ) + .await + .unwrap_or_else(|e| panic!("{who}: store failed: {e}")); + } + let listed = provider + .list(Some(&ns), None, None) + .await + .unwrap_or_else(|e| panic!("{who}: list failed: {e}")); + assert_eq!( + listed.len(), + 1, + "{who}: a re-store at the same key duplicated the row" + ); + assert_eq!( + listed[0].content, "second", + "{who}: the second store did not replace the first" + ); + cleanup(provider, &ns, &["same-key"]).await; +} + +/// `forget` reports whether the entry existed and is safe to call twice. +/// +/// # Panics +/// +/// Panics when a repeat `forget` errors or misreports. +pub async fn assert_forget_is_idempotent(provider: &dyn MemoryProvider) { + let who = provider.driver_id(); + let ns = ns(provider, "forget"); + provider + .store( + &ns, + "k", + "v", + MemoryCategory::Core, + None, + MemoryTaint::Internal, + ) + .await + .unwrap_or_else(|e| panic!("{who}: store failed: {e}")); + + let first = provider + .forget(&ns, "k") + .await + .unwrap_or_else(|e| panic!("{who}: forget failed: {e}")); + let second = provider + .forget(&ns, "k") + .await + .unwrap_or_else(|e| panic!("{who}: repeat forget errored instead of Ok(false): {e}")); + + // A driver that discards writes (the `null` reference) legitimately reports + // `false` both times; what no driver may do is report `true` for an entry it + // does not hold. + assert!( + !second, + "{who}: forget reported true for an already-forgotten key" + ); + if first { + let gone = provider + .get(&ns, "k") + .await + .unwrap_or_else(|e| panic!("{who}: get failed: {e}")); + assert!( + gone.is_none(), + "{who}: forget reported true but the entry is still readable" + ); + } +} + +/// One namespace's entries do not appear in another's. +/// +/// # Panics +/// +/// Panics when a namespace filter leaks an entry from a sibling namespace. +pub async fn assert_namespaces_are_isolated(provider: &dyn MemoryProvider) { + let who = provider.driver_id(); + let (a, b) = (ns(provider, "iso-a"), ns(provider, "iso-b")); + provider + .store( + &a, + "k", + "belongs to a", + MemoryCategory::Core, + None, + MemoryTaint::Internal, + ) + .await + .unwrap_or_else(|e| panic!("{who}: store failed: {e}")); + + let from_b = provider + .list(Some(&b), None, None) + .await + .unwrap_or_else(|e| panic!("{who}: list failed: {e}")); + assert!( + from_b.is_empty(), + "{who}: listing namespace b returned entries from a: {from_b:?}" + ); + + let get_b = provider + .get(&b, "k") + .await + .unwrap_or_else(|e| panic!("{who}: get failed: {e}")); + assert!( + get_b.is_none(), + "{who}: the same key in a sibling namespace resolved to a's entry" + ); + + cleanup(provider, &a, &["k"]).await; +} + +/// Each `list` filter narrows, and `None` everywhere narrows nothing. +/// +/// # Panics +/// +/// Panics when a filter fails to narrow or narrows the wrong rows. +pub async fn assert_list_filters_narrow(provider: &dyn MemoryProvider) { + let who = provider.driver_id(); + let ns = ns(provider, "filters"); + provider + .store( + &ns, + "core-a", + "x", + MemoryCategory::Core, + Some("s1"), + MemoryTaint::Internal, + ) + .await + .unwrap_or_else(|e| panic!("{who}: store failed: {e}")); + provider + .store( + &ns, + "daily-b", + "y", + MemoryCategory::Daily, + Some("s2"), + MemoryTaint::Internal, + ) + .await + .unwrap_or_else(|e| panic!("{who}: store failed: {e}")); + + let all = provider + .list(Some(&ns), None, None) + .await + .unwrap_or_else(|e| panic!("{who}: list failed: {e}")); + assert_eq!(all.len(), 2, "{who}: expected both entries with no filter"); + + let by_category = provider + .list(Some(&ns), Some(&MemoryCategory::Core), None) + .await + .unwrap_or_else(|e| panic!("{who}: list failed: {e}")); + assert_eq!( + by_category.len(), + 1, + "{who}: the category filter did not narrow" + ); + assert_eq!( + by_category[0].key, "core-a", + "{who}: the category filter kept the wrong row" + ); + + let by_session = provider + .list(Some(&ns), None, Some("s2")) + .await + .unwrap_or_else(|e| panic!("{who}: list failed: {e}")); + assert_eq!( + by_session.len(), + 1, + "{who}: the session filter did not narrow" + ); + assert_eq!( + by_session[0].key, "daily-b", + "{who}: the session filter kept the wrong row" + ); + + let summaries = provider + .namespaces() + .await + .unwrap_or_else(|e| panic!("{who}: namespaces failed: {e}")); + let mine = summaries.iter().find(|s| s.namespace == ns); + if let Some(summary) = mine { + assert_eq!(summary.count, 2, "{who}: namespace summary miscounted"); + } + + cleanup(provider, &ns, &["core-a", "daily-b"]).await; +} + +/// Provenance survives a store, and is not re-stamped. +/// +/// This is the security-relevant one. A driver that returns `Internal` for +/// content stored as `ExternalSync` has laundered external content into +/// internal-trust content, and every downstream policy gate keyed on taint is +/// then wrong. +/// +/// # Panics +/// +/// Panics when taint does not survive. +pub async fn assert_taint_is_preserved(provider: &dyn MemoryProvider) { + let who = provider.driver_id(); + let ns = ns(provider, "taint"); + for (key, taint) in [ + ("internal", MemoryTaint::Internal), + ("external", MemoryTaint::ExternalSync), + ] { + provider + .store(&ns, key, "content", MemoryCategory::Core, None, taint) + .await + .unwrap_or_else(|e| panic!("{who}: store failed: {e}")); + if let Some(got) = provider + .get(&ns, key) + .await + .unwrap_or_else(|e| panic!("{who}: get failed: {e}")) + { + assert_eq!( + got.taint, taint, + "{who}: taint was re-stamped on `{key}` — stored {taint:?}, read back {:?}", + got.taint + ); + } + } + cleanup(provider, &ns, &["internal", "external"]).await; +} + +/// `recall` honours its limit and its namespace filter. +/// +/// # Panics +/// +/// Panics when recall exceeds the limit or crosses a namespace. +pub async fn assert_recall_respects_limit_and_namespace(provider: &dyn MemoryProvider) { + let who = provider.driver_id(); + let (mine, theirs) = (ns(provider, "recall-a"), ns(provider, "recall-b")); + let keys = ["r1", "r2", "r3"]; + for key in keys { + provider + .store( + &mine, + key, + "shared needle text", + MemoryCategory::Core, + None, + MemoryTaint::Internal, + ) + .await + .unwrap_or_else(|e| panic!("{who}: store failed: {e}")); + } + provider + .store( + &theirs, + "other", + "shared needle text", + MemoryCategory::Core, + None, + MemoryTaint::Internal, + ) + .await + .unwrap_or_else(|e| panic!("{who}: store failed: {e}")); + + let opts = OwnedRecallOpts { + namespace: Some(mine.clone()), + ..Default::default() + }; + let hits = provider + .recall("needle", 2, &opts, None) + .await + .unwrap_or_else(|e| panic!("{who}: recall failed: {e}")); + assert!( + hits.len() <= 2, + "{who}: recall returned {} hits for a limit of 2", + hits.len() + ); + for hit in &hits { + assert_eq!( + hit.namespace.as_deref(), + Some(mine.as_str()), + "{who}: recall crossed a namespace boundary" + ); + } + + cleanup(provider, &mine, &keys).await; + cleanup(provider, &theirs, &["other"]).await; +} + +/// Exported records re-import with their taint intact. +/// +/// # Panics +/// +/// Panics when a round trip loses a record or its provenance. +pub async fn assert_export_import_round_trip(provider: &dyn MemoryProvider) { + let who = provider.driver_id(); + let ns = ns(provider, "portability"); + provider + .store( + &ns, + "p1", + "portable", + MemoryCategory::Core, + None, + MemoryTaint::ExternalSync, + ) + .await + .unwrap_or_else(|e| panic!("{who}: store failed: {e}")); + + let mut mine: Vec = Vec::new(); + let mut cursor: Option = None; + // Bounded: a driver whose cursor never terminates is a hang, and a hang in + // a conformance suite reads as an infrastructure problem rather than a bug. + for _ in 0..64 { + let page = provider + .export_page(cursor.as_deref(), 32) + .await + .unwrap_or_else(|e| panic!("{who}: export_page failed: {e}")); + mine.extend( + page.records + .iter() + .filter(|r| r.namespace.as_deref() == Some(ns.as_str())) + .cloned(), + ); + match page.next_cursor { + Some(next) => cursor = Some(next), + None => break, + } + } + assert!( + !mine.is_empty(), + "{who}: a stored entry did not appear in any export page" + ); + + let exported = mine + .iter() + .find(|r| r.taint == MemoryTaint::ExternalSync) + .unwrap_or_else(|| panic!("{who}: export dropped the record's ExternalSync taint")); + assert_eq!(exported.taint, MemoryTaint::ExternalSync); + + provider.forget(&ns, "p1").await.ok(); + let outcome = provider + .import_records(mine.clone()) + .await + .unwrap_or_else(|e| panic!("{who}: import failed: {e}")); + assert_eq!( + outcome.failed, 0, + "{who}: import rejected its own export: {:?}", + outcome.errors + ); + if outcome.failed > 0 { + assert!( + !outcome.errors.is_empty(), + "{who}: reported failures with no diagnosable reason" + ); + } + + if let Some(back) = provider.get(&ns, "p1").await.unwrap_or(None) { + assert_eq!( + back.taint, + MemoryTaint::ExternalSync, + "{who}: import re-stamped provenance instead of persisting what it was given" + ); + } + cleanup(provider, &ns, &["p1"]).await; +} + +/// The export cursor terminates on `None`, not on an empty page. +/// +/// # Panics +/// +/// Panics when a driver signals completion with an empty page while still +/// handing back a cursor, or rejects nothing for a cursor it never issued. +pub async fn assert_export_cursor_terminates(provider: &dyn MemoryProvider) { + let who = provider.driver_id(); + let page = provider + .export_page(None, 8) + .await + .unwrap_or_else(|e| panic!("{who}: export_page failed: {e}")); + if page.records.is_empty() { + assert!( + page.next_cursor.is_none(), + "{who}: an empty page handed back a cursor — a caller following it cannot terminate" + ); + } + // A cursor this driver never issued must be refused rather than silently + // restarting the export from the beginning, which would duplicate rows. + let bogus = provider.export_page(Some("!not-a-cursor!"), 8).await; + if let Ok(page) = bogus { + assert!( + page.records.is_empty(), + "{who}: an unrecognised cursor returned records instead of being refused" + ); + } +} + +/// Unicode, empty, and oversized content survive a round trip. +/// +/// # Panics +/// +/// Panics when any of them is mangled. +pub async fn assert_awkward_content_round_trips(provider: &dyn MemoryProvider) { + let who = provider.driver_id(); + let ns = ns(provider, "awkward"); + let cases: [(&str, String); 4] = [ + ("unicode", "héllo — 👋 まいど".to_string()), + ("empty", String::new()), + ("large", "x".repeat(64 * 1024)), + ("newlines", "a\nb\r\nc\0d".to_string()), + ]; + for (key, content) in &cases { + provider + .store( + &ns, + key, + content, + MemoryCategory::Core, + None, + MemoryTaint::Internal, + ) + .await + .unwrap_or_else(|e| panic!("{who}: store of `{key}` failed: {e}")); + if let Some(got) = provider + .get(&ns, key) + .await + .unwrap_or_else(|e| panic!("{who}: get of `{key}` failed: {e}")) + { + assert_eq!(&got.content, content, "{who}: `{key}` content was mangled"); + } + } + let keys: Vec<&str> = cases.iter().map(|(k, _)| *k).collect(); + cleanup(provider, &ns, &keys).await; +} + +/// A namespace unique to this driver and assertion. +/// +/// Prefixed so the suite can run against a live service holding real data +/// without colliding with it, and without needing a teardown it might not get. +fn ns(provider: &dyn MemoryProvider, what: &str) -> String { + format!("tinymemory-conformance/{}/{what}", provider.driver_id()) +} + +/// Best-effort teardown. Failures are ignored: a driver that cannot delete is +/// reported by [`assert_forget_is_idempotent`], and failing here would mask the +/// assertion that actually found the problem. +async fn cleanup(provider: &dyn MemoryProvider, namespace: &str, keys: &[&str]) { + for key in keys { + let _ = provider.forget(namespace, key).await; + } +} diff --git a/conformance/tests/reference_drivers.rs b/conformance/tests/reference_drivers.rs new file mode 100644 index 0000000..fe8be6d --- /dev/null +++ b/conformance/tests/reference_drivers.rs @@ -0,0 +1,47 @@ +//! The suite, run against the drivers this workspace ships as references. +//! +//! Two drivers, for two different reasons. +//! +//! `InMemoryProvider` is the calibration subject: its behaviour is obvious by +//! inspection, so a failure here means the *assertion* is wrong, not the +//! driver. Without it, a suite that only ever ran against real engines could +//! not tell those two cases apart. +//! +//! `NullMemoryProvider` is the opposite end — it accepts writes, discards them, +//! and reads back empty. Running the same assertions against it pins down which +//! parts of the contract a discard-everything driver must still uphold +//! (namespace isolation, an honest `forget`, a terminating export cursor, +//! errors that stay inside `MemoryError`) and which are vacuous for it. +//! A suite that could not run against `null` would be asserting storage rather +//! than the contract. + +use std::sync::Arc; + +use tinymemory_api::null::NullMemoryProvider; +use tinymemory_api::provider::MemoryProvider; +use tinymemory_conformance::{assert_provider, InMemoryProvider}; + +#[tokio::test] +async fn the_in_memory_reference_driver_conforms() { + assert_provider(Arc::new(InMemoryProvider::new())).await; +} + +#[tokio::test] +async fn the_null_driver_conforms() { + assert_provider(Arc::new(NullMemoryProvider::new())).await; +} + +#[tokio::test] +async fn the_reference_driver_advertises_exactly_the_mandatory_families() { + let provider = InMemoryProvider::new(); + let caps = provider.capabilities(); + assert_eq!( + caps.len(), + 3, + "the reference driver must advertise only what it can serve, got {caps:?}" + ); + // Every optional accessor stays `None`, which is what makes the audit pass. + assert!(provider.as_tree().is_none()); + assert!(provider.as_graph().is_none()); + assert!(provider.as_ingest().is_none()); +} diff --git a/core/Cargo.toml b/core/Cargo.toml index 18fd2f7..628cafd 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -6,7 +6,7 @@ name = "tinymemory-core" publish = false version = "0.1.0" edition = "2021" -rust-version = "1.85" +rust-version = "1.96" license = "MIT" description = "The engine-neutral memory subsystem: store, summary tree, sync pipelines, ingestion and recall" repository = "https://github.com/tinyhumansai/tinymemory" diff --git a/core/src/store/namespace_store/events.rs b/core/src/store/namespace_store/events.rs index bb3aca4..c03bf07 100644 --- a/core/src/store/namespace_store/events.rs +++ b/core/src/store/namespace_store/events.rs @@ -475,7 +475,7 @@ fn decode_embedding_row(bytes: &[u8], dim: i64) -> anyhow::Result anyhow::Result Vec { /// [`EMBEDDING_DIM`] (after decoding). The latter guards against rows /// written with a mismatched-provider blob silently passing as valid. pub fn unpack_embedding(b: &[u8]) -> Result> { - if b.len() % 4 != 0 { + if !b.len().is_multiple_of(4) { anyhow::bail!( "embedding blob length {} not a multiple of 4 — corrupt row", b.len() diff --git a/rust_out b/rust_out deleted file mode 100755 index 46d1184..0000000 Binary files a/rust_out and /dev/null differ diff --git a/tests/capability_negotiation.rs b/tests/capability_negotiation.rs new file mode 100644 index 0000000..c43081f --- /dev/null +++ b/tests/capability_negotiation.rs @@ -0,0 +1,197 @@ +//! Capability negotiation: what a host may trust a driver's advertisement for, +//! and what happens when the advertisement is wrong. +//! +//! The contract's premise is that a host negotiates once at bind time and then +//! filters its own surface from the cached set. That is only safe if the set is +//! honest, which is what `audit_provider` is for — so these tests pin both the +//! honest path and the dishonest one. + +// A failing assertion in a test *is* a panic; the crate-wide `expect_used` / +// `unwrap_used` / `panic` lints exist to keep the library from panicking, not +// the tests. Same allowance, and same reasoning, as `src/registry/test.rs`. +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + +use std::sync::Arc; + +use tinymemory::api::capabilities::{Capabilities, Capability}; +use tinymemory::api::health::MemoryHealth; +use tinymemory::api::null::NullMemoryProvider; +use tinymemory::api::provider::{audit_provider, MemoryProvider, MemoryTree}; +use tinymemory_conformance::InMemoryProvider; + +#[test] +fn the_reference_drivers_advertise_exactly_what_they_reach() { + for provider in [ + Arc::new(InMemoryProvider::new()) as Arc, + Arc::new(NullMemoryProvider::new()), + ] { + assert!( + audit_provider(provider.as_ref()).is_ok(), + "driver `{}` failed its audit", + provider.driver_id() + ); + } +} + +#[test] +fn a_host_can_filter_its_surface_from_the_cached_capability_set() { + // This is the whole point of negotiating once: a host reads the set at bind + // time and never asks again, so the set has to answer both directions. + let provider = InMemoryProvider::new(); + let caps = provider.capabilities(); + + for mandatory in Capability::MANDATORY { + assert!( + caps.contains(mandatory), + "{} must be advertised", + mandatory.as_str() + ); + assert!( + provider.provides(mandatory), + "{} must be reachable", + mandatory.as_str() + ); + } + + // An optional family this driver does not serve is absent from the set AND + // unreachable through its accessor. A host that registered an RPC method + // from the set alone would otherwise expose a method that answers errors. + assert!(!caps.contains(Capability::Tree)); + assert!(provider.as_tree().is_none()); + assert!(!provider.provides(Capability::Tree)); +} + +/// A driver that claims a family it cannot serve. +/// +/// Exists to prove the audit catches it. This is the failure mode the audit was +/// written for: the claim is cheap to make and, without a check, only surfaces +/// on the first call — which for a memory family may be days later, on a path +/// nobody is watching. +#[derive(Debug, Default)] +struct LyingProvider(InMemoryProvider); + +#[async_trait::async_trait] +impl tinymemory::api::provider::MemoryCore for LyingProvider { + async fn store( + &self, + namespace: &str, + key: &str, + content: &str, + category: tinymemory::types::MemoryCategory, + session_id: Option<&str>, + taint: tinymemory::types::MemoryTaint, + ) -> Result<(), tinymemory::error::MemoryError> { + self.0 + .store(namespace, key, content, category, session_id, taint) + .await + } + async fn get( + &self, + namespace: &str, + key: &str, + ) -> Result, tinymemory::error::MemoryError> { + self.0.get(namespace, key).await + } + async fn forget( + &self, + namespace: &str, + key: &str, + ) -> Result { + self.0.forget(namespace, key).await + } + async fn list( + &self, + namespace: Option<&str>, + category: Option<&tinymemory::types::MemoryCategory>, + session_id: Option<&str>, + ) -> Result, tinymemory::error::MemoryError> { + self.0.list(namespace, category, session_id).await + } + async fn namespaces( + &self, + ) -> Result, tinymemory::error::MemoryError> { + self.0.namespaces().await + } +} + +#[async_trait::async_trait] +impl tinymemory::api::provider::MemoryRecall for LyingProvider { + async fn recall( + &self, + query: &str, + limit: usize, + opts: &tinymemory::recall::OwnedRecallOpts, + scope: Option<&tinymemory::api::provider::SourceScope>, + ) -> Result, tinymemory::error::MemoryError> { + self.0.recall(query, limit, opts, scope).await + } +} + +#[async_trait::async_trait] +impl tinymemory::api::provider::MemoryPortability for LyingProvider { + async fn export_page( + &self, + cursor: Option<&str>, + limit: usize, + ) -> Result { + self.0.export_page(cursor, limit).await + } + async fn import_records( + &self, + records: Vec, + ) -> Result { + self.0.import_records(records).await + } +} + +#[async_trait::async_trait] +impl MemoryProvider for LyingProvider { + fn driver_id(&self) -> &'static str { + "liar" + } + + fn capabilities(&self) -> Capabilities { + // Claims a summary tree it has no accessor for. + Capabilities::mandatory().with(Capability::Tree) + } + + async fn health(&self) -> MemoryHealth { + MemoryHealth::Ready + } + + // `as_tree` deliberately left at its `None` default. +} + +#[test] +fn a_driver_that_advertises_a_family_it_cannot_serve_fails_the_audit() { + let liar = LyingProvider::default(); + let audit = audit_provider(&liar).expect_err("the audit must catch an overstated capability"); + assert!( + audit.advertised_but_absent.contains(&Capability::Tree), + "the audit should name the family: {audit:?}" + ); + assert!( + audit.present_but_unadvertised.is_empty(), + "nothing was under-advertised here: {audit:?}" + ); +} + +#[test] +fn the_audit_failure_renders_something_an_operator_can_act_on() { + let audit = audit_provider(&LyingProvider::default()) + .expect_err("the audit must fail") + .to_string(); + assert!( + audit.contains("tree"), + "the message should name the family: {audit}" + ); +} + +/// Compile-time proof that `as_tree` returning `Some` is what "reachable" +/// means, so the audit is checking the accessor and not a second declaration. +#[test] +fn reachability_is_the_accessor_not_a_second_declaration() { + let provider = InMemoryProvider::new(); + let tree: Option<&dyn MemoryTree> = provider.as_tree(); + assert!(tree.is_none()); +} diff --git a/tests/driver_selection.rs b/tests/driver_selection.rs new file mode 100644 index 0000000..d73e8cc --- /dev/null +++ b/tests/driver_selection.rs @@ -0,0 +1,161 @@ +//! Driver admission: which ids exist, what class each binds as, and what is +//! refused. +//! +//! Exercises only the public surface of the `tinymemory` facade. +//! +//! # Scope note +//! +//! Issue #18 §E3 describes this file as also asserting that "the bound +//! provider's `driver_id()` matches" the configured id. That step needs +//! `MemoryHostConfig::memory_provider()` to actually select an engine, which is +//! §A5 and does not exist yet — `create_memory_client_with_local_ai` still +//! constructs TinyCortex unconditionally. The issue's own sequencing says to +//! write these tests "against the *current* behaviour first", so this file +//! pins what admission does today. The binding half joins it when §A5 lands, +//! and this file is where it goes. + +// A failing assertion in a test *is* a panic; the crate-wide `expect_used` / +// `unwrap_used` / `panic` lints exist to keep the library from panicking, not +// the tests. Same allowance, and same reasoning, as `src/registry/test.rs`. +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + +use tinymemory::registry::{ + ConfigLabels, DriverClass, DriverEntry, DriverRegistry, COGNEE_DRIVER_ID, MEM0_DRIVER_ID, + SUPERMEMORY_DRIVER_ID, TINYCORTEX_DRIVER_ID, TRUSTED, +}; + +fn labels() -> ConfigLabels<'static> { + ConfigLabels { + section: "[memory]", + drivers: "[memory.drivers]", + driver_entry: "[memory.drivers.]", + } +} + +fn trusted_external() -> DriverEntry<'static> { + DriverEntry { + class: None, + trust_state: TRUSTED, + } +} + +#[test] +fn a_reserved_embedded_id_is_admitted_without_any_config_entry() { + // The embedded default's options live in the host's own config blocks, so + // it must not require a `drivers` entry to be selectable at all. + let admission = DriverRegistry::builtin() + .admit(TINYCORTEX_DRIVER_ID, None, labels()) + .expect("the built-in embedded engine is admitted"); + assert_eq!(admission.id, TINYCORTEX_DRIVER_ID); + assert_eq!(admission.class, DriverClass::Embedded); +} + +#[test] +fn the_null_driver_is_admitted_and_is_class_null() { + let admission = DriverRegistry::builtin() + .admit(tinymemory::registry::NULL_DRIVER_ID, None, labels()) + .expect("the null driver is admitted"); + assert_eq!(admission.class, DriverClass::Null); +} + +#[test] +fn every_reserved_external_id_resolves_to_the_external_class() { + let registry = DriverRegistry::builtin(); + for id in [SUPERMEMORY_DRIVER_ID, MEM0_DRIVER_ID, COGNEE_DRIVER_ID] { + let admission = registry + .admit(id, Some(trusted_external()), labels()) + .unwrap_or_else(|reason| panic!("{id} was refused: {}", reason.reason)); + assert_eq!(admission.class, DriverClass::External, "{id}"); + assert_eq!(admission.id, id); + } +} + +#[test] +fn an_external_driver_without_an_entry_is_refused_fail_closed() { + // The fail-closed half: an external engine needs endpoint, credential and + // trust configuration, so admitting it implicitly would bind an + // out-of-process backend nobody configured. + let reason = DriverRegistry::builtin() + .admit(SUPERMEMORY_DRIVER_ID, None, labels()) + .expect_err("an external driver with no entry must be refused"); + assert_eq!(reason.configured_driver, SUPERMEMORY_DRIVER_ID); + assert!( + reason.reason.contains("external"), + "the refusal should say why: {}", + reason.reason + ); +} + +#[test] +fn an_untrusted_external_driver_is_refused_even_with_an_entry() { + let entry = DriverEntry { + class: None, + trust_state: "untrusted", + }; + let reason = DriverRegistry::builtin() + .admit(SUPERMEMORY_DRIVER_ID, Some(entry), labels()) + .expect_err("trust must be raised explicitly before an external bind"); + assert!( + reason.reason.contains(TRUSTED), + "the refusal should name the value to set: {}", + reason.reason + ); +} + +#[test] +fn a_reserved_id_cannot_have_its_class_overridden_by_config() { + // A reserved id names a fixed implementation. An explicit `class` line may + // confirm it but never override it — otherwise config could run the + // embedded engine under the checks meant for an external one. + let entry = DriverEntry { + class: Some("external"), + trust_state: TRUSTED, + }; + let reason = DriverRegistry::builtin() + .admit(TINYCORTEX_DRIVER_ID, Some(entry), labels()) + .expect_err("a reserved id's class must not be overridable"); + assert!( + reason.reason.contains("built in"), + "the refusal should explain why: {}", + reason.reason + ); +} + +#[test] +fn an_unknown_driver_id_is_refused_rather_than_defaulted() { + let reason = DriverRegistry::builtin() + .admit("not-an-engine", None, labels()) + .expect_err("an unreserved id with no entry must be refused"); + assert_eq!(reason.configured_driver, "not-an-engine"); +} + +#[test] +fn an_empty_driver_id_is_refused() { + let reason = DriverRegistry::builtin() + .admit(" ", None, labels()) + .expect_err("a blank driver id must be refused"); + assert!( + reason.reason.contains("empty"), + "the refusal should name the problem: {}", + reason.reason + ); +} + +#[test] +fn a_config_class_typo_is_echoed_back_to_the_operator() { + // The offending value comes from the host's own config file, not from a + // driver or the network, so echoing it discloses nothing the reader did not + // write — and without it the message cannot point at the line to fix. + let entry = DriverEntry { + class: Some("embeded"), + trust_state: TRUSTED, + }; + let reason = DriverRegistry::builtin() + .admit("some-driver", Some(entry), labels()) + .expect_err("an unparseable class must be refused"); + assert!( + reason.reason.contains("embeded"), + "the refusal should quote the typo: {}", + reason.reason + ); +} diff --git a/tests/null_provider.rs b/tests/null_provider.rs new file mode 100644 index 0000000..83e3a8c --- /dev/null +++ b/tests/null_provider.rs @@ -0,0 +1,117 @@ +//! The `null` driver: the configuration a compiled-out or unconfigured memory +//! subsystem binds to. +//! +//! It has to be genuinely usable, not a placeholder that panics. A host whose +//! memory is switched off still calls the ports, and the difference between +//! "returns empty" and "aborts the process" is the difference between a +//! degraded deployment and an outage. + +// A failing assertion in a test *is* a panic; the crate-wide `expect_used` / +// `unwrap_used` / `panic` lints exist to keep the library from panicking, not +// the tests. Same allowance, and same reasoning, as `src/registry/test.rs`. +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + +use std::sync::Arc; + +use tinymemory::api::capabilities::{Capabilities, Capability}; +use tinymemory::api::null::{NullMemoryProvider, NULL_DRIVER_ID}; +use tinymemory::api::provider::{audit_provider, MemoryProvider}; +use tinymemory::types::{MemoryCategory, MemoryTaint}; + +const NS: &str = "null-provider"; + +#[test] +fn it_identifies_itself_and_passes_its_own_audit() { + let provider = NullMemoryProvider::new(); + assert_eq!(provider.driver_id(), NULL_DRIVER_ID); + assert!(audit_provider(&provider).is_ok()); + assert_eq!(provider.capabilities(), Capabilities::mandatory()); +} + +#[tokio::test] +async fn every_mandatory_method_answers_rather_than_panicking() { + let provider: Arc = Arc::new(NullMemoryProvider::new()); + + provider + .store( + NS, + "k", + "v", + MemoryCategory::Core, + None, + MemoryTaint::Internal, + ) + .await + .expect("store is accepted and discarded, not refused"); + assert!(provider.get(NS, "k").await.expect("get answers").is_none()); + assert!(!provider.forget(NS, "k").await.expect("forget answers")); + assert!(provider + .list(None, None, None) + .await + .expect("list answers") + .is_empty()); + assert!(provider + .namespaces() + .await + .expect("namespaces answers") + .is_empty()); + + let opts = tinymemory::recall::OwnedRecallOpts::default(); + assert!(provider + .recall("anything", 10, &opts, None) + .await + .expect("recall answers") + .is_empty()); + + let page = provider + .export_page(None, 10) + .await + .expect("export answers"); + assert!(page.records.is_empty()); + assert!(page.next_cursor.is_none(), "an empty export must terminate"); + + let outcome = provider + .import_records(Vec::new()) + .await + .expect("import answers"); + assert_eq!(outcome.imported, 0); + assert_eq!(outcome.failed, 0); +} + +#[tokio::test] +async fn it_is_healthy_rather_than_reporting_a_fault() { + // "Memory is switched off" is a configuration, not a failure. Reporting + // unhealthy would make an intentional deployment look like a broken one. + let provider = NullMemoryProvider::new(); + assert_eq!( + provider.health().await, + tinymemory::health::MemoryHealth::Ready + ); +} + +#[test] +fn no_optional_family_is_reachable_and_none_is_advertised() { + let provider = NullMemoryProvider::new(); + for capability in Capability::ALL { + if Capability::MANDATORY.contains(&capability) { + continue; + } + assert!( + !provider.provides(capability), + "`{}` must not be reachable on the null driver", + capability.as_str() + ); + assert!( + !provider.capabilities().contains(capability), + "`{}` must not be advertised on the null driver", + capability.as_str() + ); + } +} + +#[tokio::test] +async fn it_conforms_to_the_behavioural_suite() { + // The contract-shape half of the suite applies to a discard driver exactly + // as it does to a retaining one; the suite skips only the storage half. + tinymemory_conformance::assert_provider(Arc::new(NullMemoryProvider::new())).await; +} diff --git a/tests/taint_end_to_end.rs b/tests/taint_end_to_end.rs new file mode 100644 index 0000000..1841fb4 --- /dev/null +++ b/tests/taint_end_to_end.rs @@ -0,0 +1,208 @@ +//! Provenance, end to end through the public surface. +//! +//! `MemoryTaint` decides whether downstream policy treats content as something +//! the user authored or as something that arrived from outside. A driver that +//! loses it does not fail loudly — it silently reclassifies external content as +//! internal-trust, and every gate keyed on taint is then wrong about everything +//! that passed through. +//! +//! # Scope note +//! +//! Issue #18 §E3 describes this file as asserting that "external content stored +//! through the **sync path** arrives with `ExternalSync` at every engine". The +//! sync layer is welded to the engine today (§1.4) and its rewrite onto the +//! memory API is §B, so there is no engine-neutral sync path to drive yet. +//! +//! What is assertable now is the seam sync will hand to: taint through store, +//! read-back, list, recall, and the export/import round trip. When §B lands, +//! the sync leg is added here rather than in a new file. + +// A failing assertion in a test *is* a panic; the crate-wide `expect_used` / +// `unwrap_used` / `panic` lints exist to keep the library from panicking, not +// the tests. Same allowance, and same reasoning, as `src/registry/test.rs`. +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + +use std::sync::Arc; + +use tinymemory::api::null::NullMemoryProvider; +use tinymemory::api::provider::{MemoryCore, MemoryPortability, MemoryProvider, MemoryRecall}; +use tinymemory::types::{MemoryCategory, MemoryTaint}; +use tinymemory_conformance::InMemoryProvider; + +const NS: &str = "taint-e2e"; + +/// Every driver this workspace ships, so the assertion is "at every engine" +/// rather than "at the one we happened to test". +fn drivers() -> Vec> { + vec![ + Arc::new(InMemoryProvider::new()), + Arc::new(NullMemoryProvider::new()), + ] +} + +#[tokio::test] +async fn external_content_reads_back_as_external_at_every_driver() { + for provider in drivers() { + let who = provider.driver_id(); + provider + .store( + NS, + "from-the-web", + "scraped from a page", + MemoryCategory::Conversation, + None, + MemoryTaint::ExternalSync, + ) + .await + .unwrap_or_else(|e| panic!("{who}: store failed: {e}")); + + // A driver that retains nothing has nothing to reclassify; one that + // retains must hand back what it was given. + if let Some(entry) = provider.get(NS, "from-the-web").await.unwrap_or(None) { + assert_eq!( + entry.taint, + MemoryTaint::ExternalSync, + "{who}: external content was laundered into internal-trust content" + ); + } + let _ = provider.forget(NS, "from-the-web").await; + } +} + +#[tokio::test] +async fn internal_content_is_not_marked_external_by_accident() { + // The inverse error is just as bad in the other direction: over-marking + // makes the gate refuse the company's own material. + for provider in drivers() { + let who = provider.driver_id(); + provider + .store( + NS, + "our-own", + "we decided this", + MemoryCategory::Core, + None, + MemoryTaint::Internal, + ) + .await + .unwrap_or_else(|e| panic!("{who}: store failed: {e}")); + if let Some(entry) = provider.get(NS, "our-own").await.unwrap_or(None) { + assert_eq!( + entry.taint, + MemoryTaint::Internal, + "{who}: internal content was over-marked" + ); + } + let _ = provider.forget(NS, "our-own").await; + } +} + +#[tokio::test] +async fn taint_survives_list_and_recall_not_just_get() { + // `get` is the easy path. A driver that rebuilds entries on the list and + // recall paths can drop provenance on exactly those, which is where a + // policy gate actually reads it. + let provider = InMemoryProvider::new(); + provider + .store( + NS, + "k", + "needle from outside", + MemoryCategory::Core, + None, + MemoryTaint::ExternalSync, + ) + .await + .expect("store"); + + let listed = provider.list(Some(NS), None, None).await.expect("list"); + assert_eq!(listed.len(), 1); + assert_eq!( + listed[0].taint, + MemoryTaint::ExternalSync, + "list dropped provenance" + ); + + let opts = tinymemory::recall::OwnedRecallOpts { + namespace: Some(NS.to_string()), + ..Default::default() + }; + let hits = provider + .recall("needle", 10, &opts, None) + .await + .expect("recall"); + assert_eq!(hits.len(), 1); + assert_eq!( + hits[0].taint, + MemoryTaint::ExternalSync, + "recall dropped provenance" + ); +} + +#[tokio::test] +async fn taint_survives_export_and_re_import() { + // The migration case. An export that drops taint, or an import that + // re-stamps it, turns every restored external record into internal-trust + // content — and a restore is exactly when nobody is watching. + let provider = InMemoryProvider::new(); + provider + .store( + NS, + "moved", + "carried across", + MemoryCategory::Core, + None, + MemoryTaint::ExternalSync, + ) + .await + .expect("store"); + + let page = provider.export_page(None, 64).await.expect("export"); + let record = page + .records + .iter() + .find(|r| r.namespace.as_deref() == Some(NS)) + .expect("the stored record was exported"); + assert_eq!( + record.taint, + MemoryTaint::ExternalSync, + "export dropped provenance" + ); + + let fresh = InMemoryProvider::new(); + let outcome = fresh + .import_records(vec![record.clone()]) + .await + .expect("import"); + assert_eq!(outcome.imported, 1); + assert_eq!(outcome.failed, 0, "{:?}", outcome.errors); + + let restored = fresh + .get(NS, "moved") + .await + .expect("get") + .expect("restored"); + assert_eq!( + restored.taint, + MemoryTaint::ExternalSync, + "import re-stamped provenance instead of persisting what it was given" + ); +} + +#[test] +fn unknown_persisted_taint_values_fail_closed() { + // A corrupt or future column value must read as the *more* restrictive + // state. Failing open here would let an unrecognised row be treated as + // user-authored, which is the one direction that cannot be undone. + assert_eq!(MemoryTaint::from_db_str(""), MemoryTaint::ExternalSync); + assert_eq!( + MemoryTaint::from_db_str("future-value"), + MemoryTaint::ExternalSync + ); + assert_eq!( + MemoryTaint::from_db_str("INTERNAL"), + MemoryTaint::ExternalSync + ); + // Only the exact known spelling reads as internal. + assert_eq!(MemoryTaint::from_db_str("internal"), MemoryTaint::Internal); +} diff --git a/tmp b/tmp deleted file mode 100755 index 9cd4ca7..0000000 Binary files a/tmp and /dev/null differ