Skip to content

Commit 8977440

Browse files
Bind this crate's own store as a driver (#18 §A3)
Every storage implementation in the workspace was reachable through `MemoryProvider` except the one this crate ships. The TinyCortex adapter binds the bundled engine, `adapters/remote` binds the three hosted services, and `tinymemory-core`'s own `UnifiedMemory` -- the store OpenHuman actually uses -- could only be reached by naming its concrete type. `create_memory` returning `Box<dyn Memory>` is exactly the bypass §A3 names. Nothing structural was missing, and that is worth recording because this was mis-diagnosed as needing the crate split. After §C1 landed, zero files in `core/` name the `tinycortex` crate outside the seam -- §A3's first clause is already satisfied. The 27 files still coupled are `rusqlite`-only, which is §D2's concern, not this one. `UnifiedMemory` already implements the contract's `Memory`, and `MemoryTraitProvider` already wraps any `Memory` into a provider, so this is the one-line composition the adapters have been doing all along. `NAMESPACE_DRIVER_ID` is reserved rather than reusing `tinycortex`: they are two different engines that happen to share a class, and a host that bound one has not bound the other. The name matches what `effective_memory_backend_name` has always returned, so status output does not introduce a third vocabulary for one store. The provider advertises the mandatory three and nothing else, because that is what `Memory` can express. Widening it is §C3's shape of work. Fixes a contract violation the binding immediately exposed ------------------------------------------------------------------ Pointing the conformance suite at this store failed on the first round trip: namespace: session not preserved left: None right: Some("session-1") `memory_docs` has a `session_id` column and `list` selects it; `get` did not, and hardcoded `session_id: None`. The two readers disagreed about the same row -- list a namespace and the session is there, fetch that exact key and it is gone. Silent, and invisible until the store was held to the same standard as the adapters. `get` now selects the column it was already writing. Tests ----- `store::factories_provider_test` mirrors the adapters' `conformance_test.rs`: the full suite, a retention probe, `audit_provider`, the driver id, and registry admission. The retention probe is deliberate -- the suite tolerates a driver that refuses a write, so without it a store that silently kept nothing would pass vacuously. cargo fmt --all -- --check: clean cargo clippy --workspace --all-targets --all-features: clean cargo test --workspace: 1211 passed, 0 failed (core 798 -> 803)
1 parent 5f9052e commit 8977440

7 files changed

Lines changed: 180 additions & 12 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ walkdir = "2"
7070
# `TestHostConfig` — the concrete `MemoryHostConfig` the extracted test suites
7171
# build, since `Config` is a trait object and cannot be `Default`ed.
7272
tinymemory-api = { path = "../api", features = ["test-support"] }
73+
# The driver conformance suite (#18 §E1). A dev-dependency only: it exists to
74+
# hold this crate's own store to the same contract the adapters are held to.
75+
# No cycle — `tinymemory-conformance` depends on `tinymemory-api` alone.
76+
tinymemory-conformance = { path = "../conformance" }
7377
tempfile = "3"
7478
tokio = { version = "1", features = ["test-util"] }
7579

core/src/store/factories.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,71 @@ pub fn create_memory(
353353
create_memory_full(config, &[], None, None, "", workspace_dir)
354354
}
355355

356+
/// Bind this crate's own store as a [`MemoryProvider`], the way every other
357+
/// engine in the workspace is bound.
358+
///
359+
/// Issue #18 §A3/§A5. Until this existed, `tinymemory-core`'s
360+
/// [`UnifiedMemory`](crate::store::UnifiedMemory) was the one storage
361+
/// implementation in the workspace with no route through the contract: the
362+
/// TinyCortex adapter binds the bundled engine, `adapters/remote` binds the
363+
/// three hosted services, and core's own SQLite store was reachable only by
364+
/// naming its concrete type. A host could therefore not treat "the store
365+
/// tinymemory ships with" as a driver, which is the whole premise of the
366+
/// registry — and `create_memory` returning `Box<dyn Memory>` is exactly the
367+
/// bypass §A3 names.
368+
///
369+
/// Nothing structural was missing, which is worth recording because it was
370+
/// mis-diagnosed at one point as needing the crate split: `UnifiedMemory`
371+
/// already implements [`Memory`], and
372+
/// [`MemoryTraitProvider`] already wraps any `Memory` into a provider. This is
373+
/// the one-line composition the adapters have been doing all along.
374+
///
375+
/// # Capabilities
376+
///
377+
/// The returned provider advertises the mandatory three — Core, Recall,
378+
/// Portability — and nothing else, because that is what [`Memory`] can express.
379+
/// `UnifiedMemory` implements more than that internally (trees, chunks,
380+
/// entities), but those reach the caller through their own concrete APIs rather
381+
/// than through an optional family accessor, so advertising them here would be
382+
/// a claim `audit_provider` correctly rejects. Widening that is §C3's shape of
383+
/// work, not this function's.
384+
///
385+
/// # Errors
386+
///
387+
/// Propagates whatever [`create_memory`] fails with — a store that cannot open
388+
/// cannot be bound.
389+
///
390+
/// [`MemoryProvider`]: tinymemory_api::provider::MemoryProvider
391+
/// [`Memory`]: tinymemory_api::traits::Memory
392+
/// [`MemoryTraitProvider`]: tinymemory::mandatory::MemoryTraitProvider
393+
pub fn create_memory_provider(
394+
config: &MemoryConfig,
395+
workspace_dir: &Path,
396+
) -> anyhow::Result<Arc<dyn tinymemory_api::provider::MemoryProvider>> {
397+
let memory = create_memory(config, workspace_dir)?;
398+
Ok(bind_as_provider(memory))
399+
}
400+
401+
/// Wraps an already-built store as a driver under [`NAMESPACE_DRIVER_ID`].
402+
///
403+
/// Split out from [`create_memory_provider`] so a caller that already holds a
404+
/// store — the migration path, tests, a host that built one through
405+
/// [`create_memory_with_local_ai`] — can bind it without constructing a second
406+
/// one. Constructing twice against one directory is the hazard the host-side
407+
/// bypass allowlists exist to refuse, so the seam that avoids it belongs here
408+
/// rather than at each call site.
409+
///
410+
/// [`NAMESPACE_DRIVER_ID`]: tinymemory::registry::NAMESPACE_DRIVER_ID
411+
#[must_use]
412+
pub fn bind_as_provider(
413+
memory: Box<dyn Memory>,
414+
) -> Arc<dyn tinymemory_api::provider::MemoryProvider> {
415+
Arc::new(tinymemory::mandatory::MemoryTraitProvider::new(
416+
Arc::from(memory),
417+
tinymemory::registry::NAMESPACE_DRIVER_ID,
418+
))
419+
}
420+
356421
/// Create a memory instance honouring the unified per-workload embedding
357422
/// provider.
358423
///
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
//! `tinymemory-core`'s own store, held to the driver contract (#18 §A3/§E1).
2+
//!
3+
//! The TinyCortex adapter and the three hosted adapters each have a
4+
//! `conformance_test.rs` asserting they uphold `MemoryProvider`. This crate's
5+
//! own store had no such file, because until `create_memory_provider` there was
6+
//! no way to express it as a driver at all — which is precisely the gap §A3
7+
//! describes. These are the missing equivalents.
8+
9+
#![allow(clippy::expect_used, clippy::panic)]
10+
11+
use std::sync::Arc;
12+
13+
use tinymemory_api::host::MemoryConfig;
14+
use tinymemory_api::provider::{audit_provider, MemoryProvider};
15+
16+
use super::factories::create_memory_provider;
17+
18+
/// Builds a provider over a real store in a throwaway directory.
19+
///
20+
/// A temp dir rather than an in-memory backend on purpose: `UnifiedMemory` is a
21+
/// SQLite store, and a driver that only ever answered from memory would not be
22+
/// the thing hosts actually bind.
23+
fn provider(dir: &std::path::Path) -> Arc<dyn MemoryProvider> {
24+
// The store resolves its embedder through the process-global `EmbeddingHost`
25+
// and refuses to open without one. `init` is the crate's idempotent stub
26+
// installer, so this is the same seam every other core test uses rather
27+
// than a second setup path.
28+
crate::test_seams::init();
29+
create_memory_provider(&MemoryConfig::default(), dir).expect("the bundled store opens")
30+
}
31+
32+
#[tokio::test]
33+
async fn the_core_store_upholds_the_contract() {
34+
let dir = tempfile::tempdir().expect("temp dir");
35+
tinymemory_conformance::assert_provider(provider(dir.path())).await;
36+
}
37+
38+
#[tokio::test]
39+
async fn the_core_store_actually_retains() {
40+
// The conformance suite tolerates a driver that refuses a write; without
41+
// this probe a store that silently retained nothing could pass it
42+
// vacuously. That is not hypothetical — it is how a broken double slipped
43+
// through review once already.
44+
let dir = tempfile::tempdir().expect("temp dir");
45+
assert!(
46+
tinymemory_conformance::retains_writes(provider(dir.path()).as_ref()).await,
47+
"the bundled store reported success and kept nothing"
48+
);
49+
}
50+
51+
#[tokio::test]
52+
async fn it_binds_under_the_reserved_namespace_id() {
53+
let dir = tempfile::tempdir().expect("temp dir");
54+
assert_eq!(
55+
provider(dir.path()).driver_id(),
56+
tinymemory::registry::NAMESPACE_DRIVER_ID,
57+
"the bundled store must not bind under another engine's id"
58+
);
59+
}
60+
61+
#[tokio::test]
62+
async fn its_advertised_capabilities_match_what_it_exposes() {
63+
// `audit_provider` is the honesty check: advertised families must equal
64+
// reachable accessors. Wrapping through `MemoryTraitProvider` derives the
65+
// advertisement from the accessors, so this should hold by construction —
66+
// it runs because that construction lives in another crate.
67+
let dir = tempfile::tempdir().expect("temp dir");
68+
audit_provider(provider(dir.path()).as_ref()).expect("the bundled store is honest");
69+
}
70+
71+
#[tokio::test]
72+
async fn the_registry_admits_it_as_an_embedded_driver() {
73+
use tinymemory::registry::{DriverClass, DriverRegistry, NAMESPACE_DRIVER_ID};
74+
75+
// A reserved id with no admission path would be a driver nothing can bind.
76+
let admitted = DriverRegistry::builtin()
77+
.admit(NAMESPACE_DRIVER_ID, None, Default::default())
78+
.expect("the bundled store is admissible");
79+
assert_eq!(admitted.class, DriverClass::Embedded);
80+
}

core/src/store/memory_trait.rs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -370,9 +370,14 @@ impl Memory for UnifiedMemory {
370370
let ns = UnifiedMemory::sanitize_namespace(namespace);
371371
let key = crate::store::safety::canonical_document_key(key);
372372
let conn = self.conn.lock();
373-
let row: Option<(String, String, String, f64, String, String)> = conn
373+
// `session_id` is selected here for the same reason `list` selects it:
374+
// it is a column on this row, and a `get` that dropped it made the two
375+
// readers disagree about one record. The contract's round-trip
376+
// assertion catches exactly that (`tinymemory_conformance`), and it was
377+
// invisible until #18 §A3 let this store be bound as a driver at all.
378+
let row: Option<(String, String, String, f64, String, String, Option<String>)> = conn
374379
.query_row(
375-
"SELECT document_id, key, content, updated_at, category, taint
380+
"SELECT document_id, key, content, updated_at, category, taint, session_id
376381
FROM memory_docs WHERE namespace = ?1 AND key = ?2 LIMIT 1",
377382
params![ns, key],
378383
|row| {
@@ -383,19 +388,20 @@ impl Memory for UnifiedMemory {
383388
row.get(3)?,
384389
row.get(4)?,
385390
row.get(5)?,
391+
row.get(6)?,
386392
))
387393
},
388394
)
389395
.optional()?;
390396
Ok(row.map(
391-
|(id, key, content, updated_at, category, taint_str)| MemoryEntry {
397+
|(id, key, content, updated_at, category, taint_str, session_id)| MemoryEntry {
392398
id,
393399
key,
394400
content,
395401
namespace: Some(ns.clone()),
396402
category: memory_category_from_stored(&category),
397403
timestamp: timestamp_to_rfc3339(updated_at),
398-
session_id: None,
404+
session_id,
399405
score: None,
400406
taint: crate::MemoryTaint::from_db_str(&taint_str),
401407
},

core/src/store/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ pub mod types;
3939

4040
mod client;
4141
pub mod factories;
42+
#[cfg(test)]
43+
mod factories_provider_test;
4244
/// Golden-workspace fixture seeding / read-back / schema-manifest capture.
4345
///
4446
/// Public only so `tests/memory_golden_fixture_e2e.rs` can drive it; it needs

src/registry/mod.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,23 @@ pub use tinymemory_api::null::NULL_DRIVER_ID;
6161
/// still refuse to bind something *else* under this name.
6262
pub const TINYCORTEX_DRIVER_ID: &str = "tinycortex";
6363

64+
/// The driver id of `tinymemory-core`'s own in-process store.
65+
///
66+
/// Distinct from [`TINYCORTEX_DRIVER_ID`], and the distinction is the point:
67+
/// that one names the bundled TinyCortex engine, this one names
68+
/// `tinymemory_core::store::UnifiedMemory` — a separate SQLite store this
69+
/// workspace implements itself and which, until now, no host could reach
70+
/// through [`MemoryProvider`]. Both are `Embedded`; they are not the same
71+
/// engine, and a host that binds one has not bound the other.
72+
///
73+
/// Named for what `create_memory` has always called this backend
74+
/// (`effective_memory_backend_name` returns `"namespace"`), so the id an
75+
/// operator sees in status matches the name already in the logs rather than
76+
/// introducing a third vocabulary for one store.
77+
///
78+
/// [`MemoryProvider`]: tinymemory_api::provider::MemoryProvider
79+
pub const NAMESPACE_DRIVER_ID: &str = "namespace";
80+
6481
/// Driver id of the native Supermemory HTTP adapter.
6582
pub const SUPERMEMORY_DRIVER_ID: &str = "supermemory";
6683

@@ -176,6 +193,7 @@ impl DriverRegistry {
176193
let mut reserved = BTreeMap::new();
177194
reserved.insert(NULL_DRIVER_ID.to_string(), DriverClass::Null);
178195
reserved.insert(TINYCORTEX_DRIVER_ID.to_string(), DriverClass::Embedded);
196+
reserved.insert(NAMESPACE_DRIVER_ID.to_string(), DriverClass::Embedded);
179197
reserved.insert(SUPERMEMORY_DRIVER_ID.to_string(), DriverClass::External);
180198
reserved.insert(MEM0_DRIVER_ID.to_string(), DriverClass::External);
181199
reserved.insert(COGNEE_DRIVER_ID.to_string(), DriverClass::External);

0 commit comments

Comments
 (0)