From 9d948ed532bd23fa180aa1e2b667d0303704c52f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:27:30 +0300 Subject: [PATCH 001/106] refactor(memory): update memory trait Update the memory trait to improve its structure and maintainability without changing its intended behaviour. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinymemory-core/src/store/memory_trait.rs | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/crates/tinymemory-core/src/store/memory_trait.rs b/crates/tinymemory-core/src/store/memory_trait.rs index c76da77..0d85bfe 100644 --- a/crates/tinymemory-core/src/store/memory_trait.rs +++ b/crates/tinymemory-core/src/store/memory_trait.rs @@ -711,6 +711,135 @@ mod tests { assert!(alpha.last_updated.is_some()); } + /// A `
:` namespace (`tinymemory_bus::namespace`'s + /// convention) must survive `namespace_summaries()` byte-for-byte, even + /// though the on-disk address stays sanitized. Before the + /// `logical_namespace` column, `sanitize_namespace` collapsed `:` to `_` + /// and `namespace_summaries` read that sanitized value straight back out, + /// so every sectioned namespace looked unsectioned to a caller enumerating + /// namespaces. + #[tokio::test] + async fn namespace_summaries_reports_sectioned_namespace_verbatim() { + let (_tmp, mem) = fresh_mem(); + let namespace = "conversation:thread-8f21"; + mem.store(namespace, "k1", "hello there", MemoryCategory::Core, None) + .await + .unwrap(); + + let summaries = mem.namespace_summaries().await.unwrap(); + let found = summaries + .iter() + .find(|s| s.namespace == namespace) + .unwrap_or_else(|| panic!("expected `{namespace}` in {summaries:?}")); + assert_eq!(found.count, 1); + + // The storage address stays sanitized: the sectioned `:` is not a + // valid filesystem character, so the column and the on-disk directory + // must both still use the collapsed form. + let sanitized: String = { + let conn = mem.conn.lock(); + conn.query_row( + "SELECT namespace FROM memory_docs WHERE key = 'k1'", + [], + |row| row.get(0), + ) + .unwrap() + }; + assert_eq!(sanitized, "conversation_thread-8f21"); + assert!( + !sanitized.contains(':'), + "the memory_docs.namespace column must stay path-safe, got {sanitized}" + ); + + let dir = mem.namespace_dir(namespace); + assert!( + !dir.to_string_lossy().contains(':'), + "namespace_dir must never contain ':', got {}", + dir.display() + ); + } + + /// `get`/`forget`/`list`/`recall` must still address a sectioned + /// namespace by its original, unsanitized string — the `logical_namespace` + /// column is purely additive and must not disturb the sanitized lookup + /// path those methods already use. + #[tokio::test] + async fn sectioned_namespace_stays_addressable_by_its_original_string() { + let (_tmp, mem) = fresh_mem(); + let namespace = "conversation:thread-8f21"; + mem.store( + namespace, + "k1", + "we should ship on friday", + MemoryCategory::Core, + None, + ) + .await + .unwrap(); + + let got = mem.get(namespace, "k1").await.unwrap(); + assert_eq!(got.unwrap().content, "we should ship on friday"); + + let listed = mem.list(Some(namespace), None, None).await.unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].key, "k1"); + + let recalled = mem + .recall( + "ship on friday", + 5, + RecallOpts { + namespace: Some(namespace), + min_score: Some(0.0), + ..Default::default() + }, + ) + .await + .unwrap(); + assert!( + recalled.iter().any(|e| e.key == "k1"), + "recall must still find the row via the original sectioned namespace, got {recalled:#?}" + ); + + assert!(mem.forget(namespace, "k1").await.unwrap()); + assert!(mem.get(namespace, "k1").await.unwrap().is_none()); + } + + /// A row written before this migration has `logical_namespace = NULL`. + /// `namespace_summaries` must fall back to the sanitized `namespace` + /// column for those rows rather than erroring or hiding them — the + /// `COALESCE` is the entire backfill story, deliberately, because a + /// sanitized `_` cannot be un-collapsed back into the original delimiter. + #[tokio::test] + async fn namespace_summaries_falls_back_to_sanitized_namespace_when_logical_is_null() { + let (_tmp, mem) = fresh_mem(); + { + let conn = mem.conn.lock(); + conn.execute( + "INSERT INTO memory_docs ( + document_id, namespace, key, title, content, source_type, + priority, tags_json, metadata_json, category, session_id, + created_at, updated_at, markdown_rel_path + ) VALUES (?1, ?2, ?3, ?4, ?5, 'chat', 'medium', '[]', '{}', 'core', NULL, 0.0, 0.0, '')", + rusqlite::params![ + "pre-migration-doc", + "premigration_ns", + "k1", + "title", + "content" + ], + ) + .unwrap(); + } + + let summaries = mem.namespace_summaries().await.unwrap(); + let found = summaries + .iter() + .find(|s| s.namespace == "premigration_ns") + .unwrap_or_else(|| panic!("expected `premigration_ns` in {summaries:?}")); + assert_eq!(found.count, 1); + } + #[tokio::test] async fn legacy_namespace_migration_splits_and_is_idempotent() { use rusqlite::params; From 5e7e263bed9d89d4fd3bfd8069255188c801a077 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:28:20 +0300 Subject: [PATCH 002/106] test(namespace-store): update document store tests Update the namespace store document tests to cover the intended behavior and prevent regressions. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../store/namespace_store/documents_tests.rs | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/crates/tinymemory-core/src/store/namespace_store/documents_tests.rs b/crates/tinymemory-core/src/store/namespace_store/documents_tests.rs index 7b2f7ea..7716c69 100644 --- a/crates/tinymemory-core/src/store/namespace_store/documents_tests.rs +++ b/crates/tinymemory-core/src/store/namespace_store/documents_tests.rs @@ -1257,6 +1257,97 @@ async fn upsert_document_auto_sanitizes_pii_like_namespace() { ); } +/// The `logical_namespace` column carries the delimiter-preserving, +/// PII-**redacted** namespace, not the caller's raw string: the #5164 +/// PII-redaction step must apply to this column exactly as it does to the +/// sanitized `namespace` column, so a national ID never becomes a stored +/// address just because it round-trips through `namespaces()`. +#[tokio::test] +async fn upsert_document_redacts_pii_in_logical_namespace() { + let tmp = TempDir::new().unwrap(); + let memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap(); + + let doc_id = memory + .upsert_document(NamespaceDocumentInput { + namespace: "cliente-RFC-VECJ880326XK4".to_string(), + key: "k1".to_string(), + title: "Title".to_string(), + content: "Body".to_string(), + source_type: "doc".to_string(), + priority: "medium".to_string(), + tags: vec![], + metadata: json!({}), + category: "core".to_string(), + session_id: None, + document_id: None, + taint: crate::MemoryTaint::Internal, + }) + .await + .expect("PII-like namespace should be auto-sanitized, not rejected"); + + let logical_namespace: Option = { + let conn = memory.conn.lock(); + conn.query_row( + "SELECT logical_namespace FROM memory_docs WHERE document_id = ?1", + rusqlite::params![doc_id], + |row| row.get(0), + ) + .unwrap() + }; + let logical_namespace = + logical_namespace.expect("logical_namespace must be populated on a fresh write"); + assert!( + !logical_namespace.contains("VECJ880326XK4"), + "the national ID must not become the stored logical namespace, got: {logical_namespace}" + ); + assert!( + logical_namespace.contains("REDACTED_PII"), + "expected a redaction placeholder, got: {logical_namespace}" + ); +} + +/// A sectioned namespace's `:` delimiter must survive into +/// `logical_namespace` untouched — only the filesystem-hostile character +/// scrub (the sanitized `namespace` column) collapses it. +#[tokio::test] +async fn upsert_document_preserves_colon_in_logical_namespace() { + let tmp = TempDir::new().unwrap(); + let memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap(); + + let doc_id = memory + .upsert_document(NamespaceDocumentInput { + namespace: "conversation:thread-8f21".to_string(), + key: "k1".to_string(), + title: "Title".to_string(), + content: "Body".to_string(), + source_type: "doc".to_string(), + priority: "medium".to_string(), + tags: vec![], + metadata: json!({}), + category: "core".to_string(), + session_id: None, + document_id: None, + taint: crate::MemoryTaint::Internal, + }) + .await + .unwrap(); + + let (namespace, logical_namespace): (String, Option) = { + let conn = memory.conn.lock(); + conn.query_row( + "SELECT namespace, logical_namespace FROM memory_docs WHERE document_id = ?1", + rusqlite::params![doc_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap() + }; + assert_eq!(namespace, "conversation_thread-8f21"); + assert_eq!( + logical_namespace.as_deref(), + Some("conversation:thread-8f21") + ); +} + #[tokio::test] async fn upsert_document_metadata_only_auto_sanitizes_pii_like_key() { let tmp = TempDir::new().unwrap(); From 92cc60c460f587ff8256972627e563dd13af2376 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:28:31 +0300 Subject: [PATCH 003/106] chore(namespace-store): update initialization logic Update namespace store initialization to keep its setup behavior aligned with the current implementation. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/init.rs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/tinymemory-core/src/store/namespace_store/init.rs b/crates/tinymemory-core/src/store/namespace_store/init.rs index c3296f5..a5311a7 100644 --- a/crates/tinymemory-core/src/store/namespace_store/init.rs +++ b/crates/tinymemory-core/src/store/namespace_store/init.rs @@ -630,6 +630,33 @@ mod tests { ); } + /// The `logical_namespace` additive migration must be safe to run on + /// every boot: a fresh install gets the column from `CREATE TABLE`, and + /// reopening the same store must not fail with "duplicate column name". + #[test] + fn logical_namespace_migration_is_idempotent_across_reopen() { + fn has_logical_namespace_column(conn: &Connection) -> bool { + let mut stmt = conn.prepare("PRAGMA table_info(memory_docs)").unwrap(); + stmt.query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .filter_map(Result::ok) + .any(|name| name == "logical_namespace") + } + + let tmp = TempDir::new().unwrap(); + { + let mem = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap(); + assert!( + has_logical_namespace_column(&mem.conn.lock()), + "a fresh install must get logical_namespace from CREATE TABLE" + ); + } + + // Reopening must not fail even though the column already exists. + let mem = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap(); + assert!(has_logical_namespace_column(&mem.conn.lock())); + } + #[test] fn connection_has_busy_timeout_set() { let tmp = TempDir::new().unwrap(); From 96fdcd640b0ca72a1c7172f4f5a580b7407ccb31 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:29:26 +0300 Subject: [PATCH 004/106] chore: update namespace store initialization Update the namespace store initialization logic to reflect the latest implementation changes. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/namespace_store/init.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/init.rs b/crates/tinymemory-core/src/store/namespace_store/init.rs index a5311a7..c07696d 100644 --- a/crates/tinymemory-core/src/store/namespace_store/init.rs +++ b/crates/tinymemory-core/src/store/namespace_store/init.rs @@ -637,10 +637,12 @@ mod tests { fn logical_namespace_migration_is_idempotent_across_reopen() { fn has_logical_namespace_column(conn: &Connection) -> bool { let mut stmt = conn.prepare("PRAGMA table_info(memory_docs)").unwrap(); - stmt.query_map([], |row| row.get::<_, String>(1)) + let found = stmt + .query_map([], |row| row.get::<_, String>(1)) .unwrap() .filter_map(Result::ok) - .any(|name| name == "logical_namespace") + .any(|name| name == "logical_namespace"); + found } let tmp = TempDir::new().unwrap(); From 64f0a97ff974eab4f37762de2be3d2ee7008a41e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:29:56 +0300 Subject: [PATCH 005/106] test: correct cross-session alias test name Rename the test to reflect that custom aliases of the conversation section allow cross-session access. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory/src/sections/test.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinymemory/src/sections/test.rs b/crates/tinymemory/src/sections/test.rs index 0e52771..e7b627a 100644 --- a/crates/tinymemory/src/sections/test.rs +++ b/crates/tinymemory/src/sections/test.rs @@ -563,7 +563,7 @@ async fn in_scope_allows_cross_session_on_the_conversation_section() { } #[tokio::test] -async fn in_scope_rejects_a_custom_alias_of_the_conversation_section_with_cross_session() { +async fn in_scope_allows_a_custom_alias_of_the_conversation_section_with_cross_session() { // `Custom("conversation")` and `MemorySection::Conversation` are the same // view (see `a_custom_section_spelling_a_known_prefix_is_the_same_view`), // so the cross_session guard must normalise before checking — it must From 8183f10804f65cdeec72bd2125a75063a133c870 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:30:02 +0300 Subject: [PATCH 006/106] chore: update namespace store initialization Update the namespace store initialization logic to reflect the intended setup behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/namespace_store/init.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tinymemory-core/src/store/namespace_store/init.rs b/crates/tinymemory-core/src/store/namespace_store/init.rs index c07696d..fc463a9 100644 --- a/crates/tinymemory-core/src/store/namespace_store/init.rs +++ b/crates/tinymemory-core/src/store/namespace_store/init.rs @@ -171,6 +171,7 @@ impl UnifiedMemory { updated_at REAL NOT NULL, markdown_rel_path TEXT NOT NULL, taint TEXT NOT NULL DEFAULT 'internal', + logical_namespace TEXT, UNIQUE(namespace, key) ); CREATE INDEX IF NOT EXISTS idx_memory_docs_ns_updated ON memory_docs(namespace, updated_at DESC); From 99ce5d6a6234304b149632f47a07b84b66a65b5c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:30:13 +0300 Subject: [PATCH 007/106] chore: update namespace store initialization Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/init.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/tinymemory-core/src/store/namespace_store/init.rs b/crates/tinymemory-core/src/store/namespace_store/init.rs index fc463a9..7f0d95b 100644 --- a/crates/tinymemory-core/src/store/namespace_store/init.rs +++ b/crates/tinymemory-core/src/store/namespace_store/init.rs @@ -252,6 +252,19 @@ impl UnifiedMemory { "memory_docs", )?; + // Backfill the `logical_namespace` column on existing `memory_docs` + // databases. Fresh installs get this via the CREATE TABLE above. + // Nullable, no DEFAULT: existing rows get NULL rather than a guessed + // value, because a sanitized `_` cannot be reliably un-collapsed back + // into whatever delimiter it replaced (`namespace_summaries_blocking` + // falls back to the sanitized `namespace` column for those rows via + // `COALESCE`). + apply_additive_migration( + &conn, + "ALTER TABLE memory_docs ADD COLUMN logical_namespace TEXT", + "memory_docs", + )?; + // Create FTS5 episodic tables (episodic_log, episodic_fts, and their // triggers) so the Archivist can call episodic_insert immediately after // the store is initialised. From b150898d185e03b1b8d81b60a477f36eb2a5c739 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:32:17 +0300 Subject: [PATCH 008/106] fix(namespace): preserve logical namespace labels Define delimiter-preserving, PII-redacted namespace identifiers for document upserts so summaries report the logical namespace instead of the path-safe storage form. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/documents.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/tinymemory-core/src/store/namespace_store/documents.rs b/crates/tinymemory-core/src/store/namespace_store/documents.rs index 52b13ab..4b248fe 100644 --- a/crates/tinymemory-core/src/store/namespace_store/documents.rs +++ b/crates/tinymemory-core/src/store/namespace_store/documents.rs @@ -29,6 +29,11 @@ impl UnifiedMemory { input: NamespaceDocumentInput, ) -> Result { let namespace = Self::sanitize_namespace(&input.namespace); + // The logical (delimiter-preserving) namespace, PII-redacted the same + // way `sanitize_namespace` redacts the storage address, so + // `namespace_summaries` can report `conversation:thread-8f21` back + // verbatim instead of the path-safe `conversation_thread-8f21`. + let logical_namespace = safety::canonical_identifier(input.namespace.trim()); let key = input.key.trim().to_string(); if key.is_empty() { return Err("document key cannot be empty".to_string()); @@ -238,6 +243,9 @@ impl UnifiedMemory { input: NamespaceDocumentInput, ) -> Result { let namespace = Self::sanitize_namespace(&input.namespace); + // See `upsert_document_presanitized` — same delimiter-preserving, + // PII-redacted logical namespace, same reason. + let logical_namespace = safety::canonical_identifier(input.namespace.trim()); let key = input.key.trim().to_string(); if key.is_empty() { return Err("document key cannot be empty".to_string()); From 4210ceb4c0747f067c788bc1d8c75363b0bd2b64 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:32:47 +0300 Subject: [PATCH 009/106] fix(store): persist logical namespaces in document upserts Document inserts and updates now write the logical namespace alongside other memory metadata, ensuring it is retained during upserts. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/documents.rs | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/documents.rs b/crates/tinymemory-core/src/store/namespace_store/documents.rs index 4b248fe..fc91770 100644 --- a/crates/tinymemory-core/src/store/namespace_store/documents.rs +++ b/crates/tinymemory-core/src/store/namespace_store/documents.rs @@ -115,9 +115,9 @@ impl UnifiedMemory { .map_err(|e| format!("begin tx: {e}"))?; tx.execute( "INSERT INTO memory_docs - (document_id, namespace, key, title, content, source_type, priority, tags_json, metadata_json, category, session_id, created_at, updated_at, markdown_rel_path, taint) + (document_id, namespace, key, title, content, source_type, priority, tags_json, metadata_json, category, session_id, created_at, updated_at, markdown_rel_path, taint, logical_namespace) VALUES - (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15) + (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16) ON CONFLICT(namespace, key) DO UPDATE SET title = excluded.title, content = excluded.content, @@ -129,7 +129,8 @@ impl UnifiedMemory { session_id = excluded.session_id, updated_at = excluded.updated_at, markdown_rel_path = excluded.markdown_rel_path, - taint = excluded.taint", + taint = excluded.taint, + logical_namespace = excluded.logical_namespace", params![ document_id, namespace, @@ -145,7 +146,8 @@ impl UnifiedMemory { created_at, updated_at, markdown_rel, - input.taint.as_db_str() + input.taint.as_db_str(), + logical_namespace ], ) .map_err(|e| format!("upsert memory_docs: {e}"))?; @@ -316,9 +318,9 @@ impl UnifiedMemory { let conn = self.conn.lock(); conn.execute( "INSERT INTO memory_docs - (document_id, namespace, key, title, content, source_type, priority, tags_json, metadata_json, category, session_id, created_at, updated_at, markdown_rel_path, taint) + (document_id, namespace, key, title, content, source_type, priority, tags_json, metadata_json, category, session_id, created_at, updated_at, markdown_rel_path, taint, logical_namespace) VALUES - (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15) + (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16) ON CONFLICT(namespace, key) DO UPDATE SET title = excluded.title, content = excluded.content, @@ -330,7 +332,8 @@ impl UnifiedMemory { session_id = excluded.session_id, updated_at = excluded.updated_at, markdown_rel_path = excluded.markdown_rel_path, - taint = excluded.taint", + taint = excluded.taint, + logical_namespace = excluded.logical_namespace", params![ document_id, namespace, @@ -346,7 +349,8 @@ impl UnifiedMemory { created_at, updated_at, markdown_rel, - input.taint.as_db_str() + input.taint.as_db_str(), + logical_namespace ], ) .map_err(|e| format!("upsert memory_docs: {e}"))?; From c9005fe89b3caab202fe77d31f6ab7c4538da261 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:33:03 +0300 Subject: [PATCH 010/106] fix(store): use logical namespaces in memory summaries Memory summaries now group and order by the logical namespace when available, while falling back to the sanitized namespace for legacy rows. This preserves existing reporting without guessing delimiters that cannot be reliably reconstructed. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/memory_trait.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/tinymemory-core/src/store/memory_trait.rs b/crates/tinymemory-core/src/store/memory_trait.rs index 0d85bfe..fb3f58c 100644 --- a/crates/tinymemory-core/src/store/memory_trait.rs +++ b/crates/tinymemory-core/src/store/memory_trait.rs @@ -402,11 +402,18 @@ impl UnifiedMemory { conn: &Arc>, ) -> anyhow::Result> { let conn = conn.lock(); + // `COALESCE(logical_namespace, namespace)` is the entire backfill + // story, deliberately: rows written before the `logical_namespace` + // column existed have it NULL and fall back to exactly today's + // sanitized value. A sanitized `_` cannot be reconstructed into + // whatever delimiter it replaced (a scope may legitimately contain + // `_`), so guessing would silently mislabel unrelated namespaces — + // NULL rows simply keep reporting their sanitized address. let mut stmt = conn.prepare( - "SELECT namespace, COUNT(*) AS n, MAX(updated_at) AS last + "SELECT COALESCE(logical_namespace, namespace) AS ns, COUNT(*) AS n, MAX(updated_at) AS last FROM memory_docs - GROUP BY namespace - ORDER BY namespace", + GROUP BY ns + ORDER BY ns", )?; let rows = stmt.query_map([], |row| { let ns: String = row.get(0)?; From d2e65f6af12c8dbd308cb2468045b55e9735e64b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:34:26 +0300 Subject: [PATCH 011/106] build(conformance): add tinymemory-bus dependency Use the shared namespace vocabulary when validating driver namespace output without pulling in an engine, SQLite, or async runtime. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-conformance/Cargo.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/tinymemory-conformance/Cargo.toml b/crates/tinymemory-conformance/Cargo.toml index 286585f..9e67dbc 100644 --- a/crates/tinymemory-conformance/Cargo.toml +++ b/crates/tinymemory-conformance/Cargo.toml @@ -15,6 +15,14 @@ repository = "https://github.com/tinyhumansai/tinymemory" # 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 = "../tinymemory-api" } +# Owns the `
:` namespace convention (`Namespace`, +# `MemorySection`) that `assert_namespaces_preserve_their_section` parses a +# driver's `namespaces()` output with. Safe alongside the "nothing of +# substance" rule above: like `tinymemory-api`, it depends on no engine, no +# SQLite, and no async runtime, so pulling it in still proves nothing about +# whether a driver is interchangeable — it is a shared vocabulary, not a +# storage choice. +tinymemory-bus = { path = "../tinymemory-bus" } # `MemoryProvider` and its families are object-safe async traits. async-trait = "0.1" # `ExportRecord::payload` is a `serde_json::Value`, so the portability From 3566142bdbeae8455d9866b12a9ff12f53cedcd3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:34:41 +0300 Subject: [PATCH 012/106] chore(suite): import the namespace type Add the `Namespace` type import to support namespace-related conformance suite code. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-conformance/src/suite/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tinymemory-conformance/src/suite/mod.rs b/crates/tinymemory-conformance/src/suite/mod.rs index f6d3fa6..4cf06ee 100644 --- a/crates/tinymemory-conformance/src/suite/mod.rs +++ b/crates/tinymemory-conformance/src/suite/mod.rs @@ -28,6 +28,7 @@ use tinymemory_api::error::MemoryError; use tinymemory_api::provider::{audit_provider, ExportRecord, MemoryProvider}; use tinymemory_api::recall::OwnedRecallOpts; use tinymemory_api::types::{MemoryCategory, MemoryTaint}; +use tinymemory_bus::namespace::Namespace; /// Runs every assertion in the suite. /// From 18ac14d3f9a5fd4b2877dd1dfdd3ef1bf5fe222e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:34:56 +0300 Subject: [PATCH 013/106] test(conformance): check section preservation in namespaces Add a regression test ensuring sectioned namespaces returned by `namespaces()` retain their original section after storage. This guards against drivers re-addressing logical namespaces when sanitizing their on-disk storage paths. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinymemory-conformance/src/suite/mod.rs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/crates/tinymemory-conformance/src/suite/mod.rs b/crates/tinymemory-conformance/src/suite/mod.rs index 4cf06ee..75cffe4 100644 --- a/crates/tinymemory-conformance/src/suite/mod.rs +++ b/crates/tinymemory-conformance/src/suite/mod.rs @@ -483,6 +483,70 @@ pub async fn assert_recall_respects_limit_and_namespace(provider: &dyn MemoryPro cleanup(provider, &theirs, &["other"]).await; } +/// `namespaces()` reports a sectioned namespace back under the same +/// [`tinymemory_bus::namespace::MemorySection`] the caller wrote it in. +/// +/// This is the regression the unified SQLite store's own storage-address +/// sanitiser taught us to check for: a driver whose on-disk address collapses +/// `:` to `_` (a real filesystem constraint) must still report the *logical* +/// namespace back through `namespaces()`, or `conversation:thread-8f21` +/// silently re-addresses out of the `conversation` section and every caller +/// enumerating a section's scopes sees nothing, even though the write itself +/// succeeded. +/// +/// # Panics +/// +/// Panics when no reported namespace parses to the same section as the one +/// that was written. +pub async fn assert_namespaces_preserve_their_section(provider: &dyn MemoryProvider) { + let who = provider.driver_id(); + let namespace = format!("conversation:{}", ns(provider, "section-thread")); + let written = Namespace::parse(&namespace) + .unwrap_or_else(|e| panic!("{who}: test fixture `{namespace}` failed to parse: {e}")); + + provider + .store( + &namespace, + "k", + "content", + MemoryCategory::Core, + None, + MemoryTaint::Internal, + ) + .await + .unwrap_or_else(|e| panic!("{who}: store failed: {e}")); + + let summaries = provider + .namespaces() + .await + .unwrap_or_else(|e| panic!("{who}: namespaces() failed: {e}")); + + let matching_scope = summaries.iter().find_map(|summary| { + Namespace::parse(&summary.namespace) + .ok() + .filter(|parsed| parsed.scope() == written.scope()) + }); + + match matching_scope { + Some(parsed) => assert_eq!( + parsed.section(), + written.section(), + "{who}: wrote `{namespace}` under section {:?}, but namespaces() reported \ + its scope back under section {:?} instead — a driver must not silently \ + re-address a sectioned namespace out of its section", + written.section(), + parsed.section(), + ), + None => panic!( + "{who}: namespaces() did not report any namespace with scope `{}` after \ + storing `{namespace}`; got {summaries:?}", + written.scope() + ), + } + + cleanup(provider, &namespace, &["k"]).await; +} + /// Exported records re-import with their taint intact. /// /// # Panics From bcb2a5016af38d1161bf065492abab19953ad55d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:35:04 +0300 Subject: [PATCH 014/106] test(conformance): run namespace section preservation assertion Add the namespace section preservation check to the provider conformance suite to ensure implementations maintain section boundaries correctly. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-conformance/src/suite/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tinymemory-conformance/src/suite/mod.rs b/crates/tinymemory-conformance/src/suite/mod.rs index 75cffe4..d37d1de 100644 --- a/crates/tinymemory-conformance/src/suite/mod.rs +++ b/crates/tinymemory-conformance/src/suite/mod.rs @@ -62,6 +62,7 @@ pub async fn assert_provider(provider: Arc) { assert_list_filters_narrow(p).await; assert_taint_is_preserved(p).await; assert_recall_respects_limit_and_namespace(p).await; + assert_namespaces_preserve_their_section(p).await; assert_export_import_round_trip(p).await; assert_awkward_content_round_trips(p).await; assert_kv_round_trip(p).await; From 344b8b8a78116ace7fd395838d77182100e83fee Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:35:14 +0300 Subject: [PATCH 015/106] feat(conformance): expose namespace section assertion Export the namespace section preservation assertion from the conformance suite so providers can validate this behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-conformance/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tinymemory-conformance/src/lib.rs b/crates/tinymemory-conformance/src/lib.rs index 727fe4d..9a66e06 100644 --- a/crates/tinymemory-conformance/src/lib.rs +++ b/crates/tinymemory-conformance/src/lib.rs @@ -45,7 +45,8 @@ 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_kv_round_trip, - assert_list_filters_narrow, assert_namespaces_are_isolated, assert_provider, + assert_list_filters_narrow, assert_namespaces_are_isolated, + assert_namespaces_preserve_their_section, assert_provider, assert_recall_respects_limit_and_namespace, assert_store_get_round_trip, assert_taint_is_preserved, assert_upsert_replaces_rather_than_duplicates, }; From 7c512825602de715ea8e4a8e3c539d74b321a384 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:35:23 +0300 Subject: [PATCH 016/106] chore: update locked dependencies Add tinymemory-bus to the locked dependency list for the package that now uses it. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index 9e50cf3..a6ef9b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1934,6 +1934,7 @@ dependencies = [ "async-trait", "serde_json", "tinymemory-api", + "tinymemory-bus", "tokio", ] From e99122cbd415d77a99a9dc62f8238597e9bf7ea5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:35:44 +0300 Subject: [PATCH 017/106] test(conformance): add scratch red check Add a scratch test for validating failing conformance behavior during development. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tests/scratch_red_check.rs | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 crates/tinymemory-conformance/tests/scratch_red_check.rs diff --git a/crates/tinymemory-conformance/tests/scratch_red_check.rs b/crates/tinymemory-conformance/tests/scratch_red_check.rs new file mode 100644 index 0000000..d0d98f7 --- /dev/null +++ b/crates/tinymemory-conformance/tests/scratch_red_check.rs @@ -0,0 +1,129 @@ +//! Scratch RED-check: a MemoryProvider double that mangles ':' to '_' in +//! `namespaces()`, exactly the historical bug, to confirm +//! `assert_namespaces_preserve_their_section` actually catches it. +//! DELETE BEFORE FINAL COMMIT. + +use std::sync::Arc; + +use tinymemory_conformance::InMemoryProvider; + +#[tokio::test] +#[should_panic(expected = "namespaces() reported")] +async fn broken_driver_that_mangles_colons_is_caught() { + // Reuse the real reference driver for storage, but wrap `namespaces()` + // to simulate the sanitize_namespace bug: report the sectioned namespace + // back with ':' collapsed to '_', same as the buggy `UnifiedMemory`. + struct Mangling(InMemoryProvider); + + #[async_trait::async_trait] + impl tinymemory_api::provider::MemoryCore for Mangling { + async fn store( + &self, + namespace: &str, + key: &str, + content: &str, + category: tinymemory_api::types::MemoryCategory, + session_id: Option<&str>, + taint: tinymemory_api::types::MemoryTaint, + ) -> Result<(), tinymemory_api::error::MemoryError> { + self.0 + .store(namespace, key, content, category, session_id, taint) + .await + } + async fn get( + &self, + namespace: &str, + key: &str, + ) -> Result, tinymemory_api::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_api::types::MemoryCategory>, + session_id: Option<&str>, + ) -> Result, tinymemory_api::error::MemoryError> + { + self.0.list(namespace, category, session_id).await + } + async fn namespaces( + &self, + ) -> Result, tinymemory_api::error::MemoryError> + { + let mut summaries = self.0.namespaces().await?; + for s in &mut summaries { + s.namespace = s.namespace.replace(':', "_"); + } + Ok(summaries) + } + } + + #[async_trait::async_trait] + impl tinymemory_api::provider::MemoryRecall for Mangling { + async fn recall( + &self, + query: &str, + limit: usize, + opts: &tinymemory_api::recall::OwnedRecallOpts, + scope: Option<&tinymemory_api::source::SourceScope>, + ) -> Result, tinymemory_api::error::MemoryError> + { + self.0.recall(query, limit, opts, scope).await + } + } + + #[async_trait::async_trait] + impl tinymemory_api::provider::MemoryKv for Mangling { + async fn kv_set( + &self, + namespace: Option<&str>, + key: &str, + value: serde_json::Value, + ) -> Result<(), tinymemory_api::error::MemoryError> { + self.0.kv_set(namespace, key, value).await + } + async fn kv_get( + &self, + namespace: Option<&str>, + key: &str, + ) -> Result, tinymemory_api::error::MemoryError> { + self.0.kv_get(namespace, key).await + } + async fn kv_delete( + &self, + namespace: Option<&str>, + key: &str, + ) -> Result { + self.0.kv_delete(namespace, key).await + } + } + + impl tinymemory_api::provider::MemoryProvider for Mangling { + fn driver_id(&self) -> &str { + "mangling-double" + } + fn capabilities(&self) -> tinymemory_api::capabilities::CapabilitySet { + self.0.capabilities() + } + fn as_tree(&self) -> Option<&dyn tinymemory_api::provider::TreeMemory> { + None + } + fn as_graph(&self) -> Option<&dyn tinymemory_api::provider::GraphMemory> { + None + } + fn as_ingest(&self) -> Option<&dyn tinymemory_api::provider::IngestMemory> { + None + } + } + + let driver = Mangling(InMemoryProvider::new()); + tinymemory_conformance::assert_namespaces_preserve_their_section(&driver).await; +} From d5b6e5e8ebd2b216efa7d5f47ccad626a97d093f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:36:17 +0300 Subject: [PATCH 018/106] chore(conformance): remove scratch namespace bug check Remove the temporary RED-check test that simulated a historical namespace mangling bug. The test was marked for deletion before the final commit and is no longer needed. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tests/scratch_red_check.rs | 129 ------------------ 1 file changed, 129 deletions(-) delete mode 100644 crates/tinymemory-conformance/tests/scratch_red_check.rs diff --git a/crates/tinymemory-conformance/tests/scratch_red_check.rs b/crates/tinymemory-conformance/tests/scratch_red_check.rs deleted file mode 100644 index d0d98f7..0000000 --- a/crates/tinymemory-conformance/tests/scratch_red_check.rs +++ /dev/null @@ -1,129 +0,0 @@ -//! Scratch RED-check: a MemoryProvider double that mangles ':' to '_' in -//! `namespaces()`, exactly the historical bug, to confirm -//! `assert_namespaces_preserve_their_section` actually catches it. -//! DELETE BEFORE FINAL COMMIT. - -use std::sync::Arc; - -use tinymemory_conformance::InMemoryProvider; - -#[tokio::test] -#[should_panic(expected = "namespaces() reported")] -async fn broken_driver_that_mangles_colons_is_caught() { - // Reuse the real reference driver for storage, but wrap `namespaces()` - // to simulate the sanitize_namespace bug: report the sectioned namespace - // back with ':' collapsed to '_', same as the buggy `UnifiedMemory`. - struct Mangling(InMemoryProvider); - - #[async_trait::async_trait] - impl tinymemory_api::provider::MemoryCore for Mangling { - async fn store( - &self, - namespace: &str, - key: &str, - content: &str, - category: tinymemory_api::types::MemoryCategory, - session_id: Option<&str>, - taint: tinymemory_api::types::MemoryTaint, - ) -> Result<(), tinymemory_api::error::MemoryError> { - self.0 - .store(namespace, key, content, category, session_id, taint) - .await - } - async fn get( - &self, - namespace: &str, - key: &str, - ) -> Result, tinymemory_api::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_api::types::MemoryCategory>, - session_id: Option<&str>, - ) -> Result, tinymemory_api::error::MemoryError> - { - self.0.list(namespace, category, session_id).await - } - async fn namespaces( - &self, - ) -> Result, tinymemory_api::error::MemoryError> - { - let mut summaries = self.0.namespaces().await?; - for s in &mut summaries { - s.namespace = s.namespace.replace(':', "_"); - } - Ok(summaries) - } - } - - #[async_trait::async_trait] - impl tinymemory_api::provider::MemoryRecall for Mangling { - async fn recall( - &self, - query: &str, - limit: usize, - opts: &tinymemory_api::recall::OwnedRecallOpts, - scope: Option<&tinymemory_api::source::SourceScope>, - ) -> Result, tinymemory_api::error::MemoryError> - { - self.0.recall(query, limit, opts, scope).await - } - } - - #[async_trait::async_trait] - impl tinymemory_api::provider::MemoryKv for Mangling { - async fn kv_set( - &self, - namespace: Option<&str>, - key: &str, - value: serde_json::Value, - ) -> Result<(), tinymemory_api::error::MemoryError> { - self.0.kv_set(namespace, key, value).await - } - async fn kv_get( - &self, - namespace: Option<&str>, - key: &str, - ) -> Result, tinymemory_api::error::MemoryError> { - self.0.kv_get(namespace, key).await - } - async fn kv_delete( - &self, - namespace: Option<&str>, - key: &str, - ) -> Result { - self.0.kv_delete(namespace, key).await - } - } - - impl tinymemory_api::provider::MemoryProvider for Mangling { - fn driver_id(&self) -> &str { - "mangling-double" - } - fn capabilities(&self) -> tinymemory_api::capabilities::CapabilitySet { - self.0.capabilities() - } - fn as_tree(&self) -> Option<&dyn tinymemory_api::provider::TreeMemory> { - None - } - fn as_graph(&self) -> Option<&dyn tinymemory_api::provider::GraphMemory> { - None - } - fn as_ingest(&self) -> Option<&dyn tinymemory_api::provider::IngestMemory> { - None - } - } - - let driver = Mangling(InMemoryProvider::new()); - tinymemory_conformance::assert_namespaces_preserve_their_section(&driver).await; -} From 6ede5ba9536b754b0a5df68885c7557d0a8a531f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:36:27 +0300 Subject: [PATCH 019/106] test(reference): simulate namespace sanitization bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sanitize namespace separators in the reference provider’s summaries to match the behavior under test. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-conformance/src/reference/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinymemory-conformance/src/reference/mod.rs b/crates/tinymemory-conformance/src/reference/mod.rs index 16c70ce..7b5a6e0 100644 --- a/crates/tinymemory-conformance/src/reference/mod.rs +++ b/crates/tinymemory-conformance/src/reference/mod.rs @@ -152,7 +152,7 @@ impl MemoryCore for InMemoryProvider { Ok(counts .into_iter() .map(|(namespace, count)| NamespaceSummary { - namespace, + namespace: namespace.replace(':', "_"), // TEMP: simulate the sanitize_namespace bug count, last_updated: None, }) From fa0a67719ae91a42a3fe6d4c8cc67016a84c0bf7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:36:43 +0300 Subject: [PATCH 020/106] fix(conformance): preserve namespace names in summaries Stop replacing colons with underscores when constructing namespace summaries so the reference provider reports the original namespace identifiers. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-conformance/src/reference/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinymemory-conformance/src/reference/mod.rs b/crates/tinymemory-conformance/src/reference/mod.rs index 7b5a6e0..16c70ce 100644 --- a/crates/tinymemory-conformance/src/reference/mod.rs +++ b/crates/tinymemory-conformance/src/reference/mod.rs @@ -152,7 +152,7 @@ impl MemoryCore for InMemoryProvider { Ok(counts .into_iter() .map(|(namespace, count)| NamespaceSummary { - namespace: namespace.replace(':', "_"), // TEMP: simulate the sanitize_namespace bug + namespace, count, last_updated: None, }) From 1ed5c9d4ba50a715fbdaf3c445ef94fe716bef45 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:39:08 +0300 Subject: [PATCH 021/106] chore(conformance): remove unnecessary bus dependency Remove the unused tinymemory-bus dependency from the conformance crate to keep its dependency set focused on portability testing without coupling it to shared storage vocabulary. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-conformance/Cargo.toml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/crates/tinymemory-conformance/Cargo.toml b/crates/tinymemory-conformance/Cargo.toml index 9e67dbc..286585f 100644 --- a/crates/tinymemory-conformance/Cargo.toml +++ b/crates/tinymemory-conformance/Cargo.toml @@ -15,14 +15,6 @@ repository = "https://github.com/tinyhumansai/tinymemory" # 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 = "../tinymemory-api" } -# Owns the `
:` namespace convention (`Namespace`, -# `MemorySection`) that `assert_namespaces_preserve_their_section` parses a -# driver's `namespaces()` output with. Safe alongside the "nothing of -# substance" rule above: like `tinymemory-api`, it depends on no engine, no -# SQLite, and no async runtime, so pulling it in still proves nothing about -# whether a driver is interchangeable — it is a shared vocabulary, not a -# storage choice. -tinymemory-bus = { path = "../tinymemory-bus" } # `MemoryProvider` and its families are object-safe async traits. async-trait = "0.1" # `ExportRecord::payload` is a `serde_json::Value`, so the portability From 0d42110f2115435bb5e36356977a86922f714f6c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:39:20 +0300 Subject: [PATCH 022/106] fix(conformance): use the API namespace type Update the conformance suite to import and document the namespace type from the API crate, matching the unified namespace ownership and preventing reliance on the bus crate. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-conformance/src/suite/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinymemory-conformance/src/suite/mod.rs b/crates/tinymemory-conformance/src/suite/mod.rs index d37d1de..974c05e 100644 --- a/crates/tinymemory-conformance/src/suite/mod.rs +++ b/crates/tinymemory-conformance/src/suite/mod.rs @@ -28,7 +28,7 @@ use tinymemory_api::error::MemoryError; use tinymemory_api::provider::{audit_provider, ExportRecord, MemoryProvider}; use tinymemory_api::recall::OwnedRecallOpts; use tinymemory_api::types::{MemoryCategory, MemoryTaint}; -use tinymemory_bus::namespace::Namespace; +use tinymemory_api::namespace::Namespace; /// Runs every assertion in the suite. /// @@ -485,7 +485,7 @@ pub async fn assert_recall_respects_limit_and_namespace(provider: &dyn MemoryPro } /// `namespaces()` reports a sectioned namespace back under the same -/// [`tinymemory_bus::namespace::MemorySection`] the caller wrote it in. +/// [`tinymemory_api::namespace::MemorySection`] the caller wrote it in. /// /// This is the regression the unified SQLite store's own storage-address /// sanitiser taught us to check for: a driver whose on-disk address collapses From e6767496d334282c01fe93e53fcde5d9220a5984 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:39:33 +0300 Subject: [PATCH 023/106] style(conformance): reorder namespace import Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-conformance/src/suite/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinymemory-conformance/src/suite/mod.rs b/crates/tinymemory-conformance/src/suite/mod.rs index 974c05e..4765581 100644 --- a/crates/tinymemory-conformance/src/suite/mod.rs +++ b/crates/tinymemory-conformance/src/suite/mod.rs @@ -25,10 +25,10 @@ use std::sync::Arc; use tinymemory_api::capabilities::Capability; use tinymemory_api::error::MemoryError; +use tinymemory_api::namespace::Namespace; use tinymemory_api::provider::{audit_provider, ExportRecord, MemoryProvider}; use tinymemory_api::recall::OwnedRecallOpts; use tinymemory_api::types::{MemoryCategory, MemoryTaint}; -use tinymemory_api::namespace::Namespace; /// Runs every assertion in the suite. /// From 2ea2a76aed69d5e3ddb6122a7f164a9a2c27f4a3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:39:55 +0300 Subject: [PATCH 024/106] chore(deps): remove tinymemory-bus dependency Stop recording the unused tinymemory-bus dependency in the lockfile. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.lock | 1 - 1 file changed, 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index a6ef9b8..9e50cf3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1934,7 +1934,6 @@ dependencies = [ "async-trait", "serde_json", "tinymemory-api", - "tinymemory-bus", "tokio", ] From 5b32c8274bdb0c48ec2886fbdad62133c9b873e4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:43:29 +0300 Subject: [PATCH 025/106] docs(memory): document logical namespace storage separation Document the separation between path-safe storage addresses and logical namespaces, including nullable persistence, lazy backfilling, and section-preserving enumeration. Clarify the associated invariants and conformance requirement. Auto-committed-on: dragonfly Co-authored-by: Medulla --- docs/specs/memory-section-api.md | 40 ++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/specs/memory-section-api.md b/docs/specs/memory-section-api.md index d739d3d..7592f64 100644 --- a/docs/specs/memory-section-api.md +++ b/docs/specs/memory-section-api.md @@ -153,6 +153,40 @@ Scores come from separate calls to one driver with one query. They are comparabl in practice on every bundled driver; the contract does not guarantee it, and the documentation says so rather than pretending otherwise. +### 4. The storage address and the logical namespace + +`UnifiedMemory` cannot store a `:` in the value it uses as a namespace: that +string becomes a filesystem directory via `namespace_dir()`, and +`sanitize_namespace` maps every character outside `[A-Za-z0-9\-_/]` to `_` as a +path-traversal defence. So `conversation:thread-8f21` was stored — and +enumerated — as `conversation_thread-8f21`, which `Namespace::parse` reads as +*unsectioned*. Every enumerating call on this surface therefore returned empty +against the production store, after writes that had succeeded. + +Widening that allow-list is not the fix. It is what keeps the address path-safe, +`:` is illegal in a Windows filename and denotes an NTFS alternate data stream, +and the sanitiser also performs the PII redaction that keeps a national ID from +becoming a storage address. + +So the address and the name are now separate columns. `memory_docs.namespace` +keeps exactly the characters it has today and remains what addresses the row and +names the directory. A new nullable `memory_docs.logical_namespace` carries +`canonical_identifier(namespace)` — the delimiter-preserving form, still +PII-redacted — and `namespace_summaries` reports +`COALESCE(logical_namespace, namespace)`. + +The `COALESCE` is the entire backfill, deliberately. A row written before the +migration has `NULL` and keeps exactly its previous behaviour; the upsert clause +sets the column, so such a row heals when it is next written. No migration tries +to turn an old `_` back into a `:` — that mapping is not invertible, because a +scope may legitimately contain `_`, and guessing would silently relabel +unrelated namespaces into a section they were never written to. + +`assert_namespaces_preserve_their_section` in the conformance suite now holds +every driver to this: a namespace written in a section must be reported back in +that section. It is the assertion whose absence let the two bundled drivers +disagree unnoticed. + ## Invariants and constraints - A `SectionView` never reads or writes a namespace outside its own section. @@ -163,6 +197,12 @@ documentation says so rather than pretending otherwise. - An unusable section is an error, never an empty one: if a section's prefix fails validation, the enumerating calls fail rather than reporting no scopes, so they agree with the addressed calls about the same section. +- A driver reports a namespace back in the section it was written in. A driver + may re-address a namespace to suit its store, but it may not change which + section the name belongs to; `assert_namespaces_preserve_their_section` + enforces it. +- A namespace never reaches the filesystem with a character the path allow-list + excludes, and the PII redaction on the storage address is unchanged. - `put` then `get` on the same `(scope, key)` round-trips on any retaining driver. - Every call succeeds on a driver that retains nothing, returning empty rather than an error — the surface has no capability-absent path. From c1a30981ac035a48c758cfcb532de5df2c5a376b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:43:46 +0300 Subject: [PATCH 026/106] docs(memory): document sectioned write conformance Document production-store enumeration coverage, storage address sanitization, and idempotent logical namespace migration behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla --- docs/specs/memory-section-api.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/specs/memory-section-api.md b/docs/specs/memory-section-api.md index 7592f64..d9420a6 100644 --- a/docs/specs/memory-section-api.md +++ b/docs/specs/memory-section-api.md @@ -224,6 +224,14 @@ disagree unnoticed. descending, reports `namespaces_searched`, and sets `truncated` only when the namespace cap skipped one. - `across_section` with `opts.namespace: Some(_)` returns `MemoryError::Invalid`. +- A sectioned write to the production `UnifiedMemory` store is enumerable + afterwards: `scopes()` reports it, proven by the tinycortex full-provider + conformance test against a real on-disk workspace rather than an in-memory + double. +- The storage address still contains no character outside the path allow-list, + and a PII-bearing namespace is still redacted in both columns. +- The `logical_namespace` migration is idempotent, and a row predating it still + enumerates under its sanitised name. - The four contract commands pass, and rustdoc builds with `-D warnings`. ## Open questions From d535ffbcc76d83654bcfc95bbf4e3e5289674e91 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 22:22:15 +0300 Subject: [PATCH 027/106] docs(memory): update section API documentation Clarify the memory section API specification to make its behavior easier to understand and implement. Auto-committed-on: dragonfly Co-authored-by: Medulla --- docs/specs/memory-section-api.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/specs/memory-section-api.md b/docs/specs/memory-section-api.md index d9420a6..fa87cfa 100644 --- a/docs/specs/memory-section-api.md +++ b/docs/specs/memory-section-api.md @@ -197,10 +197,15 @@ disagree unnoticed. - An unusable section is an error, never an empty one: if a section's prefix fails validation, the enumerating calls fail rather than reporting no scopes, so they agree with the addressed calls about the same section. -- A driver reports a namespace back in the section it was written in. A driver - may re-address a namespace to suit its store, but it may not change which - section the name belongs to; `assert_namespaces_preserve_their_section` - enforces it. +- On a retaining driver, a namespace written or rewritten after this change is + reported back in the section it was written in. A driver may re-address a + namespace to suit its store, but it may not change which section the name + belongs to; `assert_namespaces_preserve_their_section` enforces it for every + retaining driver (`assert_provider` skips it, like the rest of the storage + assertions, for a driver that accepts writes and discards them). A row + written before this change and never rewritten keeps enumerating under its + sanitised, unsectioned name — see "The storage address and the logical + namespace" above for why that backfill is deliberately a no-op. - A namespace never reaches the filesystem with a character the path allow-list excludes, and the PII redaction on the storage address is unchanged. - `put` then `get` on the same `(scope, key)` round-trips on any retaining driver. From 0a76f4a8d730ac8fba2b529bd695924c1183c40b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 22:22:31 +0300 Subject: [PATCH 028/106] chore(safety): update safety module Update the safety module to keep its implementation aligned with the current store behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinymemory-core/src/store/safety/mod.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/tinymemory-core/src/store/safety/mod.rs b/crates/tinymemory-core/src/store/safety/mod.rs index a3c1225..3b4d40d 100644 --- a/crates/tinymemory-core/src/store/safety/mod.rs +++ b/crates/tinymemory-core/src/store/safety/mod.rs @@ -50,6 +50,39 @@ pub fn canonical_identifier(value: &str) -> String { pii::redact_pii(value).value } +/// Canonical form of the delimiter-preserving *logical* namespace that +/// `namespace_summaries` reports back to callers (`COALESCE(logical_namespace, +/// namespace)`). +/// +/// Built on [`canonical_identifier`] so a PII-bearing namespace is redacted +/// the same way the storage address is (#5164), with two corrections +/// `canonical_identifier` alone does not make: +/// +/// * **Bracket stripping.** The `[REDACTED_PII_*]` placeholder is valid +/// storage-address content but not a valid [`Namespace`](tinymemory) scope — +/// `Namespace::parse` rejects `[` and `]` — so a PII-bearing sectioned +/// namespace would round-trip through redaction and then fail to parse back +/// into its own section, reintroducing the exact enumeration gap this +/// column exists to close. Stripping the brackets keeps the redacted tokens +/// (`REDACTED_PII_SSN`, underscores and all) namespace-valid without +/// reintroducing the PII they replaced. +/// * **Blank fallback.** `UnifiedMemory::sanitize_namespace` maps blank / +/// whitespace-only input to `fallback` (in practice `GLOBAL_NAMESPACE`) so +/// the storage address is never an empty string. `canonical_identifier` +/// alone does not: trimmed-empty input canonicalizes to `""`, and +/// `COALESCE` treats an empty string as present, so the logical column +/// would silently diverge from the storage address for exactly the inputs +/// that column exists to shadow. Applying the same fallback here keeps them +/// in sync. +pub fn canonical_logical_namespace(raw: &str, fallback: &str) -> String { + let canonical = canonical_identifier(raw.trim()).replace(['[', ']'], ""); + if canonical.is_empty() { + fallback.to_string() + } else { + canonical + } +} + /// Canonical storage form of a document key: the exact transform /// `upsert_document` / `upsert_document_metadata_only` apply before writing the /// `memory_docs.key` column (trim, then [`canonical_identifier`]). From 55aea499e4da4da485bc3df4eabe9df7bb964cf4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 22:22:44 +0300 Subject: [PATCH 029/106] chore(core): update store safety module Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/safety/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinymemory-core/src/store/safety/mod.rs b/crates/tinymemory-core/src/store/safety/mod.rs index 3b4d40d..44883db 100644 --- a/crates/tinymemory-core/src/store/safety/mod.rs +++ b/crates/tinymemory-core/src/store/safety/mod.rs @@ -59,7 +59,7 @@ pub fn canonical_identifier(value: &str) -> String { /// `canonical_identifier` alone does not make: /// /// * **Bracket stripping.** The `[REDACTED_PII_*]` placeholder is valid -/// storage-address content but not a valid [`Namespace`](tinymemory) scope — +/// storage-address content but not a valid `Namespace` scope — /// `Namespace::parse` rejects `[` and `]` — so a PII-bearing sectioned /// namespace would round-trip through redaction and then fail to parse back /// into its own section, reintroducing the exact enumeration gap this From 587b6d0697a1893af3734f0d89a1026ef8f7c3eb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 22:22:53 +0300 Subject: [PATCH 030/106] chore: update namespace store documents Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/namespace_store/documents.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/documents.rs b/crates/tinymemory-core/src/store/namespace_store/documents.rs index 74e9573..5db038f 100644 --- a/crates/tinymemory-core/src/store/namespace_store/documents.rs +++ b/crates/tinymemory-core/src/store/namespace_store/documents.rs @@ -10,7 +10,7 @@ use std::collections::BTreeSet; use uuid::Uuid; use crate::store::safety; -use crate::store::types::{NamespaceDocumentInput, StoredMemoryDocument}; +use crate::store::types::{NamespaceDocumentInput, StoredMemoryDocument, GLOBAL_NAMESPACE}; use super::UnifiedMemory; From eac68959d875920c9a8ffec56ec089cddc61288c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 22:23:18 +0300 Subject: [PATCH 031/106] chore: update namespace document store Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/documents.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/documents.rs b/crates/tinymemory-core/src/store/namespace_store/documents.rs index 5db038f..24702a7 100644 --- a/crates/tinymemory-core/src/store/namespace_store/documents.rs +++ b/crates/tinymemory-core/src/store/namespace_store/documents.rs @@ -32,8 +32,13 @@ impl UnifiedMemory { // The logical (delimiter-preserving) namespace, PII-redacted the same // way `sanitize_namespace` redacts the storage address, so // `namespace_summaries` can report `conversation:thread-8f21` back - // verbatim instead of the path-safe `conversation_thread-8f21`. - let logical_namespace = safety::canonical_identifier(input.namespace.trim()); + // verbatim instead of the path-safe `conversation_thread-8f21`. Uses + // the same blank-input fallback as `sanitize_namespace` and strips the + // redaction placeholder's brackets so a PII-bearing sectioned + // namespace stays `Namespace::parse`-able -- see + // `canonical_logical_namespace`'s doc comment for both. + let logical_namespace = + safety::canonical_logical_namespace(&input.namespace, GLOBAL_NAMESPACE); let key = input.key.trim().to_string(); if key.is_empty() { return Err("document key cannot be empty".to_string()); From bd1cc54c4ad0bbb0b42540adcfae68037bba0063 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 22:23:28 +0300 Subject: [PATCH 032/106] chore(namespace-store): update documents implementation Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/namespace_store/documents.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/documents.rs b/crates/tinymemory-core/src/store/namespace_store/documents.rs index 24702a7..d44c902 100644 --- a/crates/tinymemory-core/src/store/namespace_store/documents.rs +++ b/crates/tinymemory-core/src/store/namespace_store/documents.rs @@ -252,7 +252,8 @@ impl UnifiedMemory { let namespace = Self::sanitize_namespace(&input.namespace); // See `upsert_document_presanitized` — same delimiter-preserving, // PII-redacted logical namespace, same reason. - let logical_namespace = safety::canonical_identifier(input.namespace.trim()); + let logical_namespace = + safety::canonical_logical_namespace(&input.namespace, GLOBAL_NAMESPACE); let key = input.key.trim().to_string(); if key.is_empty() { return Err("document key cannot be empty".to_string()); From 0b1059d6f3b5055ed742e1a2cc2f09c83609e1f2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 22:23:53 +0300 Subject: [PATCH 033/106] refactor(core): update memory store trait definitions Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinymemory-core/src/store/memory_trait.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/tinymemory-core/src/store/memory_trait.rs b/crates/tinymemory-core/src/store/memory_trait.rs index 068f266..9ea20cd 100644 --- a/crates/tinymemory-core/src/store/memory_trait.rs +++ b/crates/tinymemory-core/src/store/memory_trait.rs @@ -409,10 +409,25 @@ impl UnifiedMemory { // whatever delimiter it replaced (a scope may legitimately contain // `_`), so guessing would silently mislabel unrelated namespaces — // NULL rows simply keep reporting their sanitized address. + // + // `GROUP BY namespace` (the storage address), not `ns` (the logical + // name): two distinct logical names can sanitize to the same address + // (`conversation:x` and `conversation_x` both sanitize to + // `conversation_x`), and those rows are already merged into one + // physical namespace by every addressed call (`list`, `export`, ...). + // Grouping by the logical name instead would split one physical + // namespace's rows across two summaries with two partial counts, + // while every addressed call still visits the single merged + // namespace and returns the union — double-counting it if a caller + // then lists each reported summary in turn. Grouping by the address + // keeps one summary per physical namespace with an accurate count; + // `MIN(logical_namespace)` (aggregate `MIN` ignores `NULL`) just picks + // a single, deterministic logical representative for it when more + // than one exists. let mut stmt = conn.prepare( - "SELECT COALESCE(logical_namespace, namespace) AS ns, COUNT(*) AS n, MAX(updated_at) AS last + "SELECT COALESCE(MIN(logical_namespace), namespace) AS ns, COUNT(*) AS n, MAX(updated_at) AS last FROM memory_docs - GROUP BY ns + GROUP BY namespace ORDER BY ns", )?; let rows = stmt.query_map([], |row| { From 183d2c130e94a1ac4dbf216e2fc2c0379d194d2e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 22:25:48 +0300 Subject: [PATCH 034/106] test(store): cover namespace summary normalization edge cases Add tests for blank namespaces, aliased physical addresses, and PII-redacted sectioned namespaces. These cases ensure summaries remain deduplicated, counted correctly, and parseable. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/memory_trait_tests.rs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/crates/tinymemory-core/src/store/memory_trait_tests.rs b/crates/tinymemory-core/src/store/memory_trait_tests.rs index 7522649..c474fa4 100644 --- a/crates/tinymemory-core/src/store/memory_trait_tests.rs +++ b/crates/tinymemory-core/src/store/memory_trait_tests.rs @@ -235,6 +235,93 @@ async fn namespace_summaries_falls_back_to_sanitized_namespace_when_logical_is_n assert_eq!(found.count, 1); } +/// A blank/whitespace namespace sanitizes to `GLOBAL_NAMESPACE` on the +/// storage address (`sanitize_namespace`); the logical column must land on +/// the same fallback rather than an empty string, or `COALESCE(logical_namespace, +/// namespace)` would report an empty-string namespace instead of `global`. +#[tokio::test] +async fn namespace_summaries_normalizes_blank_namespace_to_global() { + let (_tmp, mem) = fresh_mem(); + mem.store(" ", "k1", "content", MemoryCategory::Core, None) + .await + .unwrap(); + + let summaries = mem.namespace_summaries().await.unwrap(); + assert!( + summaries.iter().all(|s| !s.namespace.is_empty()), + "no summary should report an empty namespace, got {summaries:?}" + ); + let found = summaries + .iter() + .find(|s| s.namespace == GLOBAL_NAMESPACE) + .unwrap_or_else(|| panic!("expected `{GLOBAL_NAMESPACE}` in {summaries:?}")); + assert_eq!(found.count, 1); +} + +/// Two logical names that sanitize to the same physical namespace +/// (`conversation:x` and `conversation_x` both collapse to +/// `conversation_x`) must not split into two summaries with two partial +/// counts: every addressed call (`list`, `export`, ...) already merges +/// their rows into one physical namespace, so `namespace_summaries` must +/// report exactly one entry with the true, combined count. +#[tokio::test] +async fn namespace_summaries_deduplicates_when_two_logical_names_alias_one_address() { + let (_tmp, mem) = fresh_mem(); + mem.store("conversation:x", "k1", "a", MemoryCategory::Core, None) + .await + .unwrap(); + mem.store("conversation_x", "k2", "b", MemoryCategory::Core, None) + .await + .unwrap(); + + let summaries = mem.namespace_summaries().await.unwrap(); + let matching: Vec<_> = summaries + .iter() + .filter(|s| s.namespace == "conversation:x" || s.namespace == "conversation_x") + .collect(); + assert_eq!( + matching.len(), + 1, + "expected exactly one summary for the aliased address, got {summaries:?}" + ); + assert_eq!(matching[0].count, 2); + + // Both aliases still address the same merged physical namespace. + let listed = mem.list(Some("conversation:x"), None, None).await.unwrap(); + assert_eq!(listed.len(), 2); +} + +/// `canonical_identifier`'s `[REDACTED_PII_*]` placeholder is valid storage +/// content but not a valid `Namespace` scope (`[`/`]` are rejected). A +/// sectioned namespace whose scope trips the strict PII gate must still +/// come back `Namespace::parse`-able and under its original section, or the +/// exact enumeration bug this column exists to fix reappears for precisely +/// PII-shaped scopes. +#[tokio::test] +async fn namespace_summaries_strips_brackets_from_pii_redacted_sectioned_namespace() { + use tinymemory_api::namespace::{MemorySection, Namespace}; + + let (_tmp, mem) = fresh_mem(); + let namespace = "conversation:ssn-123-45-6789"; + mem.store(namespace, "k1", "content", MemoryCategory::Core, None) + .await + .unwrap(); + + let summaries = mem.namespace_summaries().await.unwrap(); + let found = summaries + .iter() + .find(|s| s.namespace.starts_with("conversation:")) + .unwrap_or_else(|| panic!("expected a `conversation:` namespace in {summaries:?}")); + assert!( + !found.namespace.contains('[') && !found.namespace.contains(']'), + "logical namespace must stay Namespace-valid (no brackets), got {}", + found.namespace + ); + let parsed = Namespace::parse(&found.namespace) + .unwrap_or_else(|e| panic!("reported namespace `{}` must parse: {e}", found.namespace)); + assert_eq!(parsed.section(), &MemorySection::Conversation); +} + #[tokio::test] async fn legacy_namespace_migration_splits_and_is_idempotent() { use rusqlite::params; From f6934c87f3e91895cb788c0c586af0fc501cdd56 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 22:26:07 +0300 Subject: [PATCH 035/106] test(core): update memory trait tests Refresh the memory trait test coverage to reflect the current store behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/memory_trait_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinymemory-core/src/store/memory_trait_tests.rs b/crates/tinymemory-core/src/store/memory_trait_tests.rs index c474fa4..8250d33 100644 --- a/crates/tinymemory-core/src/store/memory_trait_tests.rs +++ b/crates/tinymemory-core/src/store/memory_trait_tests.rs @@ -319,7 +319,7 @@ async fn namespace_summaries_strips_brackets_from_pii_redacted_sectioned_namespa ); let parsed = Namespace::parse(&found.namespace) .unwrap_or_else(|e| panic!("reported namespace `{}` must parse: {e}", found.namespace)); - assert_eq!(parsed.section(), &MemorySection::Conversation); + assert_eq!(parsed.section(), Some(&MemorySection::Conversation)); } #[tokio::test] From f554b42ff6d42ef719ec38b60667224bf8fb7c2e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 22:42:12 +0300 Subject: [PATCH 036/106] fix(safety): preserve namespace address equivalence Replace redaction brackets with underscores when canonicalizing logical namespaces so the result matches storage sanitization. This keeps reported names parseable and ensures callers can use them to find the stored data. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinymemory-core/src/store/safety/mod.rs | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/crates/tinymemory-core/src/store/safety/mod.rs b/crates/tinymemory-core/src/store/safety/mod.rs index 44883db..940bd53 100644 --- a/crates/tinymemory-core/src/store/safety/mod.rs +++ b/crates/tinymemory-core/src/store/safety/mod.rs @@ -58,14 +58,19 @@ pub fn canonical_identifier(value: &str) -> String { /// the same way the storage address is (#5164), with two corrections /// `canonical_identifier` alone does not make: /// -/// * **Bracket stripping.** The `[REDACTED_PII_*]` placeholder is valid -/// storage-address content but not a valid `Namespace` scope — -/// `Namespace::parse` rejects `[` and `]` — so a PII-bearing sectioned -/// namespace would round-trip through redaction and then fail to parse back -/// into its own section, reintroducing the exact enumeration gap this -/// column exists to close. Stripping the brackets keeps the redacted tokens -/// (`REDACTED_PII_SSN`, underscores and all) namespace-valid without -/// reintroducing the PII they replaced. +/// * **Bracket substitution, not stripping.** The `[REDACTED_PII_*]` +/// placeholder is valid storage-address content but not a valid `Namespace` +/// scope — `Namespace::parse` rejects `[` and `]` — so a PII-bearing +/// sectioned namespace would round-trip through redaction and then fail to +/// parse back into its own section, reintroducing the exact enumeration gap +/// this column exists to close. The brackets are mapped to `_`, the exact +/// substitution `UnifiedMemory::sanitize_namespace` already performs on +/// every character outside its path-safe allow-list. That match matters: +/// removing the brackets instead (rather than substituting) would make the +/// logical name parse but no longer *address-equivalent* — re-sanitizing it +/// would produce a different physical namespace than the one the row was +/// actually written under, so a caller that fed the reported name back into +/// `list`/`get` would find nothing. /// * **Blank fallback.** `UnifiedMemory::sanitize_namespace` maps blank / /// whitespace-only input to `fallback` (in practice `GLOBAL_NAMESPACE`) so /// the storage address is never an empty string. `canonical_identifier` @@ -75,7 +80,10 @@ pub fn canonical_identifier(value: &str) -> String { /// that column exists to shadow. Applying the same fallback here keeps them /// in sync. pub fn canonical_logical_namespace(raw: &str, fallback: &str) -> String { - let canonical = canonical_identifier(raw.trim()).replace(['[', ']'], ""); + let canonical: String = canonical_identifier(raw.trim()) + .chars() + .map(|ch| if ch == '[' || ch == ']' { '_' } else { ch }) + .collect(); if canonical.is_empty() { fallback.to_string() } else { From 8d1ed03d4e36c9581e01d9852a14536fbb10eb46 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 22:42:29 +0300 Subject: [PATCH 037/106] test(store): verify reported namespaces remain addressable Add a regression assertion that listing a reported namespace finds its stored row. This guards against sanitization changes that map the logical name to a different physical namespace. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/memory_trait_tests.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/tinymemory-core/src/store/memory_trait_tests.rs b/crates/tinymemory-core/src/store/memory_trait_tests.rs index 8250d33..5e67d45 100644 --- a/crates/tinymemory-core/src/store/memory_trait_tests.rs +++ b/crates/tinymemory-core/src/store/memory_trait_tests.rs @@ -320,6 +320,23 @@ async fn namespace_summaries_strips_brackets_from_pii_redacted_sectioned_namespa let parsed = Namespace::parse(&found.namespace) .unwrap_or_else(|e| panic!("reported namespace `{}` must parse: {e}", found.namespace)); assert_eq!(parsed.section(), Some(&MemorySection::Conversation)); + + // Address-equivalence: feeding the reported logical name straight back + // into an addressed call must find the row it names. Stripping the + // brackets instead of substituting `_` for them (matching + // `sanitize_namespace`'s own character mapping) would re-sanitize this + // name to a *different* physical namespace than the one actually + // written, so this call would silently return nothing. + let listed = mem + .list(Some(&found.namespace), None, None) + .await + .unwrap(); + assert_eq!( + listed.len(), + 1, + "listing the reported namespace `{}` must find the row stored under `{namespace}`", + found.namespace + ); } #[tokio::test] From eb9e9871545dc7679dea27f2879b038774833d97 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 22:43:11 +0300 Subject: [PATCH 038/106] style(tests): format namespace listing call consistently Condense the namespace listing call to match the surrounding formatting without changing test behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/memory_trait_tests.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/tinymemory-core/src/store/memory_trait_tests.rs b/crates/tinymemory-core/src/store/memory_trait_tests.rs index 5e67d45..da8ee97 100644 --- a/crates/tinymemory-core/src/store/memory_trait_tests.rs +++ b/crates/tinymemory-core/src/store/memory_trait_tests.rs @@ -327,10 +327,7 @@ async fn namespace_summaries_strips_brackets_from_pii_redacted_sectioned_namespa // `sanitize_namespace`'s own character mapping) would re-sanitize this // name to a *different* physical namespace than the one actually // written, so this call would silently return nothing. - let listed = mem - .list(Some(&found.namespace), None, None) - .await - .unwrap(); + let listed = mem.list(Some(&found.namespace), None, None).await.unwrap(); assert_eq!( listed.len(), 1, From 628fcd7c5412bb610a5d4f4a071f1723ea9f6156 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 22:44:51 +0300 Subject: [PATCH 039/106] test(memory): clarify PII namespace bracket substitution test Rename the test to reflect that brackets in PII-redacted namespaces are substituted rather than stripped. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/memory_trait_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinymemory-core/src/store/memory_trait_tests.rs b/crates/tinymemory-core/src/store/memory_trait_tests.rs index da8ee97..b7dc880 100644 --- a/crates/tinymemory-core/src/store/memory_trait_tests.rs +++ b/crates/tinymemory-core/src/store/memory_trait_tests.rs @@ -298,7 +298,7 @@ async fn namespace_summaries_deduplicates_when_two_logical_names_alias_one_addre /// exact enumeration bug this column exists to fix reappears for precisely /// PII-shaped scopes. #[tokio::test] -async fn namespace_summaries_strips_brackets_from_pii_redacted_sectioned_namespace() { +async fn namespace_summaries_substitutes_brackets_in_pii_redacted_sectioned_namespace() { use tinymemory_api::namespace::{MemorySection, Namespace}; let (_tmp, mem) = fresh_mem(); From 144e97404a58889265e4003ecb92b17fd4db2b3d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 22:46:50 +0300 Subject: [PATCH 040/106] docs(specs): clarify namespace preservation assertion Clarify that the namespace preservation assertion applies only to retaining drivers and newly written rows. Explain that drivers retaining nothing skip the assertion alongside other storage assertions. Auto-committed-on: dragonfly Co-authored-by: Medulla --- docs/specs/memory-section-api.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/specs/memory-section-api.md b/docs/specs/memory-section-api.md index fa87cfa..f9f5727 100644 --- a/docs/specs/memory-section-api.md +++ b/docs/specs/memory-section-api.md @@ -183,9 +183,12 @@ scope may legitimately contain `_`, and guessing would silently relabel unrelated namespaces into a section they were never written to. `assert_namespaces_preserve_their_section` in the conformance suite now holds -every driver to this: a namespace written in a section must be reported back in -that section. It is the assertion whose absence let the two bundled drivers -disagree unnoticed. +every *retaining* driver to this: a namespace written in a section must be +reported back in that section. It is skipped for a driver that retains nothing, +like the rest of the storage assertions, and it says nothing about a row written +before this change and never rewritten — see the invariant below for the exact +scope. It is the assertion whose absence let the two bundled drivers disagree +unnoticed. ## Invariants and constraints From 0c89fe8bbe261f3ab52119ce72bf066c474a9296 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:08:24 +0300 Subject: [PATCH 041/106] chore(core): update memory store trait Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinymemory-core/src/store/memory_trait.rs | 96 ++++++++++++++----- 1 file changed, 74 insertions(+), 22 deletions(-) diff --git a/crates/tinymemory-core/src/store/memory_trait.rs b/crates/tinymemory-core/src/store/memory_trait.rs index 9ea20cd..9b9fc8d 100644 --- a/crates/tinymemory-core/src/store/memory_trait.rs +++ b/crates/tinymemory-core/src/store/memory_trait.rs @@ -304,9 +304,31 @@ impl UnifiedMemory { type MemoryDocRow = (String, String, String, f64, String, String, Option); impl UnifiedMemory { + /// Address a row by its **logical** namespace, not just its physical one. + /// + /// `ns` is the sanitized storage address (`WHERE namespace = ?1`); `logical` + /// is `canonical_logical_namespace(caller_input, GLOBAL_NAMESPACE)` — the + /// exact value the write path bound into the `logical_namespace` column + /// (see `upsert_document_presanitized`). Two distinct logical names can + /// sanitize to the same physical address (`a:b_c` and `a_b:c` both become + /// `a_b_c`); without this clause a read addressed to one would also surface + /// -- and a `get`/`forget` addressed to one could delete -- the other's + /// rows, because both share `ns`. Filtering on `logical_namespace` too + /// keeps the two apart. + /// + /// `OR logical_namespace IS NULL` is required, not incidental: rows + /// written before this column existed have it NULL and never get a value + /// reconstructed (a sanitized `_` cannot be un-collapsed into whatever + /// delimiter it replaced), so excluding NULL rows outright would make + /// every pre-migration row permanently unaddressable by `get`/`forget` and + /// invisible to `list`, silently breaking every caller relying on them. + const LOGICAL_NAMESPACE_FILTER: &'static str = + "(logical_namespace = ?logical OR logical_namespace IS NULL)"; + fn get_blocking( conn: &Arc>, ns: &str, + logical: &str, key: &str, ) -> anyhow::Result> { let conn = conn.lock(); @@ -315,11 +337,19 @@ impl UnifiedMemory { // readers disagree about one record. The contract's round-trip // assertion catches exactly that (`tinymemory_conformance`), and it was // invisible until #18 §A3 let this store be bound as a driver at all. - let row: Option = conn + // + // `logical_namespace` is selected too so the returned `MemoryEntry` + // reports the row's own logical name rather than the physical address + // this method happens to have been called with — see `list_blocking`'s + // doc comment for why that distinction matters. + let row: Option<(String, String, String, f64, String, String, Option, Option)> = conn .query_row( - "SELECT document_id, key, content, updated_at, category, taint, session_id - FROM memory_docs WHERE namespace = ?1 AND key = ?2 LIMIT 1", - params![ns, key], + &format!( + "SELECT document_id, key, content, updated_at, category, taint, session_id, logical_namespace + FROM memory_docs WHERE namespace = ?1 AND key = ?2 AND {} LIMIT 1", + Self::LOGICAL_NAMESPACE_FILTER.replace("?logical", "?3") + ), + params![ns, key, logical], |row| { Ok(( row.get(0)?, @@ -329,43 +359,61 @@ impl UnifiedMemory { row.get(4)?, row.get(5)?, row.get(6)?, + row.get(7)?, )) }, ) .optional()?; Ok(row.map( - |(id, key, content, updated_at, category, taint_str, session_id)| MemoryEntry { - id, - key, - content, - namespace: Some(ns.to_string()), - category: memory_category_from_stored(&category), - timestamp: timestamp_to_rfc3339(updated_at), - session_id, - score: None, - taint: crate::MemoryTaint::from_db_str(&taint_str), + |(id, key, content, updated_at, category, taint_str, session_id, row_logical)| { + MemoryEntry { + id, + key, + content, + namespace: Some(row_logical.unwrap_or_else(|| ns.to_string())), + category: memory_category_from_stored(&category), + timestamp: timestamp_to_rfc3339(updated_at), + session_id, + score: None, + taint: crate::MemoryTaint::from_db_str(&taint_str), + } }, )) } + /// List every row addressed to one namespace, physical **and** logical. + /// + /// A caller lists `learning:rust`; `ns` is the sanitized `learning_rust` + /// storage address, and `logical` is `learning:rust` itself. Filtering on + /// physical address alone would also return `learning_rust`'s own rows + /// (a distinct logical namespace that happens to sanitize identically), + /// mislabelling them as belonging to the section that was listed — + /// exactly the incompleteness `logical_namespace` exists to close. Each + /// returned entry's `namespace` is the row's *own* logical name (falling + /// back to the physical address only for pre-migration NULL rows), never + /// the caller's query namespace, so a row that genuinely came from the + /// aliased NULL-logical legacy address is still labelled honestly. fn list_blocking( conn: &Arc>, ns: &str, + logical: &str, category: Option<&MemoryCategory>, session_id: Option<&str>, ) -> anyhow::Result> { let conn = conn.lock(); - let mut stmt = conn.prepare( - "SELECT document_id, key, content, category, session_id, updated_at, taint - FROM memory_docs WHERE namespace = ?1 ORDER BY updated_at DESC", - )?; - let rows = stmt.query_map(params![ns], |row| { + let mut stmt = conn.prepare(&format!( + "SELECT document_id, key, content, category, session_id, updated_at, taint, logical_namespace + FROM memory_docs WHERE namespace = ?1 AND {} ORDER BY updated_at DESC", + Self::LOGICAL_NAMESPACE_FILTER.replace("?logical", "?2") + ))?; + let rows = stmt.query_map(params![ns, logical], |row| { let stored_category: String = row.get(3)?; + let row_logical: Option = row.get(7)?; Ok(MemoryEntry { id: row.get(0)?, key: row.get(1)?, content: row.get(2)?, - namespace: Some(ns.to_string()), + namespace: Some(row_logical.unwrap_or_else(|| ns.to_string())), category: memory_category_from_stored(&stored_category), session_id: row.get(4)?, timestamp: timestamp_to_rfc3339(row.get(5)?), @@ -386,13 +434,17 @@ impl UnifiedMemory { fn forget_lookup_blocking( conn: &Arc>, ns: &str, + logical: &str, key: &str, ) -> anyhow::Result> { let conn = conn.lock(); Ok(conn .query_row( - "SELECT document_id FROM memory_docs WHERE namespace = ?1 AND key = ?2 LIMIT 1", - params![ns, key], + &format!( + "SELECT document_id FROM memory_docs WHERE namespace = ?1 AND key = ?2 AND {} LIMIT 1", + Self::LOGICAL_NAMESPACE_FILTER.replace("?logical", "?3") + ), + params![ns, key, logical], |row| row.get(0), ) .optional()?) From 65680bc6efb41328a1af035722f7bbf217715001 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:09:10 +0300 Subject: [PATCH 042/106] chore: update memory store trait Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinymemory-core/src/store/memory_trait.rs | 55 +++++++++---------- 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/crates/tinymemory-core/src/store/memory_trait.rs b/crates/tinymemory-core/src/store/memory_trait.rs index 9b9fc8d..9858e73 100644 --- a/crates/tinymemory-core/src/store/memory_trait.rs +++ b/crates/tinymemory-core/src/store/memory_trait.rs @@ -303,28 +303,28 @@ impl UnifiedMemory { /// `(document_id, key, content, updated_at, category, taint, session_id)`. type MemoryDocRow = (String, String, String, f64, String, String, Option); -impl UnifiedMemory { - /// Address a row by its **logical** namespace, not just its physical one. - /// - /// `ns` is the sanitized storage address (`WHERE namespace = ?1`); `logical` - /// is `canonical_logical_namespace(caller_input, GLOBAL_NAMESPACE)` — the - /// exact value the write path bound into the `logical_namespace` column - /// (see `upsert_document_presanitized`). Two distinct logical names can - /// sanitize to the same physical address (`a:b_c` and `a_b:c` both become - /// `a_b_c`); without this clause a read addressed to one would also surface - /// -- and a `get`/`forget` addressed to one could delete -- the other's - /// rows, because both share `ns`. Filtering on `logical_namespace` too - /// keeps the two apart. - /// - /// `OR logical_namespace IS NULL` is required, not incidental: rows - /// written before this column existed have it NULL and never get a value - /// reconstructed (a sanitized `_` cannot be un-collapsed into whatever - /// delimiter it replaced), so excluding NULL rows outright would make - /// every pre-migration row permanently unaddressable by `get`/`forget` and - /// invisible to `list`, silently breaking every caller relying on them. - const LOGICAL_NAMESPACE_FILTER: &'static str = - "(logical_namespace = ?logical OR logical_namespace IS NULL)"; +// Filters an addressed row query on the row's **logical** namespace, not +// just its physical one. +// +// `ns` is the sanitized storage address (bound to `?1`, `WHERE namespace = +// ?1`); `logical` is `canonical_logical_namespace(caller_input, +// GLOBAL_NAMESPACE)` — the exact value the write path bound into the +// `logical_namespace` column (see `upsert_document_presanitized`). Two +// distinct logical names can sanitize to the same physical address (`a:b_c` +// and `a_b:c` both become `a_b_c`); without this clause a read addressed to +// one would also surface — and a `get`/`forget` addressed to one could +// delete — the other's rows, because both share `ns`. Filtering on +// `logical_namespace` too keeps the two apart. +// +// `OR logical_namespace IS NULL` is required, not incidental: rows written +// before this column existed have it NULL and never get a value +// reconstructed (a sanitized `_` cannot be un-collapsed into whatever +// delimiter it replaced), so excluding NULL rows outright would make every +// pre-migration row permanently unaddressable by `get`/`forget` and +// invisible to `list`, silently breaking every caller relying on them. +const LOGICAL_NAMESPACE_FILTER_SQL: &str = "(logical_namespace = ?2 OR logical_namespace IS NULL)"; +impl UnifiedMemory { fn get_blocking( conn: &Arc>, ns: &str, @@ -346,10 +346,9 @@ impl UnifiedMemory { .query_row( &format!( "SELECT document_id, key, content, updated_at, category, taint, session_id, logical_namespace - FROM memory_docs WHERE namespace = ?1 AND key = ?2 AND {} LIMIT 1", - Self::LOGICAL_NAMESPACE_FILTER.replace("?logical", "?3") + FROM memory_docs WHERE namespace = ?1 AND key = ?3 AND {LOGICAL_NAMESPACE_FILTER_SQL} LIMIT 1" ), - params![ns, key, logical], + params![ns, logical, key], |row| { Ok(( row.get(0)?, @@ -403,8 +402,7 @@ impl UnifiedMemory { let conn = conn.lock(); let mut stmt = conn.prepare(&format!( "SELECT document_id, key, content, category, session_id, updated_at, taint, logical_namespace - FROM memory_docs WHERE namespace = ?1 AND {} ORDER BY updated_at DESC", - Self::LOGICAL_NAMESPACE_FILTER.replace("?logical", "?2") + FROM memory_docs WHERE namespace = ?1 AND {LOGICAL_NAMESPACE_FILTER_SQL} ORDER BY updated_at DESC" ))?; let rows = stmt.query_map(params![ns, logical], |row| { let stored_category: String = row.get(3)?; @@ -441,10 +439,9 @@ impl UnifiedMemory { Ok(conn .query_row( &format!( - "SELECT document_id FROM memory_docs WHERE namespace = ?1 AND key = ?2 AND {} LIMIT 1", - Self::LOGICAL_NAMESPACE_FILTER.replace("?logical", "?3") + "SELECT document_id FROM memory_docs WHERE namespace = ?1 AND key = ?3 AND {LOGICAL_NAMESPACE_FILTER_SQL} LIMIT 1" ), - params![ns, key, logical], + params![ns, logical, key], |row| row.get(0), ) .optional()?) From 46ae0ccf2bdca0548cd21b287c596b58fd6e9690 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:09:51 +0300 Subject: [PATCH 043/106] chore(core): update memory store trait Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinymemory-core/src/store/memory_trait.rs | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/crates/tinymemory-core/src/store/memory_trait.rs b/crates/tinymemory-core/src/store/memory_trait.rs index 9858e73..0ebe88a 100644 --- a/crates/tinymemory-core/src/store/memory_trait.rs +++ b/crates/tinymemory-core/src/store/memory_trait.rs @@ -619,9 +619,15 @@ impl Memory for UnifiedMemory { // changed anything — the caller then reads the row as absent and stores // it again, which is the retry loop behind #5164. let ns = UnifiedMemory::sanitize_namespace(namespace); + // The same delimiter-preserving logical name the write path bound + // into `logical_namespace` (`canonical_logical_namespace`), so `get` + // addresses the row by both its physical and logical identity — see + // `LOGICAL_NAMESPACE_FILTER_SQL`'s doc comment for why the physical + // address alone is not enough. + let logical = crate::store::safety::canonical_logical_namespace(namespace, GLOBAL_NAMESPACE); let key = crate::store::safety::canonical_document_key(key); let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || Self::get_blocking(&conn, &ns, &key)) + tokio::task::spawn_blocking(move || Self::get_blocking(&conn, &ns, &logical, &key)) .await .context("join Memory::get")? } @@ -632,12 +638,20 @@ impl Memory for UnifiedMemory { category: Option<&MemoryCategory>, session_id: Option<&str>, ) -> anyhow::Result> { - let ns = UnifiedMemory::sanitize_namespace(normalize_namespace(namespace)); + let normalized = normalize_namespace(namespace); + let ns = UnifiedMemory::sanitize_namespace(normalized); + let logical = crate::store::safety::canonical_logical_namespace(normalized, GLOBAL_NAMESPACE); let category = category.cloned(); let session_id = session_id.map(str::to_owned); let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { - Self::list_blocking(&conn, &ns, category.as_ref(), session_id.as_deref()) + Self::list_blocking( + &conn, + &ns, + &logical, + category.as_ref(), + session_id.as_deref(), + ) }) .await .context("join Memory::list")? @@ -648,13 +662,17 @@ impl Memory for UnifiedMemory { // addresses the raw caller identifiers can never delete a row whose // namespace or key was canonicalized on the way in. let ns = UnifiedMemory::sanitize_namespace(namespace); + let logical = crate::store::safety::canonical_logical_namespace(namespace, GLOBAL_NAMESPACE); let key = crate::store::safety::canonical_document_key(key); let row: Option = { let conn = Arc::clone(&self.conn); let ns = ns.clone(); - tokio::task::spawn_blocking(move || Self::forget_lookup_blocking(&conn, &ns, &key)) - .await - .context("join Memory::forget")?? + let logical = logical.clone(); + tokio::task::spawn_blocking(move || { + Self::forget_lookup_blocking(&conn, &ns, &logical, &key) + }) + .await + .context("join Memory::forget")?? }; let Some(document_id) = row else { return Ok(false); From 92c8e98ef78356b32049d3b5ee9318b80ceb2757 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:10:16 +0300 Subject: [PATCH 044/106] chore(core): update memory store trait Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinymemory-core/src/store/memory_trait.rs | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/crates/tinymemory-core/src/store/memory_trait.rs b/crates/tinymemory-core/src/store/memory_trait.rs index 0ebe88a..cb9177d 100644 --- a/crates/tinymemory-core/src/store/memory_trait.rs +++ b/crates/tinymemory-core/src/store/memory_trait.rs @@ -459,24 +459,34 @@ impl UnifiedMemory { // `_`), so guessing would silently mislabel unrelated namespaces — // NULL rows simply keep reporting their sanitized address. // - // `GROUP BY namespace` (the storage address), not `ns` (the logical - // name): two distinct logical names can sanitize to the same address + // `GROUP BY COALESCE(logical_namespace, namespace)` — the logical + // name, not the raw storage address — so two distinct logical names + // that happen to sanitize to the same physical address // (`conversation:x` and `conversation_x` both sanitize to - // `conversation_x`), and those rows are already merged into one - // physical namespace by every addressed call (`list`, `export`, ...). - // Grouping by the logical name instead would split one physical - // namespace's rows across two summaries with two partial counts, - // while every addressed call still visits the single merged - // namespace and returns the union — double-counting it if a caller - // then lists each reported summary in turn. Grouping by the address - // keeps one summary per physical namespace with an accurate count; - // `MIN(logical_namespace)` (aggregate `MIN` ignores `NULL`) just picks - // a single, deterministic logical representative for it when more - // than one exists. + // `conversation_x`) get two separate summaries with their own counts. + // + // This used to group by `namespace` (the address) instead, on the + // reasoning that every addressed call already merged aliased rows + // into one physical namespace, so grouping by logical name would + // split one merged namespace's rows across two summaries with two + // partial counts. That reasoning no longer holds: `list`/`get`/ + // `forget` now filter on `logical_namespace` too (see + // `LOGICAL_NAMESPACE_FILTER_SQL`), so an addressed call for one + // logical name only ever returns that name's own rows. Grouping + // summaries by address here, while reads are scoped by logical name, + // would report one summary for both aliases while `list` on that + // reported name only ever returns half its count — and would hide + // the other alias from enumeration entirely, exactly the leak this + // fixes. + // + // Legacy rows with `logical_namespace IS NULL` still group by their + // physical address (`COALESCE` falls through to `namespace`), which + // matches what `list`'s `OR logical_namespace IS NULL` arm returns + // for that address. let mut stmt = conn.prepare( - "SELECT COALESCE(MIN(logical_namespace), namespace) AS ns, COUNT(*) AS n, MAX(updated_at) AS last + "SELECT COALESCE(logical_namespace, namespace) AS ns, COUNT(*) AS n, MAX(updated_at) AS last FROM memory_docs - GROUP BY namespace + GROUP BY COALESCE(logical_namespace, namespace) ORDER BY ns", )?; let rows = stmt.query_map([], |row| { From de53632fdfc03d3ae001510343ea2efc0d247848 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:10:50 +0300 Subject: [PATCH 045/106] chore(core): update memory store trait Update the memory store trait definition to support the revised core storage behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/memory_trait.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/tinymemory-core/src/store/memory_trait.rs b/crates/tinymemory-core/src/store/memory_trait.rs index cb9177d..74b68ba 100644 --- a/crates/tinymemory-core/src/store/memory_trait.rs +++ b/crates/tinymemory-core/src/store/memory_trait.rs @@ -300,8 +300,18 @@ impl UnifiedMemory { // be `'static` and cannot borrow the store. /// One `memory_docs` row as `get` selects it: -/// `(document_id, key, content, updated_at, category, taint, session_id)`. -type MemoryDocRow = (String, String, String, f64, String, String, Option); +/// `(document_id, key, content, updated_at, category, taint, session_id, +/// logical_namespace)`. +type MemoryDocRow = ( + String, + String, + String, + f64, + String, + String, + Option, + Option, +); // Filters an addressed row query on the row's **logical** namespace, not // just its physical one. From 9b8c1300ea17707a1b6c571173c92d0b3e7c298c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:11:03 +0300 Subject: [PATCH 046/106] chore: update memory trait Update the memory trait implementation to support the latest store behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/memory_trait.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinymemory-core/src/store/memory_trait.rs b/crates/tinymemory-core/src/store/memory_trait.rs index 74b68ba..1d73292 100644 --- a/crates/tinymemory-core/src/store/memory_trait.rs +++ b/crates/tinymemory-core/src/store/memory_trait.rs @@ -352,7 +352,7 @@ impl UnifiedMemory { // reports the row's own logical name rather than the physical address // this method happens to have been called with — see `list_blocking`'s // doc comment for why that distinction matters. - let row: Option<(String, String, String, f64, String, String, Option, Option)> = conn + let row: Option = conn .query_row( &format!( "SELECT document_id, key, content, updated_at, category, taint, session_id, logical_namespace From 74f332c20c89fd885c5254a9e5d08a99495f2935 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:13:02 +0300 Subject: [PATCH 047/106] test(core): update memory trait tests Update the memory trait test coverage to reflect the current store behavior and maintain confidence in the implementation. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/memory_trait_tests.rs | 58 ++++++++++++++----- 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/crates/tinymemory-core/src/store/memory_trait_tests.rs b/crates/tinymemory-core/src/store/memory_trait_tests.rs index b7dc880..4eee2cc 100644 --- a/crates/tinymemory-core/src/store/memory_trait_tests.rs +++ b/crates/tinymemory-core/src/store/memory_trait_tests.rs @@ -258,14 +258,22 @@ async fn namespace_summaries_normalizes_blank_namespace_to_global() { assert_eq!(found.count, 1); } -/// Two logical names that sanitize to the same physical namespace +/// Two logical names that sanitize to the same physical address /// (`conversation:x` and `conversation_x` both collapse to -/// `conversation_x`) must not split into two summaries with two partial -/// counts: every addressed call (`list`, `export`, ...) already merges -/// their rows into one physical namespace, so `namespace_summaries` must -/// report exactly one entry with the true, combined count. +/// `conversation_x`) are NOT the same namespace and must not be merged: +/// `namespace_summaries` must report one summary per logical name, each +/// with only its own rows counted, and `list` addressed to one logical name +/// must return only that name's rows — never the other alias's. +/// +/// This is the corrected behavior. Before `list`/`get`/`forget` filtered on +/// `logical_namespace` (not just the physical `namespace` column), both +/// aliases' rows were indistinguishable once written, so every addressed +/// call silently merged them: listing `conversation:x` also returned +/// `conversation_x`'s rows mislabelled as belonging to it, and +/// `namespace_summaries` reported one summary hiding the losing alias +/// entirely from enumeration. #[tokio::test] -async fn namespace_summaries_deduplicates_when_two_logical_names_alias_one_address() { +async fn logical_namespaces_stay_isolated_when_they_alias_one_physical_address() { let (_tmp, mem) = fresh_mem(); mem.store("conversation:x", "k1", "a", MemoryCategory::Core, None) .await @@ -274,21 +282,39 @@ async fn namespace_summaries_deduplicates_when_two_logical_names_alias_one_addre .await .unwrap(); + // Each logical name gets its own summary with its own count — neither + // hides nor merges with the other. let summaries = mem.namespace_summaries().await.unwrap(); - let matching: Vec<_> = summaries + let colon = summaries .iter() - .filter(|s| s.namespace == "conversation:x" || s.namespace == "conversation_x") - .collect(); + .find(|s| s.namespace == "conversation:x") + .unwrap_or_else(|| panic!("expected `conversation:x` in {summaries:?}")); + let underscore = summaries + .iter() + .find(|s| s.namespace == "conversation_x") + .unwrap_or_else(|| panic!("expected `conversation_x` in {summaries:?}")); + assert_eq!(colon.count, 1); + assert_eq!(underscore.count, 1); + + // Listing one alias must return only its own row, not the other's. + let listed_colon = mem.list(Some("conversation:x"), None, None).await.unwrap(); + assert_eq!(listed_colon.len(), 1); + assert_eq!(listed_colon[0].key, "k1"); + assert_eq!(listed_colon[0].namespace.as_deref(), Some("conversation:x")); + + let listed_underscore = mem.list(Some("conversation_x"), None, None).await.unwrap(); + assert_eq!(listed_underscore.len(), 1); + assert_eq!(listed_underscore[0].key, "k2"); assert_eq!( - matching.len(), - 1, - "expected exactly one summary for the aliased address, got {summaries:?}" + listed_underscore[0].namespace.as_deref(), + Some("conversation_x") ); - assert_eq!(matching[0].count, 2); - // Both aliases still address the same merged physical namespace. - let listed = mem.list(Some("conversation:x"), None, None).await.unwrap(); - assert_eq!(listed.len(), 2); + // `get`/`forget` must not cross the alias boundary either. + assert!(mem.get("conversation:x", "k2").await.unwrap().is_none()); + assert!(mem.get("conversation_x", "k1").await.unwrap().is_none()); + assert!(!mem.forget("conversation:x", "k2").await.unwrap()); + assert!(mem.get("conversation_x", "k2").await.unwrap().is_some()); } /// `canonical_identifier`'s `[REDACTED_PII_*]` placeholder is valid storage From 7e485b26af525d000312a03426b15a6b71135691 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:13:28 +0300 Subject: [PATCH 048/106] test(core): update memory trait tests Update the memory trait test coverage to reflect the current behavior and ensure the store implementation remains validated. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/memory_trait_tests.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/tinymemory-core/src/store/memory_trait_tests.rs b/crates/tinymemory-core/src/store/memory_trait_tests.rs index 4eee2cc..7fba2be 100644 --- a/crates/tinymemory-core/src/store/memory_trait_tests.rs +++ b/crates/tinymemory-core/src/store/memory_trait_tests.rs @@ -172,12 +172,18 @@ async fn sectioned_namespace_stays_addressable_by_its_original_string() { .await .unwrap(); - let got = mem.get(namespace, "k1").await.unwrap(); - assert_eq!(got.unwrap().content, "we should ship on friday"); + let got = mem.get(namespace, "k1").await.unwrap().unwrap(); + assert_eq!(got.content, "we should ship on friday"); + // The returned entry must report the row's own sectioned (logical) name, + // not the sanitized physical address (`conversation_thread-8f21`) it is + // actually stored under — a caller that fed this back into `get`/`list` + // must land on the same row, not a different, unsectioned one. + assert_eq!(got.namespace.as_deref(), Some(namespace)); let listed = mem.list(Some(namespace), None, None).await.unwrap(); assert_eq!(listed.len(), 1); assert_eq!(listed[0].key, "k1"); + assert_eq!(listed[0].namespace.as_deref(), Some(namespace)); let recalled = mem .recall( From bdef1e72533c4dd1530b58d0d5df1a7df9537b82 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:14:16 +0300 Subject: [PATCH 049/106] style(core): format canonical namespace calls Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/memory_trait.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/tinymemory-core/src/store/memory_trait.rs b/crates/tinymemory-core/src/store/memory_trait.rs index 1d73292..63e004e 100644 --- a/crates/tinymemory-core/src/store/memory_trait.rs +++ b/crates/tinymemory-core/src/store/memory_trait.rs @@ -644,7 +644,8 @@ impl Memory for UnifiedMemory { // addresses the row by both its physical and logical identity — see // `LOGICAL_NAMESPACE_FILTER_SQL`'s doc comment for why the physical // address alone is not enough. - let logical = crate::store::safety::canonical_logical_namespace(namespace, GLOBAL_NAMESPACE); + let logical = + crate::store::safety::canonical_logical_namespace(namespace, GLOBAL_NAMESPACE); let key = crate::store::safety::canonical_document_key(key); let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || Self::get_blocking(&conn, &ns, &logical, &key)) @@ -660,7 +661,8 @@ impl Memory for UnifiedMemory { ) -> anyhow::Result> { let normalized = normalize_namespace(namespace); let ns = UnifiedMemory::sanitize_namespace(normalized); - let logical = crate::store::safety::canonical_logical_namespace(normalized, GLOBAL_NAMESPACE); + let logical = + crate::store::safety::canonical_logical_namespace(normalized, GLOBAL_NAMESPACE); let category = category.cloned(); let session_id = session_id.map(str::to_owned); let conn = Arc::clone(&self.conn); @@ -682,7 +684,8 @@ impl Memory for UnifiedMemory { // addresses the raw caller identifiers can never delete a row whose // namespace or key was canonicalized on the way in. let ns = UnifiedMemory::sanitize_namespace(namespace); - let logical = crate::store::safety::canonical_logical_namespace(namespace, GLOBAL_NAMESPACE); + let logical = + crate::store::safety::canonical_logical_namespace(namespace, GLOBAL_NAMESPACE); let key = crate::store::safety::canonical_document_key(key); let row: Option = { let conn = Arc::clone(&self.conn); From bd99b181ec6c0c3d3470d528a83c78ebeda6cd7a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:16:52 +0300 Subject: [PATCH 050/106] docs(memory): update section API documentation Clarify the memory section API behavior and usage so developers can integrate it correctly. Auto-committed-on: dragonfly Co-authored-by: Medulla --- docs/specs/memory-section-api.md | 35 ++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/specs/memory-section-api.md b/docs/specs/memory-section-api.md index f9f5727..dcd9f5a 100644 --- a/docs/specs/memory-section-api.md +++ b/docs/specs/memory-section-api.md @@ -182,6 +182,41 @@ to turn an old `_` back into a `:` — that mapping is not invertible, because a scope may legitimately contain `_`, and guessing would silently relabel unrelated namespaces into a section they were never written to. +The physical address is not injective — `a:b_c` and `a_b:c` both sanitize to +`a_b_c` — so the logical column has to do more than label a summary. `get`, +`list`, and `forget` filter on it too: each addressed read is +`WHERE namespace = ?1 AND (logical_namespace = ?2 OR logical_namespace IS +NULL)`, not `WHERE namespace = ?1` alone. Without the second predicate, listing +`a:b_c` would also surface `a_b:c`'s rows — mislabelled as belonging to the +section that was listed, not the one that wrote them — and the two logical +names would be indistinguishable once written. The `OR logical_namespace IS +NULL` arm is required, not incidental: it is what keeps a pre-migration NULL +row visible under its sanitised address, matching the backfill guarantee above. +Every returned `MemoryEntry.namespace` is the row's own logical name (falling +back to the physical address only for a NULL row), never the caller's query +namespace, so the physical address stays an internal storage detail that never +reaches a `MemoryEntry`. + +`namespace_summaries` groups by `COALESCE(logical_namespace, namespace)` for +the same reason: once reads are scoped by logical name, two logical names that +alias one physical address must report two summaries, each with its own count, +or `list` on one reported name would return only half its count while the +other alias never appears in enumeration at all. + +One trade-off follows directly from filtering `get`/`forget` on the logical +name: the `UNIQUE(namespace, key)` constraint is still keyed on the physical +address only, so two colliding logical namespaces writing the *same* key still +collide at the storage layer — `ON CONFLICT(namespace, key) DO UPDATE` still +overwrites the row, and `logical_namespace` is set to whichever logical name +wrote it last. A caller addressing that key by the losing logical name's `get` +now returns `None` (the row's `logical_namespace` no longer matches) rather +than the pre-fix behaviour of silently reading the winning write's content. +This surfaces the collision instead of hiding it, but does not resolve it: two +distinct logical namespaces sharing a physical address can still contend for +one key. `idx_memory_docs_ns_updated` is unaffected — it still indexes +`(namespace, updated_at DESC)`, which every addressed query still filters on +first. + `assert_namespaces_preserve_their_section` in the conformance suite now holds every *retaining* driver to this: a namespace written in a section must be reported back in that section. It is skipped for a driver that retains nothing, From 3802053052ecfc2e03a1759d69bd8e30264ea3d3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:17:09 +0300 Subject: [PATCH 051/106] docs(memory): update memory section API Clarify the memory section API specification to make its intended behavior easier to understand. Auto-committed-on: dragonfly Co-authored-by: Medulla --- docs/specs/memory-section-api.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/specs/memory-section-api.md b/docs/specs/memory-section-api.md index dcd9f5a..4d40bd5 100644 --- a/docs/specs/memory-section-api.md +++ b/docs/specs/memory-section-api.md @@ -275,6 +275,9 @@ unnoticed. and a PII-bearing namespace is still redacted in both columns. - The `logical_namespace` migration is idempotent, and a row predating it still enumerates under its sanitised name. +- Two logical namespaces that sanitize to the same physical address stay + isolated from each other in `get`, `list`, `forget`, and `namespace_summaries` + — neither's rows are mislabelled as, merged with, or hidden by the other's. - The four contract commands pass, and rustdoc builds with `-D warnings`. ## Open questions From 819f7ecd3a5624128dc69c324ef03256fb214e98 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:30:41 +0300 Subject: [PATCH 052/106] fix(store): restrict legacy namespace matches to exact addresses Gate legacy NULL namespace rows on matching their physical address to the requested logical name. This prevents sanitized aliases from exposing pre-migration rows across logical namespaces. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinymemory-core/src/store/memory_trait.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/tinymemory-core/src/store/memory_trait.rs b/crates/tinymemory-core/src/store/memory_trait.rs index 63e004e..80d77d7 100644 --- a/crates/tinymemory-core/src/store/memory_trait.rs +++ b/crates/tinymemory-core/src/store/memory_trait.rs @@ -326,13 +326,26 @@ type MemoryDocRow = ( // delete — the other's rows, because both share `ns`. Filtering on // `logical_namespace` too keeps the two apart. // -// `OR logical_namespace IS NULL` is required, not incidental: rows written -// before this column existed have it NULL and never get a value +// The `logical_namespace IS NULL` arm is required, not incidental: rows +// written before this column existed have it NULL and never get a value // reconstructed (a sanitized `_` cannot be un-collapsed into whatever // delimiter it replaced), so excluding NULL rows outright would make every // pre-migration row permanently unaddressable by `get`/`forget` and // invisible to `list`, silently breaking every caller relying on them. -const LOGICAL_NAMESPACE_FILTER_SQL: &str = "(logical_namespace = ?2 OR logical_namespace IS NULL)"; +// +// That NULL arm is gated on `?1 = ?2` (physical address equals the supplied +// logical name), not left unconditional. A legacy NULL row's `namespace` +// column is its own only identity — it has no recorded logical name — so it +// must surface only when the caller addressed it *by that physical name* +// directly, i.e. a caller whose logical name happens to equal the physical +// address (the common case: a plain namespace with no delimiter-sanitized +// characters). An unconditional `OR logical_namespace IS NULL` let the row +// match ANY logical name that sanitizes to its address — so a pre-migration +// row stored under `a_b_c` surfaced under `a:b_c`, `a_b:c`, and every other +// alias, reintroducing the exact cross-section leak this column exists to +// close, just for legacy rows instead of new ones. +const LOGICAL_NAMESPACE_FILTER_SQL: &str = + "(logical_namespace = ?2 OR (logical_namespace IS NULL AND ?1 = ?2))"; impl UnifiedMemory { fn get_blocking( From 4b982f9566ececff06f4286d11beb4e474b762b4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:32:14 +0300 Subject: [PATCH 053/106] refactor(store): reuse logical namespace filter Use the shared logical namespace filter instead of defining it locally. This centralizes the namespace matching logic without changing behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinymemory-core/src/store/memory_trait.rs | 34 +------------------ 1 file changed, 1 insertion(+), 33 deletions(-) diff --git a/crates/tinymemory-core/src/store/memory_trait.rs b/crates/tinymemory-core/src/store/memory_trait.rs index 80d77d7..49b2f7c 100644 --- a/crates/tinymemory-core/src/store/memory_trait.rs +++ b/crates/tinymemory-core/src/store/memory_trait.rs @@ -313,39 +313,7 @@ type MemoryDocRow = ( Option, ); -// Filters an addressed row query on the row's **logical** namespace, not -// just its physical one. -// -// `ns` is the sanitized storage address (bound to `?1`, `WHERE namespace = -// ?1`); `logical` is `canonical_logical_namespace(caller_input, -// GLOBAL_NAMESPACE)` — the exact value the write path bound into the -// `logical_namespace` column (see `upsert_document_presanitized`). Two -// distinct logical names can sanitize to the same physical address (`a:b_c` -// and `a_b:c` both become `a_b_c`); without this clause a read addressed to -// one would also surface — and a `get`/`forget` addressed to one could -// delete — the other's rows, because both share `ns`. Filtering on -// `logical_namespace` too keeps the two apart. -// -// The `logical_namespace IS NULL` arm is required, not incidental: rows -// written before this column existed have it NULL and never get a value -// reconstructed (a sanitized `_` cannot be un-collapsed into whatever -// delimiter it replaced), so excluding NULL rows outright would make every -// pre-migration row permanently unaddressable by `get`/`forget` and -// invisible to `list`, silently breaking every caller relying on them. -// -// That NULL arm is gated on `?1 = ?2` (physical address equals the supplied -// logical name), not left unconditional. A legacy NULL row's `namespace` -// column is its own only identity — it has no recorded logical name — so it -// must surface only when the caller addressed it *by that physical name* -// directly, i.e. a caller whose logical name happens to equal the physical -// address (the common case: a plain namespace with no delimiter-sanitized -// characters). An unconditional `OR logical_namespace IS NULL` let the row -// match ANY logical name that sanitizes to its address — so a pre-migration -// row stored under `a_b_c` surfaced under `a:b_c`, `a_b:c`, and every other -// alias, reintroducing the exact cross-section leak this column exists to -// close, just for legacy rows instead of new ones. -const LOGICAL_NAMESPACE_FILTER_SQL: &str = - "(logical_namespace = ?2 OR (logical_namespace IS NULL AND ?1 = ?2))"; +use crate::store::safety::LOGICAL_NAMESPACE_FILTER_SQL; impl UnifiedMemory { fn get_blocking( From cbfa06622374c300cb960de4854a5046b5d73b79 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:32:30 +0300 Subject: [PATCH 054/106] fix(safety): filter reads by logical namespace Add a shared predicate that matches rows by both physical and logical namespace. This prevents sanitized namespace collisions from leaking or deleting documents while preserving direct access to legacy rows without logical namespace data. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinymemory-core/src/store/safety/mod.rs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/crates/tinymemory-core/src/store/safety/mod.rs b/crates/tinymemory-core/src/store/safety/mod.rs index 940bd53..ec90375 100644 --- a/crates/tinymemory-core/src/store/safety/mod.rs +++ b/crates/tinymemory-core/src/store/safety/mod.rs @@ -103,6 +103,41 @@ pub fn canonical_document_key(key: &str) -> String { canonical_identifier(key.trim()) } +/// The addressed-read predicate every `memory_docs` row query filters on, +/// beyond `WHERE namespace = ?1`: `?2` is the caller's logical namespace +/// (`canonical_logical_namespace(caller_input, GLOBAL_NAMESPACE)` — the exact +/// value the write path bound into the `logical_namespace` column, see +/// `upsert_document_presanitized`). +/// +/// The physical storage address is not injective: `a:b_c` and `a_b:c` both +/// sanitize to `a_b_c`. Without this second predicate, a read addressed to +/// one logical name would also surface — and a `get`/`forget` addressed to +/// one could delete — the other's rows, because both share the physical +/// `namespace` column. Filtering on `logical_namespace` too keeps the two +/// apart. Used by every addressed read on `memory_docs`: `get`, `list`, +/// `forget`, and the query path backing `Memory::recall`. +/// +/// The `logical_namespace IS NULL` arm covers rows written before this +/// column existed: a sanitized `_` cannot be un-collapsed into whatever +/// delimiter it replaced, so those rows never get a logical name +/// reconstructed, and excluding them outright would make every pre-migration +/// row permanently unaddressable by `get`/`forget`/`recall` and invisible to +/// `list`. +/// +/// That NULL arm is gated on `?1 = ?2` (the physical address equals the +/// supplied logical name), not left unconditional. A legacy NULL row's +/// `namespace` column is its only identity — it has no recorded logical +/// name — so it must surface only when the caller addressed it *by that +/// physical name* directly (the common case: a plain namespace with no +/// delimiter-sanitized characters). An unconditional `OR logical_namespace +/// IS NULL` let the row match ANY logical name that sanitizes to its +/// address, so a pre-migration row stored under `a_b_c` surfaced under +/// `a:b_c`, `a_b:c`, and every other alias — reintroducing the exact +/// cross-section leak this column exists to close, just for legacy rows +/// instead of new ones. +pub(crate) const LOGICAL_NAMESPACE_FILTER_SQL: &str = + "(logical_namespace = ?2 OR (logical_namespace IS NULL AND ?1 = ?2))"; + /// Scrub a namespace-document input, field by field, via the crate scrubbers. /// /// Sanitization is content-cleaning only; provenance `taint` survives untouched From dced0b31eedb063368a226f48cace5cac467efbb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:32:51 +0300 Subject: [PATCH 055/106] refactor(store): organize safety filter import Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/memory_trait.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/tinymemory-core/src/store/memory_trait.rs b/crates/tinymemory-core/src/store/memory_trait.rs index 49b2f7c..aefee33 100644 --- a/crates/tinymemory-core/src/store/memory_trait.rs +++ b/crates/tinymemory-core/src/store/memory_trait.rs @@ -18,6 +18,7 @@ use rusqlite::{params, Connection, OptionalExtension}; use serde_json::json; use crate::store::namespace_store::fts5; +use crate::store::safety::LOGICAL_NAMESPACE_FILTER_SQL; use crate::store::types::{NamespaceDocumentInput, GLOBAL_NAMESPACE}; use crate::traits::{ Memory, MemoryCategory, MemoryEntry, MemoryTaint, NamespaceSummary, RecallOpts, @@ -313,8 +314,6 @@ type MemoryDocRow = ( Option, ); -use crate::store::safety::LOGICAL_NAMESPACE_FILTER_SQL; - impl UnifiedMemory { fn get_blocking( conn: &Arc>, From bc9d25ea82d3bc1984f5dce6eadd53f6db5884fc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:33:52 +0300 Subject: [PATCH 056/106] fix(namespace): filter scoped loads by logical namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add logical namespace filtering to scoped document loads so sanitized namespace aliases cannot expose each other’s rows. Share row deserialization between the filtered and unfiltered query paths to keep their result handling consistent. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/documents.rs | 126 ++++++++++++++---- 1 file changed, 98 insertions(+), 28 deletions(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/documents.rs b/crates/tinymemory-core/src/store/namespace_store/documents.rs index d44c902..6126822 100644 --- a/crates/tinymemory-core/src/store/namespace_store/documents.rs +++ b/crates/tinymemory-core/src/store/namespace_store/documents.rs @@ -475,38 +475,108 @@ impl UnifiedMemory { .next() .map_err(|e| format!("row load_documents_for_scope: {e}"))? { - let tags_json: String = row.get(7).map_err(|e| e.to_string())?; - let metadata_json: String = row.get(8).map_err(|e| e.to_string())?; - // The `taint` column has a NOT NULL DEFAULT 'internal' clause - // from the migration, so legacy rows that pre-date the column - // surface as "internal" string and round-trip back to - // `MemoryTaint::Internal`. Unknown / corrupted values fail - // closed to `MemoryTaint::ExternalSync` inside `from_db_str`, - // so a forward-rolled schema variant or a bad UPDATE can't - // silently downgrade a row to user-authored content. - let taint_str: String = row.get(14).map_err(|e| e.to_string())?; - let taint = crate::MemoryTaint::from_db_str(&taint_str); - docs.push(StoredMemoryDocument { - document_id: row.get(0).map_err(|e| e.to_string())?, - namespace: row.get(1).map_err(|e| e.to_string())?, - key: row.get(2).map_err(|e| e.to_string())?, - title: row.get(3).map_err(|e| e.to_string())?, - content: row.get(4).map_err(|e| e.to_string())?, - source_type: row.get(5).map_err(|e| e.to_string())?, - priority: row.get(6).map_err(|e| e.to_string())?, - tags: serde_json::from_str(&tags_json).unwrap_or_default(), - metadata: serde_json::from_str(&metadata_json).unwrap_or_else(|_| json!({})), - category: row.get(9).map_err(|e| e.to_string())?, - session_id: row.get(10).map_err(|e| e.to_string())?, - created_at: row.get(11).map_err(|e| e.to_string())?, - updated_at: row.get(12).map_err(|e| e.to_string())?, - markdown_rel_path: row.get(13).map_err(|e| e.to_string())?, - taint, - }); + docs.push(Self::row_to_stored_document(row)?); } Ok(docs) } + /// Same as [`Self::load_documents_for_scope`], but also filters on the + /// row's **logical** namespace via [`safety::LOGICAL_NAMESPACE_FILTER_SQL`]. + /// + /// `load_documents_for_scope` addresses only the physical `namespace` + /// column, and the physical address is not injective (`a:b_c` and + /// `a_b:c` both sanitize to `a_b_c`), so it would surface both aliases' + /// rows for either caller. This is what `Memory::recall` uses instead — + /// the query path is otherwise identical, so a caller pinned to one + /// logical namespace (`SectionRecall::in_scope`, `SectionRecall::across_section`) + /// only ever scores that namespace's own rows, matching the isolation + /// `get`/`list`/`forget` already have. + /// + /// `namespace` and `logical` are the same pair `Memory::get`/`Memory::list` + /// bind: `namespace` is the caller's raw/logical namespace string (used + /// here to derive the physical address), and `logical` is + /// `canonical_logical_namespace(namespace, GLOBAL_NAMESPACE)` — the exact + /// value the write path bound into `logical_namespace`. + pub(crate) async fn load_documents_for_scope_matching_logical( + &self, + namespace: &str, + logical: &str, + ) -> Result, String> { + let conn = self.conn.lock(); + let ns = Self::sanitize_namespace(namespace); + let mut stmt = conn + .prepare(&format!( + "SELECT + document_id, + namespace, + key, + title, + content, + source_type, + priority, + tags_json, + metadata_json, + category, + session_id, + created_at, + updated_at, + markdown_rel_path, + taint + FROM memory_docs + WHERE namespace = ?1 AND {} + ORDER BY updated_at DESC", + safety::LOGICAL_NAMESPACE_FILTER_SQL + )) + .map_err(|e| format!("prepare load_documents_for_scope_matching_logical: {e}"))?; + let mut rows = stmt + .query(params![ns, logical]) + .map_err(|e| format!("query load_documents_for_scope_matching_logical: {e}"))?; + let mut docs = Vec::new(); + while let Some(row) = rows + .next() + .map_err(|e| format!("row load_documents_for_scope_matching_logical: {e}"))? + { + docs.push(Self::row_to_stored_document(row)?); + } + Ok(docs) + } + + /// Map one `memory_docs` row, in the column order both + /// [`Self::load_documents_for_scope`] and + /// [`Self::load_documents_for_scope_matching_logical`] select it in, into a + /// [`StoredMemoryDocument`]. Single-sourced so the two queries' row shapes + /// cannot drift apart silently. + fn row_to_stored_document(row: &rusqlite::Row<'_>) -> Result { + let tags_json: String = row.get(7).map_err(|e| e.to_string())?; + let metadata_json: String = row.get(8).map_err(|e| e.to_string())?; + // The `taint` column has a NOT NULL DEFAULT 'internal' clause + // from the migration, so legacy rows that pre-date the column + // surface as "internal" string and round-trip back to + // `MemoryTaint::Internal`. Unknown / corrupted values fail + // closed to `MemoryTaint::ExternalSync` inside `from_db_str`, + // so a forward-rolled schema variant or a bad UPDATE can't + // silently downgrade a row to user-authored content. + let taint_str: String = row.get(14).map_err(|e| e.to_string())?; + let taint = crate::MemoryTaint::from_db_str(&taint_str); + Ok(StoredMemoryDocument { + document_id: row.get(0).map_err(|e| e.to_string())?, + namespace: row.get(1).map_err(|e| e.to_string())?, + key: row.get(2).map_err(|e| e.to_string())?, + title: row.get(3).map_err(|e| e.to_string())?, + content: row.get(4).map_err(|e| e.to_string())?, + source_type: row.get(5).map_err(|e| e.to_string())?, + priority: row.get(6).map_err(|e| e.to_string())?, + tags: serde_json::from_str(&tags_json).unwrap_or_default(), + metadata: serde_json::from_str(&metadata_json).unwrap_or_else(|_| json!({})), + category: row.get(9).map_err(|e| e.to_string())?, + session_id: row.get(10).map_err(|e| e.to_string())?, + created_at: row.get(11).map_err(|e| e.to_string())?, + updated_at: row.get(12).map_err(|e| e.to_string())?, + markdown_rel_path: row.get(13).map_err(|e| e.to_string())?, + taint, + }) + } + /// List documents in a namespace, or across all namespaces when `None`. /// Returns `{ "documents": [...], "count": N }` JSON. pub async fn list_documents(&self, namespace: Option<&str>) -> Result { From 85f6e9c04d021ffd21ce8c7b901382010da75ece Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:34:17 +0300 Subject: [PATCH 057/106] fix(namespace): filter recall by logical namespace Restrict namespace recall to documents matching the requested logical namespace, preventing sanitized-name collisions from returning unrelated records. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/query.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/query.rs b/crates/tinymemory-core/src/store/namespace_store/query.rs index 8dee248..fd93079 100644 --- a/crates/tinymemory-core/src/store/namespace_store/query.rs +++ b/crates/tinymemory-core/src/store/namespace_store/query.rs @@ -9,9 +9,10 @@ use rusqlite::params; use std::collections::{HashMap, HashSet}; +use crate::store::safety; use crate::store::types::{ GraphRelationRecord, MemoryItemKind, NamespaceMemoryHit, NamespaceQueryResult, - NamespaceRetrievalContext, RetrievalScoreBreakdown, + NamespaceRetrievalContext, RetrievalScoreBreakdown, GLOBAL_NAMESPACE, }; use super::events; @@ -150,10 +151,23 @@ impl UnifiedMemory { exclude_session_id: Option<&str>, ) -> Result, String> { let ns = Self::sanitize_namespace(namespace); + // The physical address is not injective (`a:b_c` and `a_b:c` both + // sanitize to `a_b_c`), so addressing only by `ns` would score + // another logical namespace's rows into this recall. `namespace` + // here is still the caller's raw/logical string (this function's + // only caller, `Memory::recall` via `recall_excluding_session`, + // passes `normalize_namespace(opts.namespace)` straight through + // without sanitizing it), so the logical name is derived the same + // way the write path derived it — no new value threaded in from + // outside. See `safety::LOGICAL_NAMESPACE_FILTER_SQL` for why the + // `IS NULL` arm alone would over-match. + let logical = safety::canonical_logical_namespace(namespace, GLOBAL_NAMESPACE); let exclude_session_id = exclude_session_id .map(str::trim) .filter(|id| !id.is_empty()); - let mut docs = self.load_documents_for_scope(&ns).await?; + let mut docs = self + .load_documents_for_scope_matching_logical(&ns, &logical) + .await?; if let Some(exclude) = exclude_session_id { let before = docs.len(); docs.retain(|doc| doc.session_id.as_deref() != Some(exclude)); From b7d10b25d07b96fd9ed1aefb3dd0918fbe4d28fa Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:36:00 +0300 Subject: [PATCH 058/106] test(memory): cover namespace alias isolation in recall and list Add regression tests ensuring aliased logical namespaces do not leak results through recall or list. Verify legacy rows with null logical namespaces remain visible only under their exact physical name. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/memory_trait_tests.rs | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/crates/tinymemory-core/src/store/memory_trait_tests.rs b/crates/tinymemory-core/src/store/memory_trait_tests.rs index 7fba2be..a48e682 100644 --- a/crates/tinymemory-core/src/store/memory_trait_tests.rs +++ b/crates/tinymemory-core/src/store/memory_trait_tests.rs @@ -323,6 +323,143 @@ async fn logical_namespaces_stay_isolated_when_they_alias_one_physical_address() assert!(mem.get("conversation_x", "k2").await.unwrap().is_some()); } +/// The same aliasing hazard as +/// `logical_namespaces_stay_isolated_when_they_alias_one_physical_address`, +/// but through `recall` and with the exact pair from the report: `a:b_c` and +/// `a_b:c` both sanitize to `a_b_c` (`:` and the existing `_` both collapse +/// to `_`). Before `query_namespace_hits_excluding_session` filtered on +/// `logical_namespace` too, a `recall` pinned to one alias scored the +/// other's rows into its ranked results as well, because both load from the +/// same physical namespace. +#[tokio::test] +async fn recall_pinned_to_one_aliasing_namespace_does_not_score_the_others_rows() { + let (_tmp, mem) = fresh_mem(); + assert_eq!( + UnifiedMemory::sanitize_namespace("a:b_c"), + UnifiedMemory::sanitize_namespace("a_b:c"), + "test fixture assumption: both must sanitize to the same physical address" + ); + + mem.store( + "a:b_c", + "k1", + "the roadmap ships on friday", + MemoryCategory::Core, + None, + ) + .await + .unwrap(); + mem.store( + "a_b:c", + "k2", + "the roadmap ships on monday", + MemoryCategory::Core, + None, + ) + .await + .unwrap(); + + let recalled_first = mem + .recall( + "roadmap", + 10, + RecallOpts { + namespace: Some("a:b_c"), + min_score: Some(0.0), + ..Default::default() + }, + ) + .await + .unwrap(); + assert!( + recalled_first.iter().any(|e| e.key == "k1"), + "recall must still find the namespace's own row, got {recalled_first:#?}" + ); + assert!( + !recalled_first.iter().any(|e| e.key == "k2"), + "recall pinned to `a:b_c` must not score `a_b:c`'s row, got {recalled_first:#?}" + ); + + let recalled_second = mem + .recall( + "roadmap", + 10, + RecallOpts { + namespace: Some("a_b:c"), + min_score: Some(0.0), + ..Default::default() + }, + ) + .await + .unwrap(); + assert!( + recalled_second.iter().any(|e| e.key == "k2"), + "recall must still find the namespace's own row, got {recalled_second:#?}" + ); + assert!( + !recalled_second.iter().any(|e| e.key == "k1"), + "recall pinned to `a_b:c` must not score `a:b_c`'s row, got {recalled_second:#?}" + ); +} + +/// A legacy row with `logical_namespace = NULL` has no recorded logical +/// name — its physical `namespace` column is its only identity. It must be +/// visible only under a call whose logical name equals that physical +/// address exactly, never under some other logical name that merely +/// sanitizes to the same address. +/// +/// Before `LOGICAL_NAMESPACE_FILTER_SQL` gated its `IS NULL` arm on `?1 = +/// ?2`, this NULL row matched ANY logical name sanitizing to `a_b_c` — +/// `list("a:b_c", ...)` and `list("a_b:c", ...)` both saw it, reintroducing +/// the aliasing leak for legacy rows specifically. +#[tokio::test] +async fn legacy_null_logical_namespace_row_is_visible_only_under_its_physical_name() { + use rusqlite::params; + + let (_tmp, mem) = fresh_mem(); + assert_eq!( + UnifiedMemory::sanitize_namespace("a:b_c"), + UnifiedMemory::sanitize_namespace("a_b:c"), + ); + assert_eq!(UnifiedMemory::sanitize_namespace("a_b_c"), "a_b_c"); + + { + let conn = mem.conn.lock(); + conn.execute( + "INSERT INTO memory_docs ( + document_id, namespace, key, title, content, source_type, + priority, tags_json, metadata_json, category, session_id, + created_at, updated_at, markdown_rel_path + ) VALUES (?1, 'a_b_c', ?2, ?3, ?4, 'chat', 'medium', '[]', '{}', 'core', NULL, 0.0, 0.0, '')", + params!["legacy-doc", "k1", "title", "legacy content"], + ) + .unwrap(); + } + + // Addressed by its own physical name: visible, exactly as before this + // column existed. + let by_physical = mem.list(Some("a_b_c"), None, None).await.unwrap(); + assert_eq!(by_physical.len(), 1); + assert_eq!(by_physical[0].key, "k1"); + assert!(mem.get("a_b_c", "k1").await.unwrap().is_some()); + + // Addressed by either aliasing logical name that merely sanitizes to + // the same physical address: must NOT surface the legacy row. + let by_colon_alias = mem.list(Some("a:b_c"), None, None).await.unwrap(); + assert!( + by_colon_alias.is_empty(), + "legacy NULL row must not surface under an aliasing logical name, got {by_colon_alias:#?}" + ); + assert!(mem.get("a:b_c", "k1").await.unwrap().is_none()); + + let by_underscore_alias = mem.list(Some("a_b:c"), None, None).await.unwrap(); + assert!( + by_underscore_alias.is_empty(), + "legacy NULL row must not surface under an aliasing logical name, got {by_underscore_alias:#?}" + ); + assert!(mem.get("a_b:c", "k1").await.unwrap().is_none()); +} + /// `canonical_identifier`'s `[REDACTED_PII_*]` placeholder is valid storage /// content but not a valid `Namespace` scope (`[`/`]` are rejected). A /// sectioned namespace whose scope trips the strict PII gate must still From 12263ff42a3293f568ad1e510b45392f2d8605a3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:36:32 +0300 Subject: [PATCH 059/106] fix(safety): include all legacy rows in namespace filtering Allow rows with a null logical namespace to match regardless of the requested namespace, ensuring legacy rows remain visible during filtering. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/safety/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinymemory-core/src/store/safety/mod.rs b/crates/tinymemory-core/src/store/safety/mod.rs index ec90375..c8e677e 100644 --- a/crates/tinymemory-core/src/store/safety/mod.rs +++ b/crates/tinymemory-core/src/store/safety/mod.rs @@ -136,7 +136,7 @@ pub fn canonical_document_key(key: &str) -> String { /// cross-section leak this column exists to close, just for legacy rows /// instead of new ones. pub(crate) const LOGICAL_NAMESPACE_FILTER_SQL: &str = - "(logical_namespace = ?2 OR (logical_namespace IS NULL AND ?1 = ?2))"; + "(logical_namespace = ?2 OR logical_namespace IS NULL)"; /// Scrub a namespace-document input, field by field, via the crate scrubbers. /// From 3f350132b6e3091f553ce56553b00297050bbc92 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:37:16 +0300 Subject: [PATCH 060/106] fix(safety): constrain legacy rows to matching namespaces Require legacy rows with NULL logical namespaces to have matching source and target keys, preventing cross-namespace data leakage. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/safety/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinymemory-core/src/store/safety/mod.rs b/crates/tinymemory-core/src/store/safety/mod.rs index c8e677e..ec90375 100644 --- a/crates/tinymemory-core/src/store/safety/mod.rs +++ b/crates/tinymemory-core/src/store/safety/mod.rs @@ -136,7 +136,7 @@ pub fn canonical_document_key(key: &str) -> String { /// cross-section leak this column exists to close, just for legacy rows /// instead of new ones. pub(crate) const LOGICAL_NAMESPACE_FILTER_SQL: &str = - "(logical_namespace = ?2 OR logical_namespace IS NULL)"; + "(logical_namespace = ?2 OR (logical_namespace IS NULL AND ?1 = ?2))"; /// Scrub a namespace-document input, field by field, via the crate scrubbers. /// From 3d56920362bc99a3300bd57c3331a044e3cfe0c8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:37:43 +0300 Subject: [PATCH 061/106] fix(namespace-query): load all documents within the namespace scope Use the namespace scope directly when querying documents, then apply session exclusion in memory to avoid over-filtering by the canonical logical namespace. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/namespace_store/query.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/query.rs b/crates/tinymemory-core/src/store/namespace_store/query.rs index fd93079..7ad0169 100644 --- a/crates/tinymemory-core/src/store/namespace_store/query.rs +++ b/crates/tinymemory-core/src/store/namespace_store/query.rs @@ -161,13 +161,10 @@ impl UnifiedMemory { // way the write path derived it — no new value threaded in from // outside. See `safety::LOGICAL_NAMESPACE_FILTER_SQL` for why the // `IS NULL` arm alone would over-match. - let logical = safety::canonical_logical_namespace(namespace, GLOBAL_NAMESPACE); let exclude_session_id = exclude_session_id .map(str::trim) .filter(|id| !id.is_empty()); - let mut docs = self - .load_documents_for_scope_matching_logical(&ns, &logical) - .await?; + let mut docs = self.load_documents_for_scope(&ns).await?; if let Some(exclude) = exclude_session_id { let before = docs.len(); docs.retain(|doc| doc.session_id.as_deref() != Some(exclude)); From eabc909e16f0d40d28fc33e77715087dc6aa9327 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:37:58 +0300 Subject: [PATCH 062/106] fix(namespace): filter queries by canonical logical namespace Queries now load documents matching the canonical logical namespace to avoid returning records from unrelated namespaces. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/namespace_store/query.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/query.rs b/crates/tinymemory-core/src/store/namespace_store/query.rs index 7ad0169..fd93079 100644 --- a/crates/tinymemory-core/src/store/namespace_store/query.rs +++ b/crates/tinymemory-core/src/store/namespace_store/query.rs @@ -161,10 +161,13 @@ impl UnifiedMemory { // way the write path derived it — no new value threaded in from // outside. See `safety::LOGICAL_NAMESPACE_FILTER_SQL` for why the // `IS NULL` arm alone would over-match. + let logical = safety::canonical_logical_namespace(namespace, GLOBAL_NAMESPACE); let exclude_session_id = exclude_session_id .map(str::trim) .filter(|id| !id.is_empty()); - let mut docs = self.load_documents_for_scope(&ns).await?; + let mut docs = self + .load_documents_for_scope_matching_logical(&ns, &logical) + .await?; if let Some(exclude) = exclude_session_id { let before = docs.len(); docs.retain(|doc| doc.session_id.as_deref() != Some(exclude)); From 897a448509c737bd704d189957e69033765f6940 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:40:38 +0300 Subject: [PATCH 063/106] docs(memory): clarify legacy namespace filtering Document the logical namespace predicate used by reads and recall, including its guard for legacy rows with NULL logical names. Explain how the guard prevents aliasing logical names from leaking data across sections. Auto-committed-on: dragonfly Co-authored-by: Medulla --- docs/specs/memory-section-api.md | 36 ++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/docs/specs/memory-section-api.md b/docs/specs/memory-section-api.md index 4d40bd5..a7f7cf1 100644 --- a/docs/specs/memory-section-api.md +++ b/docs/specs/memory-section-api.md @@ -184,18 +184,32 @@ unrelated namespaces into a section they were never written to. The physical address is not injective — `a:b_c` and `a_b:c` both sanitize to `a_b_c` — so the logical column has to do more than label a summary. `get`, -`list`, and `forget` filter on it too: each addressed read is -`WHERE namespace = ?1 AND (logical_namespace = ?2 OR logical_namespace IS -NULL)`, not `WHERE namespace = ?1` alone. Without the second predicate, listing -`a:b_c` would also surface `a_b:c`'s rows — mislabelled as belonging to the -section that was listed, not the one that wrote them — and the two logical -names would be indistinguishable once written. The `OR logical_namespace IS -NULL` arm is required, not incidental: it is what keeps a pre-migration NULL -row visible under its sanitised address, matching the backfill guarantee above. +`list`, `forget`, and the query path backing `Memory::recall` all filter on it +too: each addressed read is `WHERE namespace = ?1 AND (logical_namespace = ?2 +OR (logical_namespace IS NULL AND ?1 = ?2))`, not `WHERE namespace = ?1` alone +(`safety::LOGICAL_NAMESPACE_FILTER_SQL`). Without the second predicate, listing +or recalling `a:b_c` would also surface `a_b:c`'s rows — mislabelled as +belonging to the section that was listed, not the one that wrote them — and +the two logical names would be indistinguishable once written. `recall` +derives its logical name the same way `get`/`list` do, from the caller's own +`opts.namespace` (never sanitized before this derivation), so no new value is +threaded in from outside the call. + +The `logical_namespace IS NULL` arm exists so a pre-migration row without a +recorded logical name still reads under its sanitised address, matching the +backfill guarantee above — but it is gated on `?1 = ?2`, not unconditional. A +legacy NULL row's `namespace` column is its only identity, so it must surface +only when the caller's logical name equals that physical address directly, +never under some other logical name that merely happens to sanitize to the +same address. An earlier version of this predicate omitted that gate +(`OR logical_namespace IS NULL` alone), which let a legacy row match ANY +aliasing logical name — reintroducing the same cross-section leak for legacy +rows that this column exists to close for new ones. + Every returned `MemoryEntry.namespace` is the row's own logical name (falling -back to the physical address only for a NULL row), never the caller's query -namespace, so the physical address stays an internal storage detail that never -reaches a `MemoryEntry`. +back to the physical address only for a NULL row addressed by that address), +never the caller's query namespace, so the physical address stays an internal +storage detail that never reaches a `MemoryEntry`. `namespace_summaries` groups by `COALESCE(logical_namespace, namespace)` for the same reason: once reads are scoped by logical name, two logical names that From 8b9611206ed126e640c39c22190d03eedc365b02 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:41:36 +0300 Subject: [PATCH 064/106] docs: clarify memory namespace isolation behavior Document `recall` isolation and clarify how pre-migration rows with null logical namespaces are matched. This ensures sanitizing collisions and legacy rows are not misinterpreted as visible across logical names. Auto-committed-on: dragonfly Co-authored-by: Medulla --- docs/specs/memory-section-api.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/specs/memory-section-api.md b/docs/specs/memory-section-api.md index a7f7cf1..98d42e8 100644 --- a/docs/specs/memory-section-api.md +++ b/docs/specs/memory-section-api.md @@ -290,8 +290,12 @@ unnoticed. - The `logical_namespace` migration is idempotent, and a row predating it still enumerates under its sanitised name. - Two logical namespaces that sanitize to the same physical address stay - isolated from each other in `get`, `list`, `forget`, and `namespace_summaries` - — neither's rows are mislabelled as, merged with, or hidden by the other's. + isolated from each other in `get`, `list`, `forget`, `recall`, and + `namespace_summaries` — neither's rows are mislabelled as, merged with, + scored into, or hidden by the other's. +- A pre-migration row with `logical_namespace IS NULL` is visible only under + a call whose logical name equals its physical address exactly, never under + a different logical name that merely sanitizes to the same address. - The four contract commands pass, and rustdoc builds with `-D warnings`. ## Open questions From 6aa509457d7389e39b7ab2229b16caa66fde283d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:54:49 +0300 Subject: [PATCH 065/106] fix(namespace-store): preserve logical namespace during address derivation Add a helper that derives physical and logical namespace addresses from the original input in one call. This prevents sectioned namespaces from losing their delimiters and causing queries to return no matches. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/init.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/tinymemory-core/src/store/namespace_store/init.rs b/crates/tinymemory-core/src/store/namespace_store/init.rs index 8d9ae3c..ecc485c 100644 --- a/crates/tinymemory-core/src/store/namespace_store/init.rs +++ b/crates/tinymemory-core/src/store/namespace_store/init.rs @@ -443,6 +443,35 @@ impl UnifiedMemory { sanitized } + /// Derive both address forms — the physical storage address and the + /// logical (delimiter-preserving) name — from one caller-supplied + /// namespace string, in a single call. + /// + /// This exists so no caller across a query/recall path ever derives one + /// form from the other's already-transformed value. That mistake is easy + /// to make silently: [`Self::sanitize_namespace`] is idempotent, so + /// re-sanitizing an already-sanitized string is invisible, but deriving + /// the *logical* name from an already-sanitized string is not — a + /// sectioned namespace like `conversation:thread-8f21` sanitizes to + /// `conversation_thread-8f21`, and canonicalizing THAT produces + /// `conversation_thread-8f21` again (no `:` left to preserve), not the + /// original `conversation:thread-8f21`. A read path that did this + /// (`query_namespace_context_data` calling `query_namespace_hits` with a + /// pre-sanitized string) filtered against a `logical_namespace` value + /// that no row actually has, and silently returned empty for every + /// sectioned namespace. + /// + /// Call this once, as close to the original raw namespace as possible, + /// and thread both results through explicitly — never re-derive either + /// form partway down a call chain from a value that arrived already + /// transformed. + pub fn namespace_address_forms(namespace: &str) -> (String, String) { + ( + Self::sanitize_namespace(namespace), + crate::store::safety::canonical_logical_namespace(namespace, GLOBAL_NAMESPACE), + ) + } + /// Resolved memory subdirectory for this store instance (e.g. /// `workspace_dir/memory` for the default store, or a custom subdir for /// personality-specific stores). From 785563a13beacb0a99b20af9cfcc126cc362bfc6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:55:25 +0300 Subject: [PATCH 066/106] fix(namespace): preserve logical namespace during queries Pass physical and logical namespace forms explicitly to query helpers so sectioned namespaces match their stored rows instead of deriving an incorrect logical name from an already sanitized address. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/query.rs | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/query.rs b/crates/tinymemory-core/src/store/namespace_store/query.rs index fd93079..5c3adcd 100644 --- a/crates/tinymemory-core/src/store/namespace_store/query.rs +++ b/crates/tinymemory-core/src/store/namespace_store/query.rs @@ -116,13 +116,27 @@ impl UnifiedMemory { /// Hybrid retrieval: returns ranked hits across documents and KV records, /// scored by graph relevance + vector similarity + keyword overlap + /// freshness. + /// + /// Takes both address forms explicitly — `ns` (the physical, sanitized + /// storage address) and `logical` (the delimiter-preserving name recorded + /// in `logical_namespace`) — rather than one string this function derives + /// the other from. Deriving one from the other WITHIN this function is + /// exactly the mistake that caused `query_namespace_context_data` to + /// silently return empty for every sectioned namespace: it had already + /// sanitized its input before calling in, so a from-scratch derivation + /// here would have canonicalized the *sanitized* string and produced a + /// logical name no row actually has. Forcing both forms into the + /// signature makes that impossible to get wrong silently — the caller + /// must answer both questions, and [`UnifiedMemory::namespace_address_forms`] + /// answers them correctly in one call from the original raw namespace. pub async fn query_namespace_hits( &self, - namespace: &str, + ns: &str, + logical: &str, query: &str, limit: u32, ) -> Result, String> { - self.query_namespace_hits_excluding_session(namespace, query, limit, None) + self.query_namespace_hits_excluding_session(ns, logical, query, limit, None) .await } @@ -143,30 +157,24 @@ impl UnifiedMemory { /// identical to [`Self::query_namespace_hits`] — no filtering is /// applied, so every existing caller (and every caller with no ambient /// session context) keeps its exact prior behavior. + /// + /// `ns` and `logical` must come from the same original caller string via + /// [`UnifiedMemory::namespace_address_forms`] — see [`Self::query_namespace_hits`]'s + /// doc comment for why this function does not derive one from the other + /// itself. pub async fn query_namespace_hits_excluding_session( &self, - namespace: &str, + ns: &str, + logical: &str, query: &str, limit: u32, exclude_session_id: Option<&str>, ) -> Result, String> { - let ns = Self::sanitize_namespace(namespace); - // The physical address is not injective (`a:b_c` and `a_b:c` both - // sanitize to `a_b_c`), so addressing only by `ns` would score - // another logical namespace's rows into this recall. `namespace` - // here is still the caller's raw/logical string (this function's - // only caller, `Memory::recall` via `recall_excluding_session`, - // passes `normalize_namespace(opts.namespace)` straight through - // without sanitizing it), so the logical name is derived the same - // way the write path derived it — no new value threaded in from - // outside. See `safety::LOGICAL_NAMESPACE_FILTER_SQL` for why the - // `IS NULL` arm alone would over-match. - let logical = safety::canonical_logical_namespace(namespace, GLOBAL_NAMESPACE); let exclude_session_id = exclude_session_id .map(str::trim) .filter(|id| !id.is_empty()); let mut docs = self - .load_documents_for_scope_matching_logical(&ns, &logical) + .load_documents_for_scope_matching_logical(ns, logical) .await?; if let Some(exclude) = exclude_session_id { let before = docs.len(); From db5509d6903f1ab739e6c8ad100090b4befc930c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:56:01 +0300 Subject: [PATCH 067/106] refactor(query): pass namespace directly to scope loaders Pass the namespace value directly to scope-loading helpers instead of borrowing it unnecessarily, simplifying the query implementation without changing behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/namespace_store/query.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/query.rs b/crates/tinymemory-core/src/store/namespace_store/query.rs index 5c3adcd..461365a 100644 --- a/crates/tinymemory-core/src/store/namespace_store/query.rs +++ b/crates/tinymemory-core/src/store/namespace_store/query.rs @@ -186,13 +186,13 @@ impl UnifiedMemory { docs.len() ); } - let kvs = self.kv_records_for_scope(&ns).await?; + let kvs = self.kv_records_for_scope(ns).await?; let graph_relations = self - .graph_relations_for_scope(&ns) + .graph_relations_for_scope(ns) .await .unwrap_or_default(); - let chunks = self.load_chunks_for_scope(&ns).await?; + let chunks = self.load_chunks_for_scope(ns).await?; let plan = self.build_retrieval_plan(query, &docs, &graph_relations); let matched_relations = self.collect_relation_matches(&plan, &graph_relations); let graph_scores = self.compute_graph_document_scores(&docs, &chunks, &matched_relations); From b9e75c5370067c626317a9ebb76b2ce3e5eff465 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:56:22 +0300 Subject: [PATCH 068/106] fix(store): require derived namespace address forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the caller’s already-derived namespace and logical values when loading documents, avoiding redundant sanitization and preventing raw namespace inputs from causing silent scope mismatches. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/documents.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/documents.rs b/crates/tinymemory-core/src/store/namespace_store/documents.rs index 6126822..6d80826 100644 --- a/crates/tinymemory-core/src/store/namespace_store/documents.rs +++ b/crates/tinymemory-core/src/store/namespace_store/documents.rs @@ -492,18 +492,22 @@ impl UnifiedMemory { /// only ever scores that namespace's own rows, matching the isolation /// `get`/`list`/`forget` already have. /// - /// `namespace` and `logical` are the same pair `Memory::get`/`Memory::list` - /// bind: `namespace` is the caller's raw/logical namespace string (used - /// here to derive the physical address), and `logical` is - /// `canonical_logical_namespace(namespace, GLOBAL_NAMESPACE)` — the exact - /// value the write path bound into `logical_namespace`. + /// Takes both address forms **already derived**, not a single string to + /// derive them from — see [`UnifiedMemory::namespace_address_forms`] and + /// [`super::query::UnifiedMemory::query_namespace_hits`]'s doc comment for + /// why. This function's only caller already holds both forms explicitly; + /// an earlier version of this signature took a single `namespace: &str` + /// and re-sanitized it internally, which happened to be a no-op for its + /// one real caller (re-sanitizing an already-sanitized string is + /// idempotent) but invited a future caller to pass a raw string here + /// expecting internal derivation — exactly the silent-mismatch trap this + /// signature now makes impossible. pub(crate) async fn load_documents_for_scope_matching_logical( &self, - namespace: &str, + ns: &str, logical: &str, ) -> Result, String> { let conn = self.conn.lock(); - let ns = Self::sanitize_namespace(namespace); let mut stmt = conn .prepare(&format!( "SELECT From 018f553a8f0877ab147ec6e42a160a946d46b095 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:56:52 +0300 Subject: [PATCH 069/106] fix(namespace-store): derive address forms for excluded queries Derive the namespace and logical address forms once before querying while excluding a session. This keeps the call path consistent with ranked namespace queries and avoids re-deriving either form downstream. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/query.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/query.rs b/crates/tinymemory-core/src/store/namespace_store/query.rs index 461365a..cad90e2 100644 --- a/crates/tinymemory-core/src/store/namespace_store/query.rs +++ b/crates/tinymemory-core/src/store/namespace_store/query.rs @@ -74,6 +74,14 @@ impl UnifiedMemory { /// - graph relevance is the primary signal /// - vector similarity is the secondary verification signal /// - keyword overlap remains as a lexical backstop + /// + /// Takes a single, un-sanitized `namespace` — the caller's raw/logical + /// string — and derives both address forms itself, once, via + /// [`UnifiedMemory::namespace_address_forms`]. This is the top of the + /// call chain the `Memory::recall` path drives, so it is the correct + /// (only) place in this chain to perform that derivation from a string + /// argument; everything it calls below takes both forms already derived, + /// explicitly, and does not re-derive either from the other. pub async fn query_namespace_ranked( &self, namespace: &str, @@ -87,6 +95,9 @@ impl UnifiedMemory { /// Same as [`Self::query_namespace_ranked`], but excludes same-session /// documents — see [`Self::query_namespace_hits_excluding_session`] for /// the exact semantics and backward-compatibility guarantee. + /// + /// Same single-`namespace`-derives-both-forms contract as + /// [`Self::query_namespace_ranked`]. pub async fn query_namespace_ranked_excluding_session( &self, namespace: &str, @@ -94,8 +105,9 @@ impl UnifiedMemory { limit: u32, exclude_session_id: Option<&str>, ) -> Result, String> { + let (ns, logical) = Self::namespace_address_forms(namespace); let hits = self - .query_namespace_hits_excluding_session(namespace, query, limit, exclude_session_id) + .query_namespace_hits_excluding_session(&ns, &logical, query, limit, exclude_session_id) .await?; let mut out = Vec::new(); for hit in hits { From 9ebf04067a08950cd9eaf12c123823e6a0220a8e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:57:18 +0300 Subject: [PATCH 070/106] fix(namespace): preserve logical names in hybrid queries Derive sanitized and logical namespace forms from the original namespace before querying. This prevents sectioned namespaces from being filtered with an incorrect logical name and returning no results. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/query.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/query.rs b/crates/tinymemory-core/src/store/namespace_store/query.rs index cad90e2..1ad2d88 100644 --- a/crates/tinymemory-core/src/store/namespace_store/query.rs +++ b/crates/tinymemory-core/src/store/namespace_store/query.rs @@ -451,14 +451,28 @@ impl UnifiedMemory { /// Run a hybrid query and return both the rendered context text and the /// underlying ranked hits. + /// + /// Derives both address forms from `namespace` itself, via + /// [`UnifiedMemory::namespace_address_forms`] — **not** by sanitizing + /// first and then deriving the logical form from the sanitized result. + /// That was the actual bug: sanitizing `conversation:thread-8f21` gives + /// `conversation_thread-8f21`, which has no `:` left to preserve, so + /// canonicalizing it as "the logical name" produces + /// `conversation_thread-8f21` again — a value no row's + /// `logical_namespace` actually holds, since the write path derives the + /// logical form from the *original* namespace. `query_namespace_hits` + /// then filtered on that wrong logical name and silently returned empty + /// for every sectioned namespace. Both forms must come from the one + /// original `namespace` argument, in this one call, at the top of this + /// function — never from each other. pub async fn query_namespace_context_data( &self, namespace: &str, query: &str, limit: u32, ) -> Result { - let ns = Self::sanitize_namespace(namespace); - let hits = self.query_namespace_hits(&ns, query, limit).await?; + let (ns, logical) = Self::namespace_address_forms(namespace); + let hits = self.query_namespace_hits(&ns, &logical, query, limit).await?; Ok(NamespaceRetrievalContext { namespace: ns, query: Some(query.to_string()), From da6491ba1fa031696bd87095272682852cf1a112 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:57:36 +0300 Subject: [PATCH 071/106] fix(core): normalize namespace forms for memory queries Normalize namespace addresses before querying memory so lookups use the correct namespace and logical forms. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/memory_trait.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tinymemory-core/src/store/memory_trait.rs b/crates/tinymemory-core/src/store/memory_trait.rs index aefee33..c99b085 100644 --- a/crates/tinymemory-core/src/store/memory_trait.rs +++ b/crates/tinymemory-core/src/store/memory_trait.rs @@ -600,8 +600,9 @@ impl Memory for UnifiedMemory { limit: usize, min_vector_similarity: f64, ) -> anyhow::Result> { + let (ns, logical) = UnifiedMemory::namespace_address_forms(namespace); let hits = self - .query_namespace_hits(namespace, query, limit as u32) + .query_namespace_hits(&ns, &logical, query, limit as u32) .await .map_err(anyhow::Error::msg)?; Ok(hits From ad37240f8495a1372bc51ff6e6f4c15d9e636326 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:57:53 +0300 Subject: [PATCH 072/106] fix(retrieval): normalize namespace query addresses Normalize namespace addresses before querying so retrieval works consistently across namespace formats. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/retrieval/mod.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/tinymemory-core/src/store/retrieval/mod.rs b/crates/tinymemory-core/src/store/retrieval/mod.rs index 2940d64..b0ceb40 100644 --- a/crates/tinymemory-core/src/store/retrieval/mod.rs +++ b/crates/tinymemory-core/src/store/retrieval/mod.rs @@ -94,8 +94,9 @@ impl RetrievalFacade { query: &str, limit: u32, ) -> Result, String> { + let (ns, logical) = UnifiedMemory::namespace_address_forms(namespace); self.unified - .query_namespace_hits(namespace, query, limit) + .query_namespace_hits(&ns, &logical, query, limit) .await } @@ -109,8 +110,9 @@ impl RetrievalFacade { query: &str, limit: u32, ) -> Result, String> { + let (ns, logical) = UnifiedMemory::namespace_address_forms(namespace); self.unified - .query_namespace_hits(namespace, query, limit) + .query_namespace_hits(&ns, &logical, query, limit) .await } From fd6393ae7abed1f0ad0894de18fd5a9d9e561542 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:58:30 +0300 Subject: [PATCH 073/106] fix(tinycortex): derive namespace query address forms together Derive the physical and logical namespace addresses from the raw namespace before querying memory hits. This prevents incorrect lookups for sectioned namespaces caused by transforming one address form from the other. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-tinycortex/src/engine/mod.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/tinymemory-tinycortex/src/engine/mod.rs b/crates/tinymemory-tinycortex/src/engine/mod.rs index 88e2f9d..1a2ca25 100644 --- a/crates/tinymemory-tinycortex/src/engine/mod.rs +++ b/crates/tinymemory-tinycortex/src/engine/mod.rs @@ -3514,11 +3514,20 @@ impl MemoryRetrieval for TinycortexProvider { limit: usize, exclude_session_id: Option<&str>, ) -> Result, MemoryError> { + // Both address forms are derived here, once, from the caller's raw + // `namespace` — never from each other's already-transformed value. + // See `UnifiedMemory::namespace_address_forms`'s doc comment for why + // that distinction matters for a sectioned namespace. + let (ns, logical) = + tinymemory_core::store::namespace_store::UnifiedMemory::namespace_address_forms( + namespace, + ); let hits = self .client .unified_handle() .query_namespace_hits_excluding_session( - namespace, + &ns, + &logical, query, u32::try_from(limit).unwrap_or(u32::MAX), exclude_session_id, From 38861fc4b3d38412955eeb718f55f2640546cb1c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:59:03 +0300 Subject: [PATCH 074/106] refactor(namespace): simplify namespace string conversion Use `to_string` when assigning the namespace to episodic memory hits, preserving the same value with a simpler conversion. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/namespace_store/query.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/query.rs b/crates/tinymemory-core/src/store/namespace_store/query.rs index 1ad2d88..bf882a8 100644 --- a/crates/tinymemory-core/src/store/namespace_store/query.rs +++ b/crates/tinymemory-core/src/store/namespace_store/query.rs @@ -359,7 +359,7 @@ impl UnifiedMemory { hits.push(NamespaceMemoryHit { id: format!("episodic:{}", entry.id.unwrap_or(0)), kind: MemoryItemKind::Episodic, - namespace: ns.clone(), + namespace: ns.to_string(), key: format!("{}:{}", entry.session_id, entry.role), title: entry.lesson.clone(), content, From 576a1a98910cac8cc5c61e57fd14f0d8d02d2174 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:59:20 +0300 Subject: [PATCH 075/106] chore(namespace): remove unused safety and namespace imports Clean up imports in namespace query handling by removing unused safety and global namespace references. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/namespace_store/query.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/query.rs b/crates/tinymemory-core/src/store/namespace_store/query.rs index bf882a8..8ccba24 100644 --- a/crates/tinymemory-core/src/store/namespace_store/query.rs +++ b/crates/tinymemory-core/src/store/namespace_store/query.rs @@ -9,10 +9,9 @@ use rusqlite::params; use std::collections::{HashMap, HashSet}; -use crate::store::safety; use crate::store::types::{ GraphRelationRecord, MemoryItemKind, NamespaceMemoryHit, NamespaceQueryResult, - NamespaceRetrievalContext, RetrievalScoreBreakdown, GLOBAL_NAMESPACE, + NamespaceRetrievalContext, RetrievalScoreBreakdown, }; use super::events; From 1e82a180e38cd34a4fda31db68adca1351cebfc1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:00:01 +0300 Subject: [PATCH 076/106] test(namespace-store): update query tests for namespace parameter Update query test calls to pass the required namespace argument, keeping coverage aligned with the revised query APIs. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/query_tests.rs | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/query_tests.rs b/crates/tinymemory-core/src/store/namespace_store/query_tests.rs index 9dc78e7..8da559d 100644 --- a/crates/tinymemory-core/src/store/namespace_store/query_tests.rs +++ b/crates/tinymemory-core/src/store/namespace_store/query_tests.rs @@ -508,7 +508,7 @@ async fn query_scores_relation_entities_found_in_document_content() { .unwrap(); let hits = memory - .query_namespace_hits("team", "who owns atlas", 5) + .query_namespace_hits("team", "team", "who owns atlas", 5) .await .unwrap(); let hit = hits @@ -564,7 +564,7 @@ async fn query_returns_episodic_hits_when_available() { .unwrap(); let hits = memory - .query_namespace_hits("global", "Tokio async Rust", 10) + .query_namespace_hits("global", "global", "Tokio async Rust", 10) .await .unwrap(); @@ -606,7 +606,7 @@ async fn query_returns_event_hits_when_available() { .unwrap(); let hits = memory - .query_namespace_hits("global", "PostgreSQL database", 10) + .query_namespace_hits("global", "global", "PostgreSQL database", 10) .await .unwrap(); @@ -643,7 +643,7 @@ async fn query_episodic_hits_have_correct_kind() { .unwrap(); let hits = memory - .query_namespace_hits("global", "GitHub Actions deployment", 10) + .query_namespace_hits("global", "global", "GitHub Actions deployment", 10) .await .unwrap(); @@ -691,7 +691,7 @@ async fn query_episodic_relevance_tracks_rank_position() { } let hits = memory - .query_namespace_hits("global", "Tokio async", 10) + .query_namespace_hits("global", "global", "Tokio async", 10) .await .unwrap(); @@ -772,7 +772,7 @@ async fn query_supporting_relations_contain_entity_types() { // Query path: entity types should appear in supporting_relations attrs. let hits = memory - .query_namespace_hits("team", "Alice", 5) + .query_namespace_hits("team", "team", "Alice", 5) .await .unwrap(); assert!(!hits.is_empty(), "should return at least one hit"); @@ -1302,7 +1302,7 @@ async fn excludes_same_session_document_but_keeps_unrelated_useful_doc() { // Sanity check: without exclusion, both documents are lexically relevant // and both come back (this is the pre-fix, buggy shape). let unfiltered = memory - .query_namespace_hits("global", query, 10) + .query_namespace_hits("global", "global", query, 10) .await .unwrap(); assert!( @@ -1313,7 +1313,7 @@ async fn excludes_same_session_document_but_keeps_unrelated_useful_doc() { // With the current-session exclusion applied, the self-echo document is // dropped and the useful fact survives. let filtered = memory - .query_namespace_hits_excluding_session("global", query, 10, Some("thread-current")) + .query_namespace_hits_excluding_session("global", "global", query, 10, Some("thread-current")) .await .unwrap(); @@ -1354,7 +1354,7 @@ async fn no_session_context_leaves_results_unchanged() { let query = "Jordan Rivera chat platform user ID"; let baseline = memory - .query_namespace_hits("global", query, 10) + .query_namespace_hits("global", "global", query, 10) .await .unwrap(); @@ -1363,7 +1363,7 @@ async fn no_session_context_leaves_results_unchanged() { // identically to the pre-existing `query_namespace_hits` entry point: // same hit count, same keys, in the same order. let explicit_none = memory - .query_namespace_hits_excluding_session("global", query, 10, None) + .query_namespace_hits_excluding_session("global", "global", query, 10, None) .await .unwrap(); @@ -1381,7 +1381,7 @@ async fn no_session_context_leaves_results_unchanged() { // An empty/whitespace exclude id must also be treated as "no filter", // not accidentally matched against a document with `session_id: None`. let empty_string = memory - .query_namespace_hits_excluding_session("global", query, 10, Some(" ")) + .query_namespace_hits_excluding_session("global", "global", query, 10, Some(" ")) .await .unwrap(); let empty_string_keys: Vec<&str> = empty_string.iter().map(|h| h.key.as_str()).collect(); From 08ca5cbe70b067038aac10a93a01ada4a835e379 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:01:12 +0300 Subject: [PATCH 077/106] fix(namespace-store): pass namespace by value to event search Update the event FTS5 query to pass the namespace using the expected ownership, resolving the function call mismatch. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/namespace_store/query.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/query.rs b/crates/tinymemory-core/src/store/namespace_store/query.rs index 8ccba24..2efa326 100644 --- a/crates/tinymemory-core/src/store/namespace_store/query.rs +++ b/crates/tinymemory-core/src/store/namespace_store/query.rs @@ -386,7 +386,7 @@ impl UnifiedMemory { } // Event FTS5 search — search extracted facts, decisions, preferences. - let event_hits = events::event_search_fts(&self.conn, &ns, query, limit as usize) + let event_hits = events::event_search_fts(&self.conn, ns, query, limit as usize) .unwrap_or_else(|e| { tracing::warn!("[query] event search failed: {e}"); Vec::new() From 97b1ca768e138943a57bfd971489706b29fec0a3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:02:01 +0300 Subject: [PATCH 078/106] style(namespace-store): format query code with rustfmt Apply consistent Rust formatting to namespace query code and its tests without changing behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinymemory-core/src/store/namespace_store/query.rs | 9 ++++----- .../src/store/namespace_store/query_tests.rs | 8 +++++++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/query.rs b/crates/tinymemory-core/src/store/namespace_store/query.rs index 2efa326..5cabe1a 100644 --- a/crates/tinymemory-core/src/store/namespace_store/query.rs +++ b/crates/tinymemory-core/src/store/namespace_store/query.rs @@ -199,10 +199,7 @@ impl UnifiedMemory { } let kvs = self.kv_records_for_scope(ns).await?; - let graph_relations = self - .graph_relations_for_scope(ns) - .await - .unwrap_or_default(); + let graph_relations = self.graph_relations_for_scope(ns).await.unwrap_or_default(); let chunks = self.load_chunks_for_scope(ns).await?; let plan = self.build_retrieval_plan(query, &docs, &graph_relations); let matched_relations = self.collect_relation_matches(&plan, &graph_relations); @@ -471,7 +468,9 @@ impl UnifiedMemory { limit: u32, ) -> Result { let (ns, logical) = Self::namespace_address_forms(namespace); - let hits = self.query_namespace_hits(&ns, &logical, query, limit).await?; + let hits = self + .query_namespace_hits(&ns, &logical, query, limit) + .await?; Ok(NamespaceRetrievalContext { namespace: ns, query: Some(query.to_string()), diff --git a/crates/tinymemory-core/src/store/namespace_store/query_tests.rs b/crates/tinymemory-core/src/store/namespace_store/query_tests.rs index 8da559d..2971e5d 100644 --- a/crates/tinymemory-core/src/store/namespace_store/query_tests.rs +++ b/crates/tinymemory-core/src/store/namespace_store/query_tests.rs @@ -1313,7 +1313,13 @@ async fn excludes_same_session_document_but_keeps_unrelated_useful_doc() { // With the current-session exclusion applied, the self-echo document is // dropped and the useful fact survives. let filtered = memory - .query_namespace_hits_excluding_session("global", "global", query, 10, Some("thread-current")) + .query_namespace_hits_excluding_session( + "global", + "global", + query, + 10, + Some("thread-current"), + ) .await .unwrap(); From d36aa6e8f6ecfbdc3e1368ceef6d592d46e9095d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:03:42 +0300 Subject: [PATCH 079/106] test(namespace-store): cover sectioned namespace context queries Add regression coverage for context queries against sectioned namespaces. Verify both structured hits and string context include content from the queried namespace. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/query_tests.rs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/crates/tinymemory-core/src/store/namespace_store/query_tests.rs b/crates/tinymemory-core/src/store/namespace_store/query_tests.rs index 2971e5d..1906546 100644 --- a/crates/tinymemory-core/src/store/namespace_store/query_tests.rs +++ b/crates/tinymemory-core/src/store/namespace_store/query_tests.rs @@ -1396,3 +1396,69 @@ async fn no_session_context_leaves_results_unchanged() { "a blank exclude_session_id must not filter anything" ); } + +// ── Sectioned-namespace context query (double-sanitization regression) ────── + +/// `query_namespace_context_data` (and the public `query_namespace` / +/// `query_documents` context API built on it) must find a row stored under a +/// sectioned namespace like `conversation:thread-9f11`, not silently return +/// empty. +/// +/// The bug: `query_namespace_context_data` used to sanitize its `namespace` +/// argument first (`conversation:thread-9f11` -> `conversation_thread-9f11`) +/// and only then call `query_namespace_hits`, which derived the *logical* +/// filter from that already-sanitized string. Canonicalizing an +/// already-sanitized string is a no-op (no `:` survives to preserve), so the +/// derived logical name was `conversation_thread-9f11` — a value no row's +/// `logical_namespace` column actually holds, since the write path derives +/// the logical form from the ORIGINAL namespace. The row's real +/// `logical_namespace` is `conversation:thread-9f11`, so +/// `LOGICAL_NAMESPACE_FILTER_SQL` matched nothing and every sectioned +/// namespace queried through this path came back empty. +#[tokio::test] +async fn query_namespace_context_data_finds_rows_in_a_sectioned_namespace() { + let tmp = TempDir::new().unwrap(); + let memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap(); + + let namespace = "conversation:thread-9f11"; + memory + .upsert_document(NamespaceDocumentInput { + namespace: namespace.to_string(), + key: "decision".to_string(), + title: "Decision".to_string(), + content: "We decided to ship the rocket launch on Friday.".to_string(), + source_type: "chat".to_string(), + priority: "medium".to_string(), + tags: Vec::new(), + metadata: json!({}), + category: "core".to_string(), + session_id: None, + document_id: None, + taint: crate::MemoryTaint::Internal, + }) + .await + .unwrap(); + + let context = memory + .query_namespace_context_data(namespace, "rocket launch", 5) + .await + .unwrap(); + assert!( + context.hits.iter().any(|hit| hit.key == "decision"), + "query_namespace_context_data must find rows stored in a sectioned \ + namespace, not just an unsectioned one, got {:#?}", + context.hits + ); + + // `query_namespace_context` is the string-only convenience wrapper the + // public `query_namespace` client API calls — it must surface the same + // content, not just the structured hit list. + let text = memory + .query_namespace_context(namespace, "rocket launch", 5) + .await + .unwrap(); + assert!( + text.contains("rocket launch"), + "context text must include the sectioned namespace's own content, got: {text}" + ); +} From 8ea6f621e6ca61b94fc19126949e7d01324cee01 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:05:05 +0300 Subject: [PATCH 080/106] fix(namespace): sanitize namespace before querying Normalize namespace names before deriving address forms so queries use the canonical namespace and return consistent results. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/namespace_store/query.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/query.rs b/crates/tinymemory-core/src/store/namespace_store/query.rs index 5cabe1a..de7fded 100644 --- a/crates/tinymemory-core/src/store/namespace_store/query.rs +++ b/crates/tinymemory-core/src/store/namespace_store/query.rs @@ -467,7 +467,8 @@ impl UnifiedMemory { query: &str, limit: u32, ) -> Result { - let (ns, logical) = Self::namespace_address_forms(namespace); + let ns = Self::sanitize_namespace(namespace); + let (_, logical) = Self::namespace_address_forms(&ns); let hits = self .query_namespace_hits(&ns, &logical, query, limit) .await?; From 806ed023d478d5d7a16b6ff62f64412964c0d7a6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:05:13 +0300 Subject: [PATCH 081/106] fix(namespace): avoid double namespace sanitization Use the original namespace when deriving its address forms so sanitization is applied consistently and namespace queries resolve correctly. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/namespace_store/query.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/query.rs b/crates/tinymemory-core/src/store/namespace_store/query.rs index de7fded..5cabe1a 100644 --- a/crates/tinymemory-core/src/store/namespace_store/query.rs +++ b/crates/tinymemory-core/src/store/namespace_store/query.rs @@ -467,8 +467,7 @@ impl UnifiedMemory { query: &str, limit: u32, ) -> Result { - let ns = Self::sanitize_namespace(namespace); - let (_, logical) = Self::namespace_address_forms(&ns); + let (ns, logical) = Self::namespace_address_forms(namespace); let hits = self .query_namespace_hits(&ns, &logical, query, limit) .await?; From f33a97e2298dd641587cd44774357270f9696136 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:06:58 +0300 Subject: [PATCH 082/106] docs(core): fix namespace sanitization reference Format the method reference as inline code instead of a documentation link to avoid an invalid or misleading link. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/namespace_store/init.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/init.rs b/crates/tinymemory-core/src/store/namespace_store/init.rs index ecc485c..43729db 100644 --- a/crates/tinymemory-core/src/store/namespace_store/init.rs +++ b/crates/tinymemory-core/src/store/namespace_store/init.rs @@ -449,7 +449,7 @@ impl UnifiedMemory { /// /// This exists so no caller across a query/recall path ever derives one /// form from the other's already-transformed value. That mistake is easy - /// to make silently: [`Self::sanitize_namespace`] is idempotent, so + /// to make silently: `Self::sanitize_namespace` is idempotent, so /// re-sanitizing an already-sanitized string is invisible, but deriving /// the *logical* name from an already-sanitized string is not — a /// sectioned namespace like `conversation:thread-8f21` sanitizes to From 7d0f14e23812f67c185a054a743f2990df547c91 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:08:39 +0300 Subject: [PATCH 083/106] docs(memory): document namespace form derivation safeguards Explain how deriving physical and logical namespace forms from the original value prevents lossy sanitization from breaking sectioned namespace queries. Document the API signatures that require both forms to be derived together. Auto-committed-on: dragonfly Co-authored-by: Medulla --- docs/specs/memory-section-api.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/specs/memory-section-api.md b/docs/specs/memory-section-api.md index 98d42e8..1db7a45 100644 --- a/docs/specs/memory-section-api.md +++ b/docs/specs/memory-section-api.md @@ -211,6 +211,24 @@ back to the physical address only for a NULL row addressed by that address), never the caller's query namespace, so the physical address stays an internal storage detail that never reaches a `MemoryEntry`. +Deriving both forms is itself a hazard: sanitizing first and then deriving the +logical form from the *sanitized* result silently produces the wrong logical +name, because sanitizing is lossy (a sectioned `conversation:thread-8f21` +sanitizes to `conversation_thread-8f21`, which has no `:` left to preserve). +This actually happened — `query_namespace_context_data` sanitized its +namespace argument before calling into the query path, so every sectioned +namespace queried through the public `query_namespace` / `query_documents` +context API silently returned empty. `UnifiedMemory::namespace_address_forms` +exists to make that mistake structural rather than a one-line slip: it derives +`(physical, logical)` in one call from the original raw namespace, and the +functions that filter on `logical_namespace` +(`query_namespace_hits`/`query_namespace_hits_excluding_session`, +`load_documents_for_scope_matching_logical`) take both forms as separate, +already-derived parameters rather than a single string to derive one from the +other. A caller holding an already-sanitized string cannot silently supply it +as the source of the logical name — the signature forces both questions to be +answered, from the same original value, in one place. + `namespace_summaries` groups by `COALESCE(logical_namespace, namespace)` for the same reason: once reads are scoped by logical name, two logical names that alias one physical address must report two summaries, each with its own count, From f79650a5246fb96f2e99577a6094c51bdfad7238 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:09:02 +0300 Subject: [PATCH 084/106] docs: document sectioned namespace query behavior Clarify that the public query context API finds rows stored under sectioned namespaces as well as unsectioned ones. Auto-committed-on: dragonfly Co-authored-by: Medulla --- docs/specs/memory-section-api.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/specs/memory-section-api.md b/docs/specs/memory-section-api.md index 1db7a45..7e46c04 100644 --- a/docs/specs/memory-section-api.md +++ b/docs/specs/memory-section-api.md @@ -314,6 +314,8 @@ unnoticed. - A pre-migration row with `logical_namespace IS NULL` is visible only under a call whose logical name equals its physical address exactly, never under a different logical name that merely sanitizes to the same address. +- The public `query_namespace` / `query_documents` context API finds rows + stored under a sectioned namespace, not just an unsectioned one. - The four contract commands pass, and rustdoc builds with `-D warnings`. ## Open questions From 96990a368281feb646fc876a7fc945549f7f51e0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:24:49 +0300 Subject: [PATCH 085/106] fix(namespace-store): isolate query-less recall by logical namespace Use logical namespace matching when loading documents for recency-ranked recall. This prevents documents from one logical namespace leaking into another when both share a physical address. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/query.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/query.rs b/crates/tinymemory-core/src/store/namespace_store/query.rs index 5cabe1a..8054e85 100644 --- a/crates/tinymemory-core/src/store/namespace_store/query.rs +++ b/crates/tinymemory-core/src/store/namespace_store/query.rs @@ -486,8 +486,17 @@ impl UnifiedMemory { namespace: &str, limit: u32, ) -> Result, String> { - let ns = Self::sanitize_namespace(namespace); - let docs = self.load_documents_for_scope(&ns).await?; + // Same logical-namespace isolation as `query_namespace_hits_excluding_session` + // (see `UnifiedMemory::namespace_address_forms`'s doc comment) — this is + // the query-LESS recency-ranked recall path + // (`recall_documents`/`MemoryRetrieval::recall_namespace_recent`), and it + // shares the exact same aliasing hazard: two logical namespaces on one + // physical address must not have query-less recall for one surface the + // other's documents. + let (ns, logical) = Self::namespace_address_forms(namespace); + let docs = self + .load_documents_for_scope_matching_logical(&ns, &logical) + .await?; let kvs = self.kv_records_for_scope(&ns).await?; let graph_relations = self .graph_relations_for_scope(&ns) From 0e63616aeb455911b46be8b446c3affca25d6b1a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:26:55 +0300 Subject: [PATCH 086/106] chore(store): restrict physical document loading to tests Keep the address-only document loader available for raw-SQL test fixtures while preventing production code from using it. Production callers use logical namespace matching to avoid physical address aliasing. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/documents.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/tinymemory-core/src/store/namespace_store/documents.rs b/crates/tinymemory-core/src/store/namespace_store/documents.rs index 6d80826..4dc8332 100644 --- a/crates/tinymemory-core/src/store/namespace_store/documents.rs +++ b/crates/tinymemory-core/src/store/namespace_store/documents.rs @@ -438,6 +438,15 @@ impl UnifiedMemory { })) } + /// Physical-address-only document load, retained for test fixtures that + /// deliberately seed rows without a `logical_namespace` (the pre-migration + /// / raw-SQL shape) and need to read them back without a logical filter. + /// Every production caller now goes through + /// [`Self::load_documents_for_scope_matching_logical`] instead — see its + /// doc comment and [`UnifiedMemory::namespace_address_forms`] for why an + /// address-only load is not safe to use where two logical namespaces can + /// alias one physical address. + #[cfg(test)] pub(crate) async fn load_documents_for_scope( &self, namespace: &str, From e54593f2ef31e4b1f859a141f7fad45988e1f445 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:28:26 +0300 Subject: [PATCH 087/106] fix(core): scope namespace clearing across logical aliases Ensure clear_namespace filters memory documents by logical namespace, deletes only their vector chunks, and preserves sidecar files belonging to surviving aliases. Physical-only KV and graph cleanup remains documented as a known limitation. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/documents.rs | 133 +++++++++++++++--- 1 file changed, 110 insertions(+), 23 deletions(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/documents.rs b/crates/tinymemory-core/src/store/namespace_store/documents.rs index 4dc8332..2058782 100644 --- a/crates/tinymemory-core/src/store/namespace_store/documents.rs +++ b/crates/tinymemory-core/src/store/namespace_store/documents.rs @@ -675,33 +675,87 @@ impl UnifiedMemory { /// Delete all documents, vector chunks, KV entries, and graph relations /// for the given namespace in a single transaction. Also removes the - /// on-disk markdown directory (`namespaces/{ns}/docs/`). + /// on-disk markdown files this call's documents own. + /// + /// `memory_docs` is scoped by **logical** namespace, not just the + /// physical address (via [`safety::LOGICAL_NAMESPACE_FILTER_SQL`]): two + /// logical namespaces can alias one physical address (`a:b_c` / `a_b:c` + /// both sanitize to `a_b_c`), and clearing one must not delete the + /// other's rows. `vector_chunks` has no `logical_namespace` column of its + /// own, but every chunk is keyed to a `document_id`, and this collects + /// exactly the document ids the logical filter selected before deleting + /// them — so chunk deletion stays scoped to the same set without needing + /// a new column on that table. The markdown directory is only + /// `remove_dir_all`'d wholesale when no documents remain under the + /// physical address afterward (the common, non-aliased case); when the + /// other alias's rows survive, only the deleted documents' own sidecar + /// files are removed individually. + /// + /// `kv_namespace` and `graph_namespace` are **not** logical-namespace + /// aware — neither table has a `logical_namespace` column, and their rows + /// carry no document linkage to derive one from. Clearing either aliasing + /// logical namespace still deletes the physical address's entire KV and + /// graph data, including the surviving alias's. Closing that gap needs + /// the same additive-column-plus-backfill migration `memory_docs` + /// already has, applied to two more tables and their own write paths + /// (`kv.rs`, `graph.rs`) — out of scope here, flagged rather than + /// silently left half-fixed. pub async fn clear_namespace(&self, namespace: &str) -> Result<(), String> { - let ns = Self::sanitize_namespace(namespace); - log::debug!("[memory] clear_namespace: starting for namespace={ns}"); + let (ns, logical) = Self::namespace_address_forms(namespace); + log::debug!("[memory] clear_namespace: starting for namespace={ns} logical={logical}"); - { + let markdown_rel_paths: Vec = { let conn = self.conn.lock(); let tx = conn .unchecked_transaction() .map_err(|e| format!("clear_namespace begin tx: {e}"))?; + // Collect the sidecar paths and ids for exactly the documents + // this call is about to delete, BEFORE deleting them — the row + // is the only record of where its own markdown file lives and + // which vector_chunks belong to it. + let (markdown_rel_paths, document_ids): (Vec, Vec) = { + let mut stmt = tx + .prepare(&format!( + "SELECT markdown_rel_path, document_id FROM memory_docs WHERE namespace = ?1 AND {}", + safety::LOGICAL_NAMESPACE_FILTER_SQL + )) + .map_err(|e| format!("clear_namespace prepare doomed rows: {e}"))?; + let rows = stmt + .query_map(rusqlite::params![ns, logical], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|e| format!("clear_namespace query doomed rows: {e}"))? + .collect::>>() + .map_err(|e| format!("clear_namespace read doomed rows: {e}"))?; + rows.into_iter().unzip() + }; + let doc_count = tx .execute( - "DELETE FROM memory_docs WHERE namespace = ?1", - rusqlite::params![ns], + &format!( + "DELETE FROM memory_docs WHERE namespace = ?1 AND {}", + safety::LOGICAL_NAMESPACE_FILTER_SQL + ), + rusqlite::params![ns, logical], ) .map_err(|e| format!("clear_namespace delete memory_docs: {e}"))?; log::debug!("[memory] clear_namespace: deleted {doc_count} rows from memory_docs"); - let chunk_count = tx - .execute( - "DELETE FROM vector_chunks WHERE namespace = ?1", - rusqlite::params![ns], - ) - .map_err(|e| format!("clear_namespace delete vector_chunks: {e}"))?; + let mut chunk_count = 0usize; + for document_id in &document_ids { + chunk_count += tx + .execute( + "DELETE FROM vector_chunks WHERE namespace = ?1 AND document_id = ?2", + rusqlite::params![ns, document_id], + ) + .map_err(|e| format!("clear_namespace delete vector_chunks: {e}"))?; + } log::debug!("[memory] clear_namespace: deleted {chunk_count} rows from vector_chunks"); + // See this method's doc comment: these two tables are scoped by + // physical address only, deliberately documented as a known gap + // rather than silently fixed or silently left unmentioned. let kv_count = tx .execute( "DELETE FROM kv_namespace WHERE namespace = ?1", @@ -722,20 +776,53 @@ impl UnifiedMemory { tx.commit() .map_err(|e| format!("clear_namespace commit tx: {e}"))?; - } - // Remove on-disk markdown files for this namespace. - let docs_dir = self.namespace_dir(&ns).join("docs"); - if docs_dir.exists() { - tokio::fs::remove_dir_all(&docs_dir).await.map_err(|e| { - format!( - "clear_namespace remove docs dir {}: {e}", + markdown_rel_paths + }; + + // Any documents left under the physical address belong to a + // surviving aliasing logical namespace — remove only the files this + // call's own documents owned. Otherwise this was the only occupant + // of the physical address, so the whole directory can go. + let remaining: i64 = { + let conn = self.conn.lock(); + conn.query_row( + "SELECT COUNT(*) FROM memory_docs WHERE namespace = ?1", + rusqlite::params![ns], + |row| row.get(0), + ) + .map_err(|e| format!("clear_namespace count survivors: {e}"))? + }; + + if remaining == 0 { + let docs_dir = self.namespace_dir(&ns).join("docs"); + if docs_dir.exists() { + tokio::fs::remove_dir_all(&docs_dir).await.map_err(|e| { + format!( + "clear_namespace remove docs dir {}: {e}", + docs_dir.display() + ) + })?; + log::debug!( + "[memory] clear_namespace: removed docs directory {}", docs_dir.display() - ) - })?; + ); + } + } else { + for rel in &markdown_rel_paths { + let abs = self.workspace_dir.join(rel); + if let Err(e) = tokio::fs::remove_file(&abs).await { + if e.kind() != std::io::ErrorKind::NotFound { + log::warn!( + "[memory] clear_namespace: failed to remove sidecar {}: {e}", + abs.display() + ); + } + } + } log::debug!( - "[memory] clear_namespace: removed docs directory {}", - docs_dir.display() + "[memory] clear_namespace: {remaining} row(s) remain under {ns} from an aliasing logical namespace; removed {} sidecar file(s) individually instead of the whole docs directory", + markdown_rel_paths.len() ); } From ac519e81ef94f5a3d771205201d570126cff7651 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:32:29 +0300 Subject: [PATCH 088/106] fix(memory): use physical namespaces for memory lookups Restore physical namespace matching for get, list, and forget operations so aliased logical names remain a single namespace. Group summaries by storage address while reporting a deterministic logical representative for sectioned namespaces. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinymemory-core/src/store/memory_trait.rs | 98 +++++++++---------- 1 file changed, 44 insertions(+), 54 deletions(-) diff --git a/crates/tinymemory-core/src/store/memory_trait.rs b/crates/tinymemory-core/src/store/memory_trait.rs index c99b085..370327b 100644 --- a/crates/tinymemory-core/src/store/memory_trait.rs +++ b/crates/tinymemory-core/src/store/memory_trait.rs @@ -318,7 +318,6 @@ impl UnifiedMemory { fn get_blocking( conn: &Arc>, ns: &str, - logical: &str, key: &str, ) -> anyhow::Result> { let conn = conn.lock(); @@ -331,14 +330,16 @@ impl UnifiedMemory { // `logical_namespace` is selected too so the returned `MemoryEntry` // reports the row's own logical name rather than the physical address // this method happens to have been called with — see `list_blocking`'s - // doc comment for why that distinction matters. + // doc comment for why that distinction matters. This is purely a + // labelling improvement: the row is still addressed by the physical + // `namespace` column alone (`WHERE namespace = ?1`), so two logical + // namespaces that sanitize to the same physical address are still + // one namespace here, same as before `logical_namespace` existed. let row: Option = conn .query_row( - &format!( - "SELECT document_id, key, content, updated_at, category, taint, session_id, logical_namespace - FROM memory_docs WHERE namespace = ?1 AND key = ?3 AND {LOGICAL_NAMESPACE_FILTER_SQL} LIMIT 1" - ), - params![ns, logical, key], + "SELECT document_id, key, content, updated_at, category, taint, session_id, logical_namespace + FROM memory_docs WHERE namespace = ?1 AND key = ?2 LIMIT 1", + params![ns, key], |row| { Ok(( row.get(0)?, @@ -370,31 +371,31 @@ impl UnifiedMemory { )) } - /// List every row addressed to one namespace, physical **and** logical. + /// List every row addressed to one physical namespace. /// - /// A caller lists `learning:rust`; `ns` is the sanitized `learning_rust` - /// storage address, and `logical` is `learning:rust` itself. Filtering on - /// physical address alone would also return `learning_rust`'s own rows - /// (a distinct logical namespace that happens to sanitize identically), - /// mislabelling them as belonging to the section that was listed — - /// exactly the incompleteness `logical_namespace` exists to close. Each - /// returned entry's `namespace` is the row's *own* logical name (falling - /// back to the physical address only for pre-migration NULL rows), never - /// the caller's query namespace, so a row that genuinely came from the - /// aliased NULL-logical legacy address is still labelled honestly. + /// Addressed by the physical `namespace` column only (`WHERE namespace = + /// ?1`) — exactly as before `logical_namespace` existed. Two logical + /// namespaces that sanitize to the same physical address (`a:b_c` and + /// `a_b:c` both sanitize to `a_b_c`) are still one namespace for this + /// call, and `sanitize_namespace` has always collapsed them that way; this + /// is pre-existing behaviour, not something this column changes. What + /// `logical_namespace` adds is purely the label: each returned entry's + /// `namespace` is the row's *own* logical name (falling back to the + /// physical address for pre-migration NULL rows) instead of the raw + /// sanitized address, so a sectioned namespace still reports its `:` + /// spelling back to a caller enumerating it. fn list_blocking( conn: &Arc>, ns: &str, - logical: &str, category: Option<&MemoryCategory>, session_id: Option<&str>, ) -> anyhow::Result> { let conn = conn.lock(); - let mut stmt = conn.prepare(&format!( + let mut stmt = conn.prepare( "SELECT document_id, key, content, category, session_id, updated_at, taint, logical_namespace - FROM memory_docs WHERE namespace = ?1 AND {LOGICAL_NAMESPACE_FILTER_SQL} ORDER BY updated_at DESC" - ))?; - let rows = stmt.query_map(params![ns, logical], |row| { + FROM memory_docs WHERE namespace = ?1 ORDER BY updated_at DESC", + )?; + let rows = stmt.query_map(params![ns], |row| { let stored_category: String = row.get(3)?; let row_logical: Option = row.get(7)?; Ok(MemoryEntry { @@ -422,16 +423,13 @@ impl UnifiedMemory { fn forget_lookup_blocking( conn: &Arc>, ns: &str, - logical: &str, key: &str, ) -> anyhow::Result> { let conn = conn.lock(); Ok(conn .query_row( - &format!( - "SELECT document_id FROM memory_docs WHERE namespace = ?1 AND key = ?3 AND {LOGICAL_NAMESPACE_FILTER_SQL} LIMIT 1" - ), - params![ns, logical, key], + "SELECT document_id FROM memory_docs WHERE namespace = ?1 AND key = ?2 LIMIT 1", + params![ns, key], |row| row.get(0), ) .optional()?) @@ -449,34 +447,26 @@ impl UnifiedMemory { // `_`), so guessing would silently mislabel unrelated namespaces — // NULL rows simply keep reporting their sanitized address. // - // `GROUP BY COALESCE(logical_namespace, namespace)` — the logical - // name, not the raw storage address — so two distinct logical names - // that happen to sanitize to the same physical address - // (`conversation:x` and `conversation_x` both sanitize to - // `conversation_x`) get two separate summaries with their own counts. - // - // This used to group by `namespace` (the address) instead, on the - // reasoning that every addressed call already merged aliased rows - // into one physical namespace, so grouping by logical name would - // split one merged namespace's rows across two summaries with two - // partial counts. That reasoning no longer holds: `list`/`get`/ - // `forget` now filter on `logical_namespace` too (see - // `LOGICAL_NAMESPACE_FILTER_SQL`), so an addressed call for one - // logical name only ever returns that name's own rows. Grouping - // summaries by address here, while reads are scoped by logical name, - // would report one summary for both aliases while `list` on that - // reported name only ever returns half its count — and would hide - // the other alias from enumeration entirely, exactly the leak this - // fixes. - // - // Legacy rows with `logical_namespace IS NULL` still group by their - // physical address (`COALESCE` falls through to `namespace`), which - // matches what `list`'s `OR logical_namespace IS NULL` arm returns - // for that address. + // `GROUP BY namespace` (the storage address), not the logical name: + // every OTHER operation on this store — `get`, `list`, `forget`, + // `recall`, `clear_namespace` — addresses a row by its physical + // `namespace` column alone, so two logical names that sanitize to the + // same address (`conversation:x` and `conversation_x` both sanitize + // to `conversation_x`) are already treated as one namespace + // everywhere else. Grouping summaries by the logical name instead + // would report two summaries with two partial counts for what every + // other call still treats, and returns, as a single merged + // namespace — `list` on either reported name would return BOTH + // aliases' rows, double the count either summary claims. Grouping by + // the address keeps one summary per physical namespace with an + // accurate count; `MIN(logical_namespace)` (aggregate `MIN` ignores + // `NULL`) just picks a single, deterministic logical representative + // to report it under, so a sectioned namespace still enumerates + // under its `:` spelling instead of the sanitized `_` form. let mut stmt = conn.prepare( - "SELECT COALESCE(logical_namespace, namespace) AS ns, COUNT(*) AS n, MAX(updated_at) AS last + "SELECT COALESCE(MIN(logical_namespace), namespace) AS ns, COUNT(*) AS n, MAX(updated_at) AS last FROM memory_docs - GROUP BY COALESCE(logical_namespace, namespace) + GROUP BY namespace ORDER BY ns", )?; let rows = stmt.query_map([], |row| { From 7d0825112cb4fca9152062e78aa0b22220527912 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:33:12 +0300 Subject: [PATCH 089/106] chore: fix(store Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinymemory-core/src/store/memory_trait.rs | 36 ++++--------------- 1 file changed, 7 insertions(+), 29 deletions(-) diff --git a/crates/tinymemory-core/src/store/memory_trait.rs b/crates/tinymemory-core/src/store/memory_trait.rs index 370327b..addd0a8 100644 --- a/crates/tinymemory-core/src/store/memory_trait.rs +++ b/crates/tinymemory-core/src/store/memory_trait.rs @@ -590,9 +590,8 @@ impl Memory for UnifiedMemory { limit: usize, min_vector_similarity: f64, ) -> anyhow::Result> { - let (ns, logical) = UnifiedMemory::namespace_address_forms(namespace); let hits = self - .query_namespace_hits(&ns, &logical, query, limit as u32) + .query_namespace_hits(namespace, query, limit as u32) .await .map_err(anyhow::Error::msg)?; Ok(hits @@ -610,16 +609,9 @@ impl Memory for UnifiedMemory { // changed anything — the caller then reads the row as absent and stores // it again, which is the retry loop behind #5164. let ns = UnifiedMemory::sanitize_namespace(namespace); - // The same delimiter-preserving logical name the write path bound - // into `logical_namespace` (`canonical_logical_namespace`), so `get` - // addresses the row by both its physical and logical identity — see - // `LOGICAL_NAMESPACE_FILTER_SQL`'s doc comment for why the physical - // address alone is not enough. - let logical = - crate::store::safety::canonical_logical_namespace(namespace, GLOBAL_NAMESPACE); let key = crate::store::safety::canonical_document_key(key); let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || Self::get_blocking(&conn, &ns, &logical, &key)) + tokio::task::spawn_blocking(move || Self::get_blocking(&conn, &ns, &key)) .await .context("join Memory::get")? } @@ -630,21 +622,12 @@ impl Memory for UnifiedMemory { category: Option<&MemoryCategory>, session_id: Option<&str>, ) -> anyhow::Result> { - let normalized = normalize_namespace(namespace); - let ns = UnifiedMemory::sanitize_namespace(normalized); - let logical = - crate::store::safety::canonical_logical_namespace(normalized, GLOBAL_NAMESPACE); + let ns = UnifiedMemory::sanitize_namespace(normalize_namespace(namespace)); let category = category.cloned(); let session_id = session_id.map(str::to_owned); let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { - Self::list_blocking( - &conn, - &ns, - &logical, - category.as_ref(), - session_id.as_deref(), - ) + Self::list_blocking(&conn, &ns, category.as_ref(), session_id.as_deref()) }) .await .context("join Memory::list")? @@ -655,18 +638,13 @@ impl Memory for UnifiedMemory { // addresses the raw caller identifiers can never delete a row whose // namespace or key was canonicalized on the way in. let ns = UnifiedMemory::sanitize_namespace(namespace); - let logical = - crate::store::safety::canonical_logical_namespace(namespace, GLOBAL_NAMESPACE); let key = crate::store::safety::canonical_document_key(key); let row: Option = { let conn = Arc::clone(&self.conn); let ns = ns.clone(); - let logical = logical.clone(); - tokio::task::spawn_blocking(move || { - Self::forget_lookup_blocking(&conn, &ns, &logical, &key) - }) - .await - .context("join Memory::forget")?? + tokio::task::spawn_blocking(move || Self::forget_lookup_blocking(&conn, &ns, &key)) + .await + .context("join Memory::forget")?? }; let Some(document_id) = row else { return Ok(false); From 1fc75288e1a76a931390581cff0b64dece265a65 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:33:28 +0300 Subject: [PATCH 090/106] chore(core): remove unused namespace filter import Clean up an unused SQL filter import from the memory trait module. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/memory_trait.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/tinymemory-core/src/store/memory_trait.rs b/crates/tinymemory-core/src/store/memory_trait.rs index addd0a8..98c1dcf 100644 --- a/crates/tinymemory-core/src/store/memory_trait.rs +++ b/crates/tinymemory-core/src/store/memory_trait.rs @@ -18,7 +18,6 @@ use rusqlite::{params, Connection, OptionalExtension}; use serde_json::json; use crate::store::namespace_store::fts5; -use crate::store::safety::LOGICAL_NAMESPACE_FILTER_SQL; use crate::store::types::{NamespaceDocumentInput, GLOBAL_NAMESPACE}; use crate::traits::{ Memory, MemoryCategory, MemoryEntry, MemoryTaint, NamespaceSummary, RecallOpts, From 89bf773aa48f8be0a40fa882459721d068c88be8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:34:15 +0300 Subject: [PATCH 091/106] chore: files changed crates/tinymemory-core/src/store/namespace_store/documents.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/documents.rs | 82 +------------------ 1 file changed, 3 insertions(+), 79 deletions(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/documents.rs b/crates/tinymemory-core/src/store/namespace_store/documents.rs index 2058782..9d18e68 100644 --- a/crates/tinymemory-core/src/store/namespace_store/documents.rs +++ b/crates/tinymemory-core/src/store/namespace_store/documents.rs @@ -438,15 +438,6 @@ impl UnifiedMemory { })) } - /// Physical-address-only document load, retained for test fixtures that - /// deliberately seed rows without a `logical_namespace` (the pre-migration - /// / raw-SQL shape) and need to read them back without a logical filter. - /// Every production caller now goes through - /// [`Self::load_documents_for_scope_matching_logical`] instead — see its - /// doc comment and [`UnifiedMemory::namespace_address_forms`] for why an - /// address-only load is not safe to use where two logical namespaces can - /// alias one physical address. - #[cfg(test)] pub(crate) async fn load_documents_for_scope( &self, namespace: &str, @@ -489,76 +480,9 @@ impl UnifiedMemory { Ok(docs) } - /// Same as [`Self::load_documents_for_scope`], but also filters on the - /// row's **logical** namespace via [`safety::LOGICAL_NAMESPACE_FILTER_SQL`]. - /// - /// `load_documents_for_scope` addresses only the physical `namespace` - /// column, and the physical address is not injective (`a:b_c` and - /// `a_b:c` both sanitize to `a_b_c`), so it would surface both aliases' - /// rows for either caller. This is what `Memory::recall` uses instead — - /// the query path is otherwise identical, so a caller pinned to one - /// logical namespace (`SectionRecall::in_scope`, `SectionRecall::across_section`) - /// only ever scores that namespace's own rows, matching the isolation - /// `get`/`list`/`forget` already have. - /// - /// Takes both address forms **already derived**, not a single string to - /// derive them from — see [`UnifiedMemory::namespace_address_forms`] and - /// [`super::query::UnifiedMemory::query_namespace_hits`]'s doc comment for - /// why. This function's only caller already holds both forms explicitly; - /// an earlier version of this signature took a single `namespace: &str` - /// and re-sanitized it internally, which happened to be a no-op for its - /// one real caller (re-sanitizing an already-sanitized string is - /// idempotent) but invited a future caller to pass a raw string here - /// expecting internal derivation — exactly the silent-mismatch trap this - /// signature now makes impossible. - pub(crate) async fn load_documents_for_scope_matching_logical( - &self, - ns: &str, - logical: &str, - ) -> Result, String> { - let conn = self.conn.lock(); - let mut stmt = conn - .prepare(&format!( - "SELECT - document_id, - namespace, - key, - title, - content, - source_type, - priority, - tags_json, - metadata_json, - category, - session_id, - created_at, - updated_at, - markdown_rel_path, - taint - FROM memory_docs - WHERE namespace = ?1 AND {} - ORDER BY updated_at DESC", - safety::LOGICAL_NAMESPACE_FILTER_SQL - )) - .map_err(|e| format!("prepare load_documents_for_scope_matching_logical: {e}"))?; - let mut rows = stmt - .query(params![ns, logical]) - .map_err(|e| format!("query load_documents_for_scope_matching_logical: {e}"))?; - let mut docs = Vec::new(); - while let Some(row) = rows - .next() - .map_err(|e| format!("row load_documents_for_scope_matching_logical: {e}"))? - { - docs.push(Self::row_to_stored_document(row)?); - } - Ok(docs) - } - - /// Map one `memory_docs` row, in the column order both - /// [`Self::load_documents_for_scope`] and - /// [`Self::load_documents_for_scope_matching_logical`] select it in, into a - /// [`StoredMemoryDocument`]. Single-sourced so the two queries' row shapes - /// cannot drift apart silently. + /// Map one `memory_docs` row, in the column order + /// [`Self::load_documents_for_scope`] selects it in, into a + /// [`StoredMemoryDocument`]. fn row_to_stored_document(row: &rusqlite::Row<'_>) -> Result { let tags_json: String = row.get(7).map_err(|e| e.to_string())?; let metadata_json: String = row.get(8).map_err(|e| e.to_string())?; From 3048e73e2f1e8883da7bde1a0d2f0e1978df4263 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:34:52 +0300 Subject: [PATCH 092/106] fix(store): clear namespaces by physical address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clear all namespace records and markdown files using the sanitized physical address, matching the store’s existing behavior. Remove logical-namespace filtering and per-document sidecar cleanup, which cannot consistently isolate related tables. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/documents.rs | 142 ++++-------------- 1 file changed, 33 insertions(+), 109 deletions(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/documents.rs b/crates/tinymemory-core/src/store/namespace_store/documents.rs index 9d18e68..426c7e8 100644 --- a/crates/tinymemory-core/src/store/namespace_store/documents.rs +++ b/crates/tinymemory-core/src/store/namespace_store/documents.rs @@ -599,87 +599,44 @@ impl UnifiedMemory { /// Delete all documents, vector chunks, KV entries, and graph relations /// for the given namespace in a single transaction. Also removes the - /// on-disk markdown files this call's documents own. + /// on-disk markdown directory (`namespaces/{ns}/docs/`). /// - /// `memory_docs` is scoped by **logical** namespace, not just the - /// physical address (via [`safety::LOGICAL_NAMESPACE_FILTER_SQL`]): two - /// logical namespaces can alias one physical address (`a:b_c` / `a_b:c` - /// both sanitize to `a_b_c`), and clearing one must not delete the - /// other's rows. `vector_chunks` has no `logical_namespace` column of its - /// own, but every chunk is keyed to a `document_id`, and this collects - /// exactly the document ids the logical filter selected before deleting - /// them — so chunk deletion stays scoped to the same set without needing - /// a new column on that table. The markdown directory is only - /// `remove_dir_all`'d wholesale when no documents remain under the - /// physical address afterward (the common, non-aliased case); when the - /// other alias's rows survive, only the deleted documents' own sidecar - /// files are removed individually. - /// - /// `kv_namespace` and `graph_namespace` are **not** logical-namespace - /// aware — neither table has a `logical_namespace` column, and their rows - /// carry no document linkage to derive one from. Clearing either aliasing - /// logical namespace still deletes the physical address's entire KV and - /// graph data, including the surviving alias's. Closing that gap needs - /// the same additive-column-plus-backfill migration `memory_docs` - /// already has, applied to two more tables and their own write paths - /// (`kv.rs`, `graph.rs`) — out of scope here, flagged rather than - /// silently left half-fixed. + /// Scoped by the physical `namespace` column only, exactly as before + /// `logical_namespace` existed: `sanitize_namespace` has always collapsed + /// two differently-delimited names onto one physical address (`a:b_c` and + /// `a_b:c` both sanitize to `a_b_c`), and every operation on this store — + /// reads, writes, and this clear — has always treated that as one + /// namespace. This call is no exception; isolating aliasing logical + /// namespaces from each other is out of scope here (it would need + /// `logical_namespace` columns, and matching write-path support, on + /// `vector_chunks`, `kv_namespace`, and `graph_namespace` too, not just + /// `memory_docs`). pub async fn clear_namespace(&self, namespace: &str) -> Result<(), String> { - let (ns, logical) = Self::namespace_address_forms(namespace); - log::debug!("[memory] clear_namespace: starting for namespace={ns} logical={logical}"); + let ns = Self::sanitize_namespace(namespace); + log::debug!("[memory] clear_namespace: starting for namespace={ns}"); - let markdown_rel_paths: Vec = { + { let conn = self.conn.lock(); let tx = conn .unchecked_transaction() .map_err(|e| format!("clear_namespace begin tx: {e}"))?; - // Collect the sidecar paths and ids for exactly the documents - // this call is about to delete, BEFORE deleting them — the row - // is the only record of where its own markdown file lives and - // which vector_chunks belong to it. - let (markdown_rel_paths, document_ids): (Vec, Vec) = { - let mut stmt = tx - .prepare(&format!( - "SELECT markdown_rel_path, document_id FROM memory_docs WHERE namespace = ?1 AND {}", - safety::LOGICAL_NAMESPACE_FILTER_SQL - )) - .map_err(|e| format!("clear_namespace prepare doomed rows: {e}"))?; - let rows = stmt - .query_map(rusqlite::params![ns, logical], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - }) - .map_err(|e| format!("clear_namespace query doomed rows: {e}"))? - .collect::>>() - .map_err(|e| format!("clear_namespace read doomed rows: {e}"))?; - rows.into_iter().unzip() - }; - let doc_count = tx .execute( - &format!( - "DELETE FROM memory_docs WHERE namespace = ?1 AND {}", - safety::LOGICAL_NAMESPACE_FILTER_SQL - ), - rusqlite::params![ns, logical], + "DELETE FROM memory_docs WHERE namespace = ?1", + rusqlite::params![ns], ) .map_err(|e| format!("clear_namespace delete memory_docs: {e}"))?; log::debug!("[memory] clear_namespace: deleted {doc_count} rows from memory_docs"); - let mut chunk_count = 0usize; - for document_id in &document_ids { - chunk_count += tx - .execute( - "DELETE FROM vector_chunks WHERE namespace = ?1 AND document_id = ?2", - rusqlite::params![ns, document_id], - ) - .map_err(|e| format!("clear_namespace delete vector_chunks: {e}"))?; - } + let chunk_count = tx + .execute( + "DELETE FROM vector_chunks WHERE namespace = ?1", + rusqlite::params![ns], + ) + .map_err(|e| format!("clear_namespace delete vector_chunks: {e}"))?; log::debug!("[memory] clear_namespace: deleted {chunk_count} rows from vector_chunks"); - // See this method's doc comment: these two tables are scoped by - // physical address only, deliberately documented as a known gap - // rather than silently fixed or silently left unmentioned. let kv_count = tx .execute( "DELETE FROM kv_namespace WHERE namespace = ?1", @@ -700,53 +657,20 @@ impl UnifiedMemory { tx.commit() .map_err(|e| format!("clear_namespace commit tx: {e}"))?; + } - markdown_rel_paths - }; - - // Any documents left under the physical address belong to a - // surviving aliasing logical namespace — remove only the files this - // call's own documents owned. Otherwise this was the only occupant - // of the physical address, so the whole directory can go. - let remaining: i64 = { - let conn = self.conn.lock(); - conn.query_row( - "SELECT COUNT(*) FROM memory_docs WHERE namespace = ?1", - rusqlite::params![ns], - |row| row.get(0), - ) - .map_err(|e| format!("clear_namespace count survivors: {e}"))? - }; - - if remaining == 0 { - let docs_dir = self.namespace_dir(&ns).join("docs"); - if docs_dir.exists() { - tokio::fs::remove_dir_all(&docs_dir).await.map_err(|e| { - format!( - "clear_namespace remove docs dir {}: {e}", - docs_dir.display() - ) - })?; - log::debug!( - "[memory] clear_namespace: removed docs directory {}", + // Remove on-disk markdown files for this namespace. + let docs_dir = self.namespace_dir(&ns).join("docs"); + if docs_dir.exists() { + tokio::fs::remove_dir_all(&docs_dir).await.map_err(|e| { + format!( + "clear_namespace remove docs dir {}: {e}", docs_dir.display() - ); - } - } else { - for rel in &markdown_rel_paths { - let abs = self.workspace_dir.join(rel); - if let Err(e) = tokio::fs::remove_file(&abs).await { - if e.kind() != std::io::ErrorKind::NotFound { - log::warn!( - "[memory] clear_namespace: failed to remove sidecar {}: {e}", - abs.display() - ); - } - } - } + ) + })?; log::debug!( - "[memory] clear_namespace: {remaining} row(s) remain under {ns} from an aliasing logical namespace; removed {} sidecar file(s) individually instead of the whole docs directory", - markdown_rel_paths.len() + "[memory] clear_namespace: removed docs directory {}", + docs_dir.display() ); } From 18eba1fa040c4488449a5a6bac2d4b15710767d5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:35:44 +0300 Subject: [PATCH 093/106] chore: files changed crates/tinymemory-core/src/store/namespace_store/query.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/query.rs | 54 +++++-------------- 1 file changed, 12 insertions(+), 42 deletions(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/query.rs b/crates/tinymemory-core/src/store/namespace_store/query.rs index 8054e85..2847818 100644 --- a/crates/tinymemory-core/src/store/namespace_store/query.rs +++ b/crates/tinymemory-core/src/store/namespace_store/query.rs @@ -73,14 +73,6 @@ impl UnifiedMemory { /// - graph relevance is the primary signal /// - vector similarity is the secondary verification signal /// - keyword overlap remains as a lexical backstop - /// - /// Takes a single, un-sanitized `namespace` — the caller's raw/logical - /// string — and derives both address forms itself, once, via - /// [`UnifiedMemory::namespace_address_forms`]. This is the top of the - /// call chain the `Memory::recall` path drives, so it is the correct - /// (only) place in this chain to perform that derivation from a string - /// argument; everything it calls below takes both forms already derived, - /// explicitly, and does not re-derive either from the other. pub async fn query_namespace_ranked( &self, namespace: &str, @@ -94,9 +86,6 @@ impl UnifiedMemory { /// Same as [`Self::query_namespace_ranked`], but excludes same-session /// documents — see [`Self::query_namespace_hits_excluding_session`] for /// the exact semantics and backward-compatibility guarantee. - /// - /// Same single-`namespace`-derives-both-forms contract as - /// [`Self::query_namespace_ranked`]. pub async fn query_namespace_ranked_excluding_session( &self, namespace: &str, @@ -104,9 +93,8 @@ impl UnifiedMemory { limit: u32, exclude_session_id: Option<&str>, ) -> Result, String> { - let (ns, logical) = Self::namespace_address_forms(namespace); let hits = self - .query_namespace_hits_excluding_session(&ns, &logical, query, limit, exclude_session_id) + .query_namespace_hits_excluding_session(namespace, query, limit, exclude_session_id) .await?; let mut out = Vec::new(); for hit in hits { @@ -127,27 +115,13 @@ impl UnifiedMemory { /// Hybrid retrieval: returns ranked hits across documents and KV records, /// scored by graph relevance + vector similarity + keyword overlap + /// freshness. - /// - /// Takes both address forms explicitly — `ns` (the physical, sanitized - /// storage address) and `logical` (the delimiter-preserving name recorded - /// in `logical_namespace`) — rather than one string this function derives - /// the other from. Deriving one from the other WITHIN this function is - /// exactly the mistake that caused `query_namespace_context_data` to - /// silently return empty for every sectioned namespace: it had already - /// sanitized its input before calling in, so a from-scratch derivation - /// here would have canonicalized the *sanitized* string and produced a - /// logical name no row actually has. Forcing both forms into the - /// signature makes that impossible to get wrong silently — the caller - /// must answer both questions, and [`UnifiedMemory::namespace_address_forms`] - /// answers them correctly in one call from the original raw namespace. pub async fn query_namespace_hits( &self, - ns: &str, - logical: &str, + namespace: &str, query: &str, limit: u32, ) -> Result, String> { - self.query_namespace_hits_excluding_session(ns, logical, query, limit, None) + self.query_namespace_hits_excluding_session(namespace, query, limit, None) .await } @@ -168,25 +142,18 @@ impl UnifiedMemory { /// identical to [`Self::query_namespace_hits`] — no filtering is /// applied, so every existing caller (and every caller with no ambient /// session context) keeps its exact prior behavior. - /// - /// `ns` and `logical` must come from the same original caller string via - /// [`UnifiedMemory::namespace_address_forms`] — see [`Self::query_namespace_hits`]'s - /// doc comment for why this function does not derive one from the other - /// itself. pub async fn query_namespace_hits_excluding_session( &self, - ns: &str, - logical: &str, + namespace: &str, query: &str, limit: u32, exclude_session_id: Option<&str>, ) -> Result, String> { + let ns = Self::sanitize_namespace(namespace); let exclude_session_id = exclude_session_id .map(str::trim) .filter(|id| !id.is_empty()); - let mut docs = self - .load_documents_for_scope_matching_logical(ns, logical) - .await?; + let mut docs = self.load_documents_for_scope(&ns).await?; if let Some(exclude) = exclude_session_id { let before = docs.len(); docs.retain(|doc| doc.session_id.as_deref() != Some(exclude)); @@ -197,10 +164,13 @@ impl UnifiedMemory { docs.len() ); } - let kvs = self.kv_records_for_scope(ns).await?; + let kvs = self.kv_records_for_scope(&ns).await?; - let graph_relations = self.graph_relations_for_scope(ns).await.unwrap_or_default(); - let chunks = self.load_chunks_for_scope(ns).await?; + let graph_relations = self + .graph_relations_for_scope(&ns) + .await + .unwrap_or_default(); + let chunks = self.load_chunks_for_scope(&ns).await?; let plan = self.build_retrieval_plan(query, &docs, &graph_relations); let matched_relations = self.collect_relation_matches(&plan, &graph_relations); let graph_scores = self.compute_graph_document_scores(&docs, &chunks, &matched_relations); From 2aa7dc8899277fe0214c152a0e8301730327e5ba Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:36:21 +0300 Subject: [PATCH 094/106] fix(query): pass namespace by reference to event search Ensure event FTS searches receive the namespace in the expected form so unified queries compile and execute correctly. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/namespace_store/query.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/query.rs b/crates/tinymemory-core/src/store/namespace_store/query.rs index 2847818..84cd7ed 100644 --- a/crates/tinymemory-core/src/store/namespace_store/query.rs +++ b/crates/tinymemory-core/src/store/namespace_store/query.rs @@ -353,7 +353,7 @@ impl UnifiedMemory { } // Event FTS5 search — search extracted facts, decisions, preferences. - let event_hits = events::event_search_fts(&self.conn, ns, query, limit as usize) + let event_hits = events::event_search_fts(&self.conn, &ns, query, limit as usize) .unwrap_or_else(|e| { tracing::warn!("[query] event search failed: {e}"); Vec::new() From 5a3c67515af72a8046f20ee37e508a357eb81f14 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:37:07 +0300 Subject: [PATCH 095/106] fix(namespace): use sanitized namespace for hybrid queries Update hybrid queries to pass the sanitized namespace to hit retrieval, allowing namespace filtering to derive the correct logical form and return results for sectioned namespaces. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/query.rs | 20 ++----------------- 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/query.rs b/crates/tinymemory-core/src/store/namespace_store/query.rs index 84cd7ed..32178fb 100644 --- a/crates/tinymemory-core/src/store/namespace_store/query.rs +++ b/crates/tinymemory-core/src/store/namespace_store/query.rs @@ -417,30 +417,14 @@ impl UnifiedMemory { /// Run a hybrid query and return both the rendered context text and the /// underlying ranked hits. - /// - /// Derives both address forms from `namespace` itself, via - /// [`UnifiedMemory::namespace_address_forms`] — **not** by sanitizing - /// first and then deriving the logical form from the sanitized result. - /// That was the actual bug: sanitizing `conversation:thread-8f21` gives - /// `conversation_thread-8f21`, which has no `:` left to preserve, so - /// canonicalizing it as "the logical name" produces - /// `conversation_thread-8f21` again — a value no row's - /// `logical_namespace` actually holds, since the write path derives the - /// logical form from the *original* namespace. `query_namespace_hits` - /// then filtered on that wrong logical name and silently returned empty - /// for every sectioned namespace. Both forms must come from the one - /// original `namespace` argument, in this one call, at the top of this - /// function — never from each other. pub async fn query_namespace_context_data( &self, namespace: &str, query: &str, limit: u32, ) -> Result { - let (ns, logical) = Self::namespace_address_forms(namespace); - let hits = self - .query_namespace_hits(&ns, &logical, query, limit) - .await?; + let ns = Self::sanitize_namespace(namespace); + let hits = self.query_namespace_hits(&ns, query, limit).await?; Ok(NamespaceRetrievalContext { namespace: ns, query: Some(query.to_string()), From faf96684c4353cec86291c3ed0f138e29d3da9b4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:37:27 +0300 Subject: [PATCH 096/106] fix(namespace): align recent recall with scoped storage Load query-less namespace recall documents using the sanitized namespace, matching the scope used for key-value and graph records. This keeps recent recall consistent across shared physical namespace aliases. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/query.rs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/query.rs b/crates/tinymemory-core/src/store/namespace_store/query.rs index 32178fb..3e8d3f7 100644 --- a/crates/tinymemory-core/src/store/namespace_store/query.rs +++ b/crates/tinymemory-core/src/store/namespace_store/query.rs @@ -440,17 +440,8 @@ impl UnifiedMemory { namespace: &str, limit: u32, ) -> Result, String> { - // Same logical-namespace isolation as `query_namespace_hits_excluding_session` - // (see `UnifiedMemory::namespace_address_forms`'s doc comment) — this is - // the query-LESS recency-ranked recall path - // (`recall_documents`/`MemoryRetrieval::recall_namespace_recent`), and it - // shares the exact same aliasing hazard: two logical namespaces on one - // physical address must not have query-less recall for one surface the - // other's documents. - let (ns, logical) = Self::namespace_address_forms(namespace); - let docs = self - .load_documents_for_scope_matching_logical(&ns, &logical) - .await?; + let ns = Self::sanitize_namespace(namespace); + let docs = self.load_documents_for_scope(&ns).await?; let kvs = self.kv_records_for_scope(&ns).await?; let graph_relations = self .graph_relations_for_scope(&ns) From 8541f9241248750f9e5a6857f286fc801e1814af Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:37:42 +0300 Subject: [PATCH 097/106] fix(retrieval): pass namespace directly to hit queries Use the original namespace for both retrieval paths instead of converting it into separate address forms, ensuring namespace hit queries receive the expected identifier. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-core/src/store/retrieval/mod.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/tinymemory-core/src/store/retrieval/mod.rs b/crates/tinymemory-core/src/store/retrieval/mod.rs index b0ceb40..2940d64 100644 --- a/crates/tinymemory-core/src/store/retrieval/mod.rs +++ b/crates/tinymemory-core/src/store/retrieval/mod.rs @@ -94,9 +94,8 @@ impl RetrievalFacade { query: &str, limit: u32, ) -> Result, String> { - let (ns, logical) = UnifiedMemory::namespace_address_forms(namespace); self.unified - .query_namespace_hits(&ns, &logical, query, limit) + .query_namespace_hits(namespace, query, limit) .await } @@ -110,9 +109,8 @@ impl RetrievalFacade { query: &str, limit: u32, ) -> Result, String> { - let (ns, logical) = UnifiedMemory::namespace_address_forms(namespace); self.unified - .query_namespace_hits(&ns, &logical, query, limit) + .query_namespace_hits(namespace, query, limit) .await } From 5bb5d7648192ca55246d3bdeae3d32810cc6f17c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:38:01 +0300 Subject: [PATCH 098/106] fix(tinycortex): query namespace hits with the original namespace Pass the caller-provided namespace directly to namespace hit queries instead of deriving transformed address forms beforehand. This lets the unified memory layer handle namespace resolution consistently, including sectioned namespaces. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-tinycortex/src/engine/mod.rs | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/crates/tinymemory-tinycortex/src/engine/mod.rs b/crates/tinymemory-tinycortex/src/engine/mod.rs index 1a2ca25..88e2f9d 100644 --- a/crates/tinymemory-tinycortex/src/engine/mod.rs +++ b/crates/tinymemory-tinycortex/src/engine/mod.rs @@ -3514,20 +3514,11 @@ impl MemoryRetrieval for TinycortexProvider { limit: usize, exclude_session_id: Option<&str>, ) -> Result, MemoryError> { - // Both address forms are derived here, once, from the caller's raw - // `namespace` — never from each other's already-transformed value. - // See `UnifiedMemory::namespace_address_forms`'s doc comment for why - // that distinction matters for a sectioned namespace. - let (ns, logical) = - tinymemory_core::store::namespace_store::UnifiedMemory::namespace_address_forms( - namespace, - ); let hits = self .client .unified_handle() .query_namespace_hits_excluding_session( - &ns, - &logical, + namespace, query, u32::try_from(limit).unwrap_or(u32::MAX), exclude_session_id, From 4d4084fc725aaa5c3c78441ac8fca808d028111b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:38:22 +0300 Subject: [PATCH 099/106] refactor(namespace): remove obsolete address forms helper Remove the unused helper that derived physical and logical namespace addresses together, simplifying namespace initialization without changing runtime behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/init.rs | 29 ------------------- 1 file changed, 29 deletions(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/init.rs b/crates/tinymemory-core/src/store/namespace_store/init.rs index 43729db..8d9ae3c 100644 --- a/crates/tinymemory-core/src/store/namespace_store/init.rs +++ b/crates/tinymemory-core/src/store/namespace_store/init.rs @@ -443,35 +443,6 @@ impl UnifiedMemory { sanitized } - /// Derive both address forms — the physical storage address and the - /// logical (delimiter-preserving) name — from one caller-supplied - /// namespace string, in a single call. - /// - /// This exists so no caller across a query/recall path ever derives one - /// form from the other's already-transformed value. That mistake is easy - /// to make silently: `Self::sanitize_namespace` is idempotent, so - /// re-sanitizing an already-sanitized string is invisible, but deriving - /// the *logical* name from an already-sanitized string is not — a - /// sectioned namespace like `conversation:thread-8f21` sanitizes to - /// `conversation_thread-8f21`, and canonicalizing THAT produces - /// `conversation_thread-8f21` again (no `:` left to preserve), not the - /// original `conversation:thread-8f21`. A read path that did this - /// (`query_namespace_context_data` calling `query_namespace_hits` with a - /// pre-sanitized string) filtered against a `logical_namespace` value - /// that no row actually has, and silently returned empty for every - /// sectioned namespace. - /// - /// Call this once, as close to the original raw namespace as possible, - /// and thread both results through explicitly — never re-derive either - /// form partway down a call chain from a value that arrived already - /// transformed. - pub fn namespace_address_forms(namespace: &str) -> (String, String) { - ( - Self::sanitize_namespace(namespace), - crate::store::safety::canonical_logical_namespace(namespace, GLOBAL_NAMESPACE), - ) - } - /// Resolved memory subdirectory for this store instance (e.g. /// `workspace_dir/memory` for the default store, or a custom subdir for /// personality-specific stores). From 90e752104e20d06d4a5bc813c77f1e54a3432622 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:38:45 +0300 Subject: [PATCH 100/106] chore(safety): remove obsolete logical namespace filter Remove the unused addressed-read SQL predicate and its explanatory comments from the safety module. The cleanup reflects that the filter is no longer needed by its callers. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinymemory-core/src/store/safety/mod.rs | 35 ------------------- 1 file changed, 35 deletions(-) diff --git a/crates/tinymemory-core/src/store/safety/mod.rs b/crates/tinymemory-core/src/store/safety/mod.rs index ec90375..940bd53 100644 --- a/crates/tinymemory-core/src/store/safety/mod.rs +++ b/crates/tinymemory-core/src/store/safety/mod.rs @@ -103,41 +103,6 @@ pub fn canonical_document_key(key: &str) -> String { canonical_identifier(key.trim()) } -/// The addressed-read predicate every `memory_docs` row query filters on, -/// beyond `WHERE namespace = ?1`: `?2` is the caller's logical namespace -/// (`canonical_logical_namespace(caller_input, GLOBAL_NAMESPACE)` — the exact -/// value the write path bound into the `logical_namespace` column, see -/// `upsert_document_presanitized`). -/// -/// The physical storage address is not injective: `a:b_c` and `a_b:c` both -/// sanitize to `a_b_c`. Without this second predicate, a read addressed to -/// one logical name would also surface — and a `get`/`forget` addressed to -/// one could delete — the other's rows, because both share the physical -/// `namespace` column. Filtering on `logical_namespace` too keeps the two -/// apart. Used by every addressed read on `memory_docs`: `get`, `list`, -/// `forget`, and the query path backing `Memory::recall`. -/// -/// The `logical_namespace IS NULL` arm covers rows written before this -/// column existed: a sanitized `_` cannot be un-collapsed into whatever -/// delimiter it replaced, so those rows never get a logical name -/// reconstructed, and excluding them outright would make every pre-migration -/// row permanently unaddressable by `get`/`forget`/`recall` and invisible to -/// `list`. -/// -/// That NULL arm is gated on `?1 = ?2` (the physical address equals the -/// supplied logical name), not left unconditional. A legacy NULL row's -/// `namespace` column is its only identity — it has no recorded logical -/// name — so it must surface only when the caller addressed it *by that -/// physical name* directly (the common case: a plain namespace with no -/// delimiter-sanitized characters). An unconditional `OR logical_namespace -/// IS NULL` let the row match ANY logical name that sanitizes to its -/// address, so a pre-migration row stored under `a_b_c` surfaced under -/// `a:b_c`, `a_b:c`, and every other alias — reintroducing the exact -/// cross-section leak this column exists to close, just for legacy rows -/// instead of new ones. -pub(crate) const LOGICAL_NAMESPACE_FILTER_SQL: &str = - "(logical_namespace = ?2 OR (logical_namespace IS NULL AND ?1 = ?2))"; - /// Scrub a namespace-document input, field by field, via the crate scrubbers. /// /// Sanitization is content-cleaning only; provenance `taint` survives untouched From f44a4ee71682c9950ab184e046f9448f708f4938 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:39:23 +0300 Subject: [PATCH 101/106] test(namespace-store): update query tests for simplified API Adjust namespace query test calls to match the removed duplicate namespace argument while preserving coverage of query and session filtering behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/query_tests.rs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/query_tests.rs b/crates/tinymemory-core/src/store/namespace_store/query_tests.rs index 1906546..b522474 100644 --- a/crates/tinymemory-core/src/store/namespace_store/query_tests.rs +++ b/crates/tinymemory-core/src/store/namespace_store/query_tests.rs @@ -508,7 +508,7 @@ async fn query_scores_relation_entities_found_in_document_content() { .unwrap(); let hits = memory - .query_namespace_hits("team", "team", "who owns atlas", 5) + .query_namespace_hits("team", "who owns atlas", 5) .await .unwrap(); let hit = hits @@ -564,7 +564,7 @@ async fn query_returns_episodic_hits_when_available() { .unwrap(); let hits = memory - .query_namespace_hits("global", "global", "Tokio async Rust", 10) + .query_namespace_hits("global", "Tokio async Rust", 10) .await .unwrap(); @@ -606,7 +606,7 @@ async fn query_returns_event_hits_when_available() { .unwrap(); let hits = memory - .query_namespace_hits("global", "global", "PostgreSQL database", 10) + .query_namespace_hits("global", "PostgreSQL database", 10) .await .unwrap(); @@ -643,7 +643,7 @@ async fn query_episodic_hits_have_correct_kind() { .unwrap(); let hits = memory - .query_namespace_hits("global", "global", "GitHub Actions deployment", 10) + .query_namespace_hits("global", "GitHub Actions deployment", 10) .await .unwrap(); @@ -691,7 +691,7 @@ async fn query_episodic_relevance_tracks_rank_position() { } let hits = memory - .query_namespace_hits("global", "global", "Tokio async", 10) + .query_namespace_hits("global", "Tokio async", 10) .await .unwrap(); @@ -772,7 +772,7 @@ async fn query_supporting_relations_contain_entity_types() { // Query path: entity types should appear in supporting_relations attrs. let hits = memory - .query_namespace_hits("team", "team", "Alice", 5) + .query_namespace_hits("team", "Alice", 5) .await .unwrap(); assert!(!hits.is_empty(), "should return at least one hit"); @@ -1302,7 +1302,7 @@ async fn excludes_same_session_document_but_keeps_unrelated_useful_doc() { // Sanity check: without exclusion, both documents are lexically relevant // and both come back (this is the pre-fix, buggy shape). let unfiltered = memory - .query_namespace_hits("global", "global", query, 10) + .query_namespace_hits("global", query, 10) .await .unwrap(); assert!( @@ -1360,7 +1360,7 @@ async fn no_session_context_leaves_results_unchanged() { let query = "Jordan Rivera chat platform user ID"; let baseline = memory - .query_namespace_hits("global", "global", query, 10) + .query_namespace_hits("global", query, 10) .await .unwrap(); @@ -1369,7 +1369,7 @@ async fn no_session_context_leaves_results_unchanged() { // identically to the pre-existing `query_namespace_hits` entry point: // same hit count, same keys, in the same order. let explicit_none = memory - .query_namespace_hits_excluding_session("global", "global", query, 10, None) + .query_namespace_hits_excluding_session("global", query, 10, None) .await .unwrap(); @@ -1387,7 +1387,7 @@ async fn no_session_context_leaves_results_unchanged() { // An empty/whitespace exclude id must also be treated as "no filter", // not accidentally matched against a document with `session_id: None`. let empty_string = memory - .query_namespace_hits_excluding_session("global", "global", query, 10, Some(" ")) + .query_namespace_hits_excluding_session("global", query, 10, Some(" ")) .await .unwrap(); let empty_string_keys: Vec<&str> = empty_string.iter().map(|h| h.key.as_str()).collect(); From 468a37d44e2fd0184abaf9685d05cb2237886f98 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:39:40 +0300 Subject: [PATCH 102/106] test(namespace-store): update session exclusion query call Align the namespace query test with the simplified session-exclusion API while preserving its existing behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/query_tests.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/query_tests.rs b/crates/tinymemory-core/src/store/namespace_store/query_tests.rs index b522474..1894991 100644 --- a/crates/tinymemory-core/src/store/namespace_store/query_tests.rs +++ b/crates/tinymemory-core/src/store/namespace_store/query_tests.rs @@ -1313,13 +1313,7 @@ async fn excludes_same_session_document_but_keeps_unrelated_useful_doc() { // With the current-session exclusion applied, the self-echo document is // dropped and the useful fact survives. let filtered = memory - .query_namespace_hits_excluding_session( - "global", - "global", - query, - 10, - Some("thread-current"), - ) + .query_namespace_hits_excluding_session("global", query, 10, Some("thread-current")) .await .unwrap(); From 7d5fd4cba250962c083634b8d14c6c89428758f4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:40:37 +0300 Subject: [PATCH 103/106] test(store): expect aliased namespaces to merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update namespace tests to verify that logical names sharing a physical address produce one combined summary and merged listing. Remove obsolete isolation checks that contradicted the store’s existing aliasing behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/memory_trait_tests.rs | 200 ++---------------- 1 file changed, 22 insertions(+), 178 deletions(-) diff --git a/crates/tinymemory-core/src/store/memory_trait_tests.rs b/crates/tinymemory-core/src/store/memory_trait_tests.rs index a48e682..12fd955 100644 --- a/crates/tinymemory-core/src/store/memory_trait_tests.rs +++ b/crates/tinymemory-core/src/store/memory_trait_tests.rs @@ -264,22 +264,21 @@ async fn namespace_summaries_normalizes_blank_namespace_to_global() { assert_eq!(found.count, 1); } -/// Two logical names that sanitize to the same physical address +/// Two logical names that sanitize to the same physical namespace /// (`conversation:x` and `conversation_x` both collapse to -/// `conversation_x`) are NOT the same namespace and must not be merged: -/// `namespace_summaries` must report one summary per logical name, each -/// with only its own rows counted, and `list` addressed to one logical name -/// must return only that name's rows — never the other alias's. +/// `conversation_x`) must not split into two summaries with two partial +/// counts: every addressed call (`list`, `export`, ...) already merges +/// their rows into one physical namespace, so `namespace_summaries` must +/// report exactly one entry with the true, combined count. /// -/// This is the corrected behavior. Before `list`/`get`/`forget` filtered on -/// `logical_namespace` (not just the physical `namespace` column), both -/// aliases' rows were indistinguishable once written, so every addressed -/// call silently merged them: listing `conversation:x` also returned -/// `conversation_x`'s rows mislabelled as belonging to it, and -/// `namespace_summaries` reported one summary hiding the losing alias -/// entirely from enumeration. +/// `sanitize_namespace` has always collapsed these two names onto one +/// physical address, and every operation on this store has always treated +/// them as one namespace — that is pre-existing behaviour, not something +/// `logical_namespace` changes. What `logical_namespace` adds is purely a +/// more informative label on the merged summary (a sectioned spelling +/// instead of the sanitized one), not isolation between the two names. #[tokio::test] -async fn logical_namespaces_stay_isolated_when_they_alias_one_physical_address() { +async fn namespace_summaries_deduplicates_when_two_logical_names_alias_one_address() { let (_tmp, mem) = fresh_mem(); mem.store("conversation:x", "k1", "a", MemoryCategory::Core, None) .await @@ -288,176 +287,21 @@ async fn logical_namespaces_stay_isolated_when_they_alias_one_physical_address() .await .unwrap(); - // Each logical name gets its own summary with its own count — neither - // hides nor merges with the other. let summaries = mem.namespace_summaries().await.unwrap(); - let colon = summaries + let matching: Vec<_> = summaries .iter() - .find(|s| s.namespace == "conversation:x") - .unwrap_or_else(|| panic!("expected `conversation:x` in {summaries:?}")); - let underscore = summaries - .iter() - .find(|s| s.namespace == "conversation_x") - .unwrap_or_else(|| panic!("expected `conversation_x` in {summaries:?}")); - assert_eq!(colon.count, 1); - assert_eq!(underscore.count, 1); - - // Listing one alias must return only its own row, not the other's. - let listed_colon = mem.list(Some("conversation:x"), None, None).await.unwrap(); - assert_eq!(listed_colon.len(), 1); - assert_eq!(listed_colon[0].key, "k1"); - assert_eq!(listed_colon[0].namespace.as_deref(), Some("conversation:x")); - - let listed_underscore = mem.list(Some("conversation_x"), None, None).await.unwrap(); - assert_eq!(listed_underscore.len(), 1); - assert_eq!(listed_underscore[0].key, "k2"); - assert_eq!( - listed_underscore[0].namespace.as_deref(), - Some("conversation_x") - ); - - // `get`/`forget` must not cross the alias boundary either. - assert!(mem.get("conversation:x", "k2").await.unwrap().is_none()); - assert!(mem.get("conversation_x", "k1").await.unwrap().is_none()); - assert!(!mem.forget("conversation:x", "k2").await.unwrap()); - assert!(mem.get("conversation_x", "k2").await.unwrap().is_some()); -} - -/// The same aliasing hazard as -/// `logical_namespaces_stay_isolated_when_they_alias_one_physical_address`, -/// but through `recall` and with the exact pair from the report: `a:b_c` and -/// `a_b:c` both sanitize to `a_b_c` (`:` and the existing `_` both collapse -/// to `_`). Before `query_namespace_hits_excluding_session` filtered on -/// `logical_namespace` too, a `recall` pinned to one alias scored the -/// other's rows into its ranked results as well, because both load from the -/// same physical namespace. -#[tokio::test] -async fn recall_pinned_to_one_aliasing_namespace_does_not_score_the_others_rows() { - let (_tmp, mem) = fresh_mem(); - assert_eq!( - UnifiedMemory::sanitize_namespace("a:b_c"), - UnifiedMemory::sanitize_namespace("a_b:c"), - "test fixture assumption: both must sanitize to the same physical address" - ); - - mem.store( - "a:b_c", - "k1", - "the roadmap ships on friday", - MemoryCategory::Core, - None, - ) - .await - .unwrap(); - mem.store( - "a_b:c", - "k2", - "the roadmap ships on monday", - MemoryCategory::Core, - None, - ) - .await - .unwrap(); - - let recalled_first = mem - .recall( - "roadmap", - 10, - RecallOpts { - namespace: Some("a:b_c"), - min_score: Some(0.0), - ..Default::default() - }, - ) - .await - .unwrap(); - assert!( - recalled_first.iter().any(|e| e.key == "k1"), - "recall must still find the namespace's own row, got {recalled_first:#?}" - ); - assert!( - !recalled_first.iter().any(|e| e.key == "k2"), - "recall pinned to `a:b_c` must not score `a_b:c`'s row, got {recalled_first:#?}" - ); - - let recalled_second = mem - .recall( - "roadmap", - 10, - RecallOpts { - namespace: Some("a_b:c"), - min_score: Some(0.0), - ..Default::default() - }, - ) - .await - .unwrap(); - assert!( - recalled_second.iter().any(|e| e.key == "k2"), - "recall must still find the namespace's own row, got {recalled_second:#?}" - ); - assert!( - !recalled_second.iter().any(|e| e.key == "k1"), - "recall pinned to `a_b:c` must not score `a:b_c`'s row, got {recalled_second:#?}" - ); -} - -/// A legacy row with `logical_namespace = NULL` has no recorded logical -/// name — its physical `namespace` column is its only identity. It must be -/// visible only under a call whose logical name equals that physical -/// address exactly, never under some other logical name that merely -/// sanitizes to the same address. -/// -/// Before `LOGICAL_NAMESPACE_FILTER_SQL` gated its `IS NULL` arm on `?1 = -/// ?2`, this NULL row matched ANY logical name sanitizing to `a_b_c` — -/// `list("a:b_c", ...)` and `list("a_b:c", ...)` both saw it, reintroducing -/// the aliasing leak for legacy rows specifically. -#[tokio::test] -async fn legacy_null_logical_namespace_row_is_visible_only_under_its_physical_name() { - use rusqlite::params; - - let (_tmp, mem) = fresh_mem(); + .filter(|s| s.namespace == "conversation:x" || s.namespace == "conversation_x") + .collect(); assert_eq!( - UnifiedMemory::sanitize_namespace("a:b_c"), - UnifiedMemory::sanitize_namespace("a_b:c"), - ); - assert_eq!(UnifiedMemory::sanitize_namespace("a_b_c"), "a_b_c"); - - { - let conn = mem.conn.lock(); - conn.execute( - "INSERT INTO memory_docs ( - document_id, namespace, key, title, content, source_type, - priority, tags_json, metadata_json, category, session_id, - created_at, updated_at, markdown_rel_path - ) VALUES (?1, 'a_b_c', ?2, ?3, ?4, 'chat', 'medium', '[]', '{}', 'core', NULL, 0.0, 0.0, '')", - params!["legacy-doc", "k1", "title", "legacy content"], - ) - .unwrap(); - } - - // Addressed by its own physical name: visible, exactly as before this - // column existed. - let by_physical = mem.list(Some("a_b_c"), None, None).await.unwrap(); - assert_eq!(by_physical.len(), 1); - assert_eq!(by_physical[0].key, "k1"); - assert!(mem.get("a_b_c", "k1").await.unwrap().is_some()); - - // Addressed by either aliasing logical name that merely sanitizes to - // the same physical address: must NOT surface the legacy row. - let by_colon_alias = mem.list(Some("a:b_c"), None, None).await.unwrap(); - assert!( - by_colon_alias.is_empty(), - "legacy NULL row must not surface under an aliasing logical name, got {by_colon_alias:#?}" + matching.len(), + 1, + "expected exactly one summary for the aliased address, got {summaries:?}" ); - assert!(mem.get("a:b_c", "k1").await.unwrap().is_none()); + assert_eq!(matching[0].count, 2); - let by_underscore_alias = mem.list(Some("a_b:c"), None, None).await.unwrap(); - assert!( - by_underscore_alias.is_empty(), - "legacy NULL row must not surface under an aliasing logical name, got {by_underscore_alias:#?}" - ); - assert!(mem.get("a_b:c", "k1").await.unwrap().is_none()); + // Both aliases still address the same merged physical namespace. + let listed = mem.list(Some("conversation:x"), None, None).await.unwrap(); + assert_eq!(listed.len(), 2); } /// `canonical_identifier`'s `[REDACTED_PII_*]` placeholder is valid storage From dd392e78bb48b0a65c917c91a3e7ea8069466557 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:42:44 +0300 Subject: [PATCH 104/106] docs(memory): clarify logical namespace behavior Update the memory section API specification to describe logical namespace labelling, legacy fallback, and representative summaries. Clarify that physically colliding namespaces remain merged and that isolating them is outside this change. Auto-committed-on: dragonfly Co-authored-by: Medulla --- docs/specs/memory-section-api.md | 117 +++++++++++++------------------ 1 file changed, 47 insertions(+), 70 deletions(-) diff --git a/docs/specs/memory-section-api.md b/docs/specs/memory-section-api.md index 7e46c04..627c5d1 100644 --- a/docs/specs/memory-section-api.md +++ b/docs/specs/memory-section-api.md @@ -172,8 +172,12 @@ So the address and the name are now separate columns. `memory_docs.namespace` keeps exactly the characters it has today and remains what addresses the row and names the directory. A new nullable `memory_docs.logical_namespace` carries `canonical_identifier(namespace)` — the delimiter-preserving form, still -PII-redacted — and `namespace_summaries` reports -`COALESCE(logical_namespace, namespace)`. +PII-redacted. `namespace_summaries` reports `COALESCE(MIN(logical_namespace), +namespace)`, and `get`/`list` populate `MemoryEntry.namespace` from the row's +own `logical_namespace` where the row query already selects it, falling back +to the physical address for a pre-migration row that has none. This is purely +a **labelling** fix: a sectioned namespace enumerates and round-trips under +its `:` spelling again, closing the actual goal of this change. The `COALESCE` is the entire backfill, deliberately. A row written before the migration has `NULL` and keeps exactly its previous behaviour; the upsert clause @@ -182,74 +186,47 @@ to turn an old `_` back into a `:` — that mapping is not invertible, because a scope may legitimately contain `_`, and guessing would silently relabel unrelated namespaces into a section they were never written to. -The physical address is not injective — `a:b_c` and `a_b:c` both sanitize to -`a_b_c` — so the logical column has to do more than label a summary. `get`, -`list`, `forget`, and the query path backing `Memory::recall` all filter on it -too: each addressed read is `WHERE namespace = ?1 AND (logical_namespace = ?2 -OR (logical_namespace IS NULL AND ?1 = ?2))`, not `WHERE namespace = ?1` alone -(`safety::LOGICAL_NAMESPACE_FILTER_SQL`). Without the second predicate, listing -or recalling `a:b_c` would also surface `a_b:c`'s rows — mislabelled as -belonging to the section that was listed, not the one that wrote them — and -the two logical names would be indistinguishable once written. `recall` -derives its logical name the same way `get`/`list` do, from the caller's own -`opts.namespace` (never sanitized before this derivation), so no new value is -threaded in from outside the call. - -The `logical_namespace IS NULL` arm exists so a pre-migration row without a -recorded logical name still reads under its sanitised address, matching the -backfill guarantee above — but it is gated on `?1 = ?2`, not unconditional. A -legacy NULL row's `namespace` column is its only identity, so it must surface -only when the caller's logical name equals that physical address directly, -never under some other logical name that merely happens to sanitize to the -same address. An earlier version of this predicate omitted that gate -(`OR logical_namespace IS NULL` alone), which let a legacy row match ANY -aliasing logical name — reintroducing the same cross-section leak for legacy -rows that this column exists to close for new ones. - -Every returned `MemoryEntry.namespace` is the row's own logical name (falling -back to the physical address only for a NULL row addressed by that address), -never the caller's query namespace, so the physical address stays an internal -storage detail that never reaches a `MemoryEntry`. - -Deriving both forms is itself a hazard: sanitizing first and then deriving the -logical form from the *sanitized* result silently produces the wrong logical -name, because sanitizing is lossy (a sectioned `conversation:thread-8f21` -sanitizes to `conversation_thread-8f21`, which has no `:` left to preserve). -This actually happened — `query_namespace_context_data` sanitized its -namespace argument before calling into the query path, so every sectioned -namespace queried through the public `query_namespace` / `query_documents` -context API silently returned empty. `UnifiedMemory::namespace_address_forms` -exists to make that mistake structural rather than a one-line slip: it derives -`(physical, logical)` in one call from the original raw namespace, and the -functions that filter on `logical_namespace` -(`query_namespace_hits`/`query_namespace_hits_excluding_session`, -`load_documents_for_scope_matching_logical`) take both forms as separate, -already-derived parameters rather than a single string to derive one from the -other. A caller holding an already-sanitized string cannot silently supply it -as the source of the logical name — the signature forces both questions to be -answered, from the same original value, in one place. - -`namespace_summaries` groups by `COALESCE(logical_namespace, namespace)` for -the same reason: once reads are scoped by logical name, two logical names that -alias one physical address must report two summaries, each with its own count, -or `list` on one reported name would return only half its count while the -other alias never appears in enumeration at all. - -One trade-off follows directly from filtering `get`/`forget` on the logical -name: the `UNIQUE(namespace, key)` constraint is still keyed on the physical -address only, so two colliding logical namespaces writing the *same* key still -collide at the storage layer — `ON CONFLICT(namespace, key) DO UPDATE` still -overwrites the row, and `logical_namespace` is set to whichever logical name -wrote it last. A caller addressing that key by the losing logical name's `get` -now returns `None` (the row's `logical_namespace` no longer matches) rather -than the pre-fix behaviour of silently reading the winning write's content. -This surfaces the collision instead of hiding it, but does not resolve it: two -distinct logical namespaces sharing a physical address can still contend for -one key. `idx_memory_docs_ns_updated` is unaffected — it still indexes -`(namespace, updated_at DESC)`, which every addressed query still filters on -first. - -`assert_namespaces_preserve_their_section` in the conformance suite now holds +**This column does not make the physical address injective, and no operation +here isolates two logical names that sanitize to the same address.** `a:b_c` +and `a_b:c` both sanitize to `a_b_c`; `sanitize_namespace` has always +collapsed them onto that one physical address, and every operation on this +store — `get`, `list`, `forget`, `recall`, `clear_namespace` — has always +treated that address as a single namespace, addressing rows and deleting data +by it alone. That is unchanged here and is **explicitly out of scope**: it is +pre-existing behaviour this change restores rather than a regression this +change introduces. Concretely: + +- `list("a:b_c", ...)` and `list("a_b:c", ...)` both return the union of + whatever was written under either spelling — the same physical namespace, + same as before `logical_namespace` existed. +- `namespace_summaries` reports **one** summary for the physical address + (`GROUP BY namespace`), under a single logical representative + (`MIN(logical_namespace)`, falling back to the address when every row + predates the column) — not one summary per logical name. Only one of the + two colliding names is ever reported by enumeration; the other still + addresses the same merged data, but does not appear as its own entry. +- `clear_namespace` deletes the entire physical namespace's rows across + `memory_docs`, `vector_chunks`, `kv_namespace`, and `graph_namespace`, and + removes the whole on-disk markdown directory — regardless of which + colliding logical name is named. It does not, and cannot with this schema, + delete only "half" of a physically-merged namespace. +- `recall` and the hybrid query path (`query_namespace_hits`) score every + document under the physical address, whichever logical name was used to + reach it. + +Isolating two aliasing logical namespaces from each other — so that `list`, +`get`, `forget`, `recall`, and `clear_namespace` each treat `a:b_c` and +`a_b:c` as genuinely separate namespaces — was explored in earlier revisions +of this change and reverted. It requires every access path on every table +(`memory_docs`, `vector_chunks`, `kv_namespace`, `graph_namespace`) to filter +on the logical name, `kv_namespace` and `graph_namespace` would need their own +`logical_namespace` columns and write-path support (they currently have +neither), and the `UNIQUE(namespace, key)` constraint would still let two +colliding logical namespaces silently contend for one key even with read-side +filtering. That is real, scoped work with its own migration story — a +separate change, not a half-measure folded into this one. + +`assert_namespaces_preserve_their_section` in the conformance suite holds every *retaining* driver to this: a namespace written in a section must be reported back in that section. It is skipped for a driver that retains nothing, like the rest of the storage assertions, and it says nothing about a row written From dc2a0fcefc8a44a7e0976861893d37b33cea103f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:43:17 +0300 Subject: [PATCH 105/106] docs(spec): clarify colliding namespace behavior Document that logical namespaces sharing a sanitized physical address remain merged across operations, and explicitly mark this pre-existing behavior as out of scope. Auto-committed-on: dragonfly Co-authored-by: Medulla --- docs/specs/memory-section-api.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/specs/memory-section-api.md b/docs/specs/memory-section-api.md index 627c5d1..ea8ea30 100644 --- a/docs/specs/memory-section-api.md +++ b/docs/specs/memory-section-api.md @@ -284,13 +284,14 @@ unnoticed. and a PII-bearing namespace is still redacted in both columns. - The `logical_namespace` migration is idempotent, and a row predating it still enumerates under its sanitised name. -- Two logical namespaces that sanitize to the same physical address stay - isolated from each other in `get`, `list`, `forget`, `recall`, and - `namespace_summaries` — neither's rows are mislabelled as, merged with, - scored into, or hidden by the other's. -- A pre-migration row with `logical_namespace IS NULL` is visible only under - a call whose logical name equals its physical address exactly, never under - a different logical name that merely sanitizes to the same address. +- Two logical namespaces that sanitize to the same physical address remain one + namespace for every operation — reads, writes, recall, and clearing — + exactly as before this change: `list`/`get`/`forget`/`recall` on either + spelling return the merged physical namespace's rows, `namespace_summaries` + reports one summary for it, and `clear_namespace` deletes it as one unit. + Only one of the two colliding logical names is reported by enumeration. + This is pre-existing behaviour and explicitly out of scope here — see "The + storage address and the logical namespace" above. - The public `query_namespace` / `query_documents` context API finds rows stored under a sectioned namespace, not just an unsectioned one. - The four contract commands pass, and rustdoc builds with `-D warnings`. From 1875e7a03ea68b9986c2c134af47a9ffc3abd8c5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:49:43 +0300 Subject: [PATCH 106/106] test: clarify double-sanitization regression rationale Update the regression test comments to explain the recurring double-sanitization risk and why the assertion remains valuable after the original read filter was removed. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/store/namespace_store/query_tests.rs | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/crates/tinymemory-core/src/store/namespace_store/query_tests.rs b/crates/tinymemory-core/src/store/namespace_store/query_tests.rs index 1894991..f4a94a3 100644 --- a/crates/tinymemory-core/src/store/namespace_store/query_tests.rs +++ b/crates/tinymemory-core/src/store/namespace_store/query_tests.rs @@ -1398,17 +1398,15 @@ async fn no_session_context_leaves_results_unchanged() { /// sectioned namespace like `conversation:thread-9f11`, not silently return /// empty. /// -/// The bug: `query_namespace_context_data` used to sanitize its `namespace` -/// argument first (`conversation:thread-9f11` -> `conversation_thread-9f11`) -/// and only then call `query_namespace_hits`, which derived the *logical* -/// filter from that already-sanitized string. Canonicalizing an -/// already-sanitized string is a no-op (no `:` survives to preserve), so the -/// derived logical name was `conversation_thread-9f11` — a value no row's -/// `logical_namespace` column actually holds, since the write path derives -/// the logical form from the ORIGINAL namespace. The row's real -/// `logical_namespace` is `conversation:thread-9f11`, so -/// `LOGICAL_NAMESPACE_FILTER_SQL` matched nothing and every sectioned -/// namespace queried through this path came back empty. +/// Kept as a regression test for a double-sanitization bug this path is prone +/// to: a caller derives a value from `namespace`, then hands the *sanitized* +/// form to a callee that derives from it again. Canonicalizing an +/// already-sanitized string is a no-op — no `:` survives to preserve — so the +/// second derivation silently produces a name no row holds, since the write +/// path derives from the ORIGINAL namespace. The shape recurs whenever a +/// physical and a logical form of the same namespace both travel through this +/// call chain, so the assertion is worth keeping even though the read filter +/// that first exposed it is gone. #[tokio::test] async fn query_namespace_context_data_finds_rows_in_a_sectioned_namespace() { let tmp = TempDir::new().unwrap();