Repository navigation
fix(import): tag synced chunk sources as taint:external_sync #247
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,14 +10,23 @@ | |
| //! people rather than the assistant. Every other source kind (`document`, | ||
| //! `email`) becomes one document whose body is its chunks joined by blank | ||
| //! lines. | ||
| //! | ||
| //! v1 kept no taint on chunks (its chunk tier refused `ExternalSync`), but a | ||
| //! chunk's `owner` says where it came from. A source is tagged | ||
| //! [`EXTERNAL_SYNC_TAG`] unless every chunk's owner is one the host itself | ||
| //! writes: `cron` (or `cron:<id>`) and the archivist's session key, a JSON | ||
| //! object with a `thread_id`. Anything else, a connector sync such as | ||
| //! `gmail-sync:<connection>`, an agent's own label, or a blank owner, is | ||
| //! external, failing closed as the `memory_docs` decode does. A store without | ||
| //! the `owner` column has nothing to read and gets no tag. | ||
|
|
||
| use std::io::ErrorKind; | ||
| use std::path::{Component, Path}; | ||
|
|
||
| use rusqlite::params; | ||
| use tinymemory_api::{DocumentBody, Role, StoreItem, Turn, TurnRange}; | ||
|
|
||
| use super::{Mark, Scanned, import_meta, push_unique, sql_limit}; | ||
| use super::{EXTERNAL_SYNC_TAG, Mark, Scanned, import_meta, push_unique, sql_limit}; | ||
| use crate::import::checkpoint::ChunkCursor; | ||
| use crate::import::convert; | ||
| use crate::import::error::{Error, Result}; | ||
|
|
@@ -29,6 +38,8 @@ struct Chunk { | |
| text: String, | ||
| timestamp_ms: i64, | ||
| tags_json: String, | ||
| /// `owner`, or `None` when the store has no such column. | ||
| owner: Option<String>, | ||
| } | ||
|
|
||
| /// The next page of sources after `after`; empty when the workspace has no | ||
|
|
@@ -117,6 +128,13 @@ fn source_item( | |
| } | ||
| } | ||
| push_unique(&mut tags, format!("source_kind:{}", source.source_kind)); | ||
| if store.owner | ||
| && !chunks | ||
| .iter() | ||
| .all(|chunk| is_host_owner(chunk.owner.as_deref())) | ||
| { | ||
| push_unique(&mut tags, EXTERNAL_SYNC_TAG.to_string()); | ||
| } | ||
| meta.tags = tags; | ||
| meta.observed_at = chunks | ||
| .iter() | ||
|
|
@@ -158,8 +176,9 @@ fn chunks(store: &ChunkStore, source: &ChunkCursor) -> Result<Vec<Chunk>> { | |
| } else { | ||
| "NULL" | ||
| }; | ||
| let owner = if store.owner { "owner" } else { "NULL" }; | ||
| let sql = format!( | ||
| "SELECT content, {content_path}, timestamp_ms, tags_json FROM mem_tree_chunks \ | ||
| "SELECT content, {content_path}, timestamp_ms, tags_json, {owner} FROM mem_tree_chunks \ | ||
| WHERE source_kind = ?1 AND source_id = ?2 ORDER BY seq_in_source, id" | ||
| ); | ||
| let mut stmt = store.conn.prepare(&sql)?; | ||
|
|
@@ -169,11 +188,12 @@ fn chunks(store: &ChunkStore, source: &ChunkCursor) -> Result<Vec<Chunk>> { | |
| row.get::<_, Option<String>>(1)?, | ||
| row.get::<_, Option<i64>>(2)?.unwrap_or_default(), | ||
| row.get::<_, Option<String>>(3)?.unwrap_or_default(), | ||
| row.get::<_, Option<String>>(4)?, | ||
| )) | ||
| })?; | ||
| let mut chunks = Vec::new(); | ||
| for row in rows { | ||
| let (preview, path, timestamp_ms, tags_json) = row?; | ||
| let (preview, path, timestamp_ms, tags_json, owner) = row?; | ||
| let full = match path.as_deref() { | ||
| Some(path) => full_body(&store.content_dir, path)?, | ||
| None => None, | ||
|
|
@@ -186,11 +206,29 @@ fn chunks(store: &ChunkStore, source: &ChunkCursor) -> Result<Vec<Chunk>> { | |
| text, | ||
| timestamp_ms, | ||
| tags_json, | ||
| owner, | ||
| }); | ||
| } | ||
| Ok(chunks) | ||
| } | ||
|
|
||
| /// Whether `owner` is one the host writes for its own content: `cron` (or | ||
| /// `cron:<id>`), or the archivist's session key, a JSON object carrying a | ||
| /// `thread_id`. A missing or blank owner is not. | ||
| fn is_host_owner(owner: Option<&str>) -> bool { | ||
| let Some(owner) = owner.map(str::trim).filter(|owner| !owner.is_empty()) else { | ||
| return false; | ||
| }; | ||
| if owner == "cron" || owner.starts_with("cron:") { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reject empty host-owner identifiers This treats malformed owners such as Additional
|
||
| return true; | ||
| } | ||
| serde_json::from_str::<serde_json::Value>(owner).is_ok_and(|value| { | ||
| value | ||
| .get("thread_id") | ||
| .is_some_and(serde_json::Value::is_string) | ||
| }) | ||
| } | ||
|
|
||
| /// Reads `content_dir/<relative>`; `None` when the path is not a plain | ||
| /// relative path inside the content directory or the file does not exist. | ||
| fn full_body(content_dir: &Path, relative: &str) -> Result<Option<String>> { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,7 +8,7 @@ mod support; | |
|
|
||
| use std::path::Path; | ||
|
|
||
| use support::{OLD_MEMORY_DDL, chunk, chunk_store, doc, facet, turn, workspace}; | ||
| use support::{OLD_MEMORY_DDL, chunk, chunk_store, doc, facet, owned_chunk, turn, workspace}; | ||
| use tinymemory_api::{ | ||
| DocumentBody, LearningKind, Role, SourceKind, StoreItem, ToolCallRef, TurnRange, | ||
| }; | ||
|
|
@@ -866,6 +866,106 @@ fn a_store_without_the_taint_column_reads_as_internal() { | |
| })); | ||
| } | ||
|
|
||
| /// Whether the item imported from `legacy_id` carries [`EXTERNAL_SYNC_TAG`]. | ||
| fn is_external(items: &[ImportedItem], legacy_id: &str) -> bool { | ||
| find(items, legacy_id) | ||
| .meta() | ||
| .tags | ||
| .iter() | ||
| .any(|t| t == EXTERNAL_SYNC_TAG) | ||
| } | ||
|
|
||
| #[test] | ||
| fn chunk_sources_a_connector_synced_are_tagged_external() { | ||
| let dir = tempfile::tempdir().unwrap(); | ||
| let chunks = chunk_store(dir.path()); | ||
| owned_chunk( | ||
| &chunks, | ||
| "k1", | ||
| "email", | ||
| "gmail:me|them", | ||
| 0, | ||
| "gmail-sync:ca_1", | ||
| ); | ||
| owned_chunk(&chunks, "k2", "document", "gmail:msg", 0, "gmail-sync:ca_1"); | ||
| owned_chunk(&chunks, "k3", "chat", "slack:c1", 0, "slack:conn"); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Insert the requested owner into the test fixture
[RULE] invalid-test-fixture · |
||
| owned_chunk(&chunks, "k4", "chat", "cron-out", 0, "cron"); | ||
| owned_chunk(&chunks, "k5", "chat", "cron-job", 0, "cron:job-7"); | ||
| owned_chunk( | ||
| &chunks, | ||
| "k6", | ||
| "chat", | ||
| "conversations:agent", | ||
| 0, | ||
| r#"{"client_id":"c","thread_id":"thread-1"}"#, | ||
| ); | ||
| drop(chunks); | ||
| let ws = LegacyWorkspace::open(dir.path()).unwrap(); | ||
| let items = all(&ws); | ||
| assert!(is_external(&items, "mem_tree_chunks:email:gmail:me|them")); | ||
| assert!(is_external(&items, "mem_tree_chunks:document:gmail:msg")); | ||
| assert!(is_external(&items, "mem_tree_chunks:chat:slack:c1")); | ||
| assert!(!is_external(&items, "mem_tree_chunks:chat:cron-out")); | ||
| assert!(!is_external(&items, "mem_tree_chunks:chat:cron-job")); | ||
| assert!(!is_external( | ||
| &items, | ||
| "mem_tree_chunks:chat:conversations:agent" | ||
| )); | ||
| let StoreItem::Document { meta, .. } = find(&items, "mem_tree_chunks:email:gmail:me|them") | ||
| else { | ||
| panic!("email is a document"); | ||
| }; | ||
| assert_eq!(meta.tags, ["source_kind:email", EXTERNAL_SYNC_TAG]); | ||
| } | ||
|
|
||
| #[test] | ||
| fn a_chunk_owner_the_host_does_not_write_fails_closed() { | ||
| let dir = tempfile::tempdir().unwrap(); | ||
| let chunks = chunk_store(dir.path()); | ||
| // One synced chunk taints the whole source. | ||
| owned_chunk(&chunks, "k1", "chat", "mixed", 0, "cron"); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make the mixed-owner fixture actually mixed Both calls ultimately insert [RULE] invalid-test-fixture · |
||
| owned_chunk(&chunks, "k2", "chat", "mixed", 1, "slack:conn"); | ||
| owned_chunk(&chunks, "k3", "document", "blank", 0, " "); | ||
| owned_chunk(&chunks, "k4", "document", "label", 0, "agent-notes"); | ||
| owned_chunk(&chunks, "k5", "document", "crony", 0, "cronjob"); | ||
| owned_chunk(&chunks, "k6", "document", "json", 0, r#"{"client_id":"c"}"#); | ||
| drop(chunks); | ||
| let ws = LegacyWorkspace::open(dir.path()).unwrap(); | ||
| let items = all(&ws); | ||
| for id in [ | ||
| "chat:mixed", | ||
| "document:blank", | ||
| "document:label", | ||
| "document:crony", | ||
| "document:json", | ||
| ] { | ||
| assert!( | ||
| is_external(&items, &format!("mem_tree_chunks:{id}")), | ||
| "{id}" | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn a_chunk_store_without_the_owner_column_gets_no_taint() { | ||
| let dir = tempfile::tempdir().unwrap(); | ||
| std::fs::create_dir_all(dir.path().join("memory_tree")).unwrap(); | ||
| let chunks = rusqlite::Connection::open(dir.path().join("memory_tree/chunks.db")).unwrap(); | ||
| chunks | ||
| .execute_batch( | ||
| "CREATE TABLE mem_tree_chunks (id TEXT PRIMARY KEY, source_kind TEXT NOT NULL, | ||
| source_id TEXT NOT NULL, timestamp_ms INTEGER NOT NULL, | ||
| tags_json TEXT NOT NULL DEFAULT '[]', content TEXT NOT NULL, | ||
| seq_in_source INTEGER NOT NULL); | ||
| INSERT INTO mem_tree_chunks VALUES ('k1', 'email', 'e1', 1000, '[]', 'hello', 0);", | ||
| ) | ||
| .unwrap(); | ||
| drop(chunks); | ||
| let ws = LegacyWorkspace::open(dir.path()).unwrap(); | ||
| let items = all(&ws); | ||
| assert!(!is_external(&items, "mem_tree_chunks:email:e1")); | ||
| } | ||
|
|
||
| #[test] | ||
| fn imports_an_early_v1_store_without_optional_columns() { | ||
| let (dir, conn) = workspace(OLD_MEMORY_DDL); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add regression tests for ownership-based tainting
This introduces new behavior without tests covering the failure paths: a store with the
ownercolumn containing a non-host owner, a source mixing host and external owners, and a store without the column. The repository rules require tests for every behavior change and coverage of failure paths; add deterministic regression tests in the prescribed test location.Additional
e2eobservationCover the new chunk taint tagging with an end-to-end run
[RULE] e2e-uncovered
This new tagging decides which imported content is treated as externally synced, which downstream keeps out of external-effect tool decisions. The new tests in tests/legacy_import.rs call
LegacyWorkspace::opendirectly against fixture SQLite files; they are importer-level tests, not the running system. The e2e harness (integration/cortexdb, mock_inference.py) never imports a legacy workspace and never observestaint:external_syncon a chunk-sourced item, and no e2e workflow triggered for this head. An end-to-end test would have to import a real legacy workspace containing a gmail-sync-owned chunk store and observe through the running system that the resulting memory is excluded from an external-effect tool decision. The two lexical candidates (no-hyde-multihop.env's comment mentioning 'document', bitemporal-enforce.env's comment mentioning 'source') are unrelated comments, not coverage.[RULE] missing-tests ·