From 7fd4919e50cbcab80c68999d9a3a4672d8835881 Mon Sep 17 00:00:00 2001 From: lazy Date: Tue, 15 Sep 2026 17:00:17 -0400 Subject: [PATCH 1/3] Protect DOX source identities and custom-store sync --- Cargo.lock | 6 +- Cargo.toml | 2 +- README.md | 9 + .../src/actions/adapters.rs | 87 +++++- .../tree-ring-memory-cli/src/tui/actions.rs | 31 +- crates/tree-ring-memory-cli/src/tui/app.rs | 104 ++++++- crates/tree-ring-memory-cli/src/tui/render.rs | 53 ++++ crates/tree-ring-memory-core/AGENTS.md | 2 + crates/tree-ring-memory-core/src/dox.rs | 145 ++++++++- crates/tree-ring-memory-sqlite/AGENTS.md | 2 + crates/tree-ring-memory-sqlite/src/lib.rs | 290 +++++++++++++++++- 11 files changed, 707 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9403eb7..a2d2a9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1690,7 +1690,7 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "tree-ring-memory-cli" -version = "0.15.10" +version = "0.15.11" dependencies = [ "chrono", "clap", @@ -1709,7 +1709,7 @@ dependencies = [ [[package]] name = "tree-ring-memory-core" -version = "0.15.10" +version = "0.15.11" dependencies = [ "chrono", "libc", @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "tree-ring-memory-sqlite" -version = "0.15.10" +version = "0.15.11" dependencies = [ "rusqlite", "serde", diff --git a/Cargo.toml b/Cargo.toml index 67ad2df..f20ac82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.15.10" +version = "0.15.11" edition = "2021" license = "MIT" authors = ["TerminallyLazy"] diff --git a/README.md b/README.md index 3e41a38..4fce1cb 100644 --- a/README.md +++ b/README.md @@ -678,7 +678,16 @@ an agent working in that project. local instruction files that may not be tracked in Git. Review the source paths, project, destination store and candidates before confirming. Confirmation saves that preview and refreshes the dashboard; cancellation leaves memories unchanged. +Files containing detected secret content are skipped entirely. With a custom +memory-store `--root`, the TUI scans the project from which it was launched. Repeated sync updates the same source-linked records rather than duplicating them. +DOX records retain their stable IDs and include a source-root fingerprint. If a +shared store has the same ID from another project or root, sync rejects the whole +batch; use separate project stores. Matching legacy records without a fingerprint +can be updated in the source project's `.tree-ring` store; their earlier location +cannot be verified. Shared legacy records and fingerprinted records copied from +a different root require provenance review before reuse. Sync does not +automatically rebind a recorded fingerprint or migrate IDs. In Coordinated mode, saving requires the coordinator capability in the terminal environment when the TUI starts; preview remains available without it. diff --git a/crates/tree-ring-memory-cli/src/actions/adapters.rs b/crates/tree-ring-memory-cli/src/actions/adapters.rs index e31309a..d8f812f 100644 --- a/crates/tree-ring-memory-cli/src/actions/adapters.rs +++ b/crates/tree-ring-memory-cli/src/actions/adapters.rs @@ -59,8 +59,25 @@ pub fn apply_dox_preview( store: &mut SQLiteMemoryStore, report: &DoxSyncReport, ) -> ActionResult<()> { + // Older DOX records have no root provenance. Only a source project's own + // .tree-ring store can establish that association without guessing which + // project originally wrote a shared legacy record. + let source_root = if report.root.is_file() { + report.root.parent().unwrap_or(&report.root) + } else { + &report.root + }; + let source_root = std::fs::canonicalize(source_root).ok(); + let local_store_project = store.database_path().ok().and_then(|database| { + let memory_root = database.parent()?; + if memory_root.file_name()? != ".tree-ring" { + return None; + } + std::fs::canonicalize(memory_root.parent()?).ok() + }); + let allow_legacy_sources = source_root.is_some() && source_root == local_store_project; store - .put_many(&report.events) + .put_dox_many(&report.events, allow_legacy_sources) .map_err(|err| err.to_string()) } @@ -110,4 +127,72 @@ mod tests { assert_eq!(report.report.memory_count, 1); assert!(!dir.path().join("memory.sqlite").exists()); } + + #[test] + fn legacy_dox_updates_only_in_its_source_projects_local_store() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("AGENTS.md"), "# Rules\n\nAlways run tests.").unwrap(); + let preview = sync_dox( + None, + DoxSyncActionRequest { + source_root: dir.path().to_path_buf(), + project: Some("project".to_string()), + dry_run: true, + }, + ) + .unwrap() + .report; + let mut legacy = preview.events[0].clone(); + legacy.links.retain(|link| link.link_type != "dox-root"); + + for (location, allowed) in [(".tree-ring", true), ("shared-store", false)] { + let root = dir.path().join(location); + fs::create_dir(&root).unwrap(); + let mut store = SQLiteMemoryStore::open(root.join("memory.sqlite")).unwrap(); + store.put(&legacy).unwrap(); + let result = apply_dox_preview(&mut store, &preview); + assert_eq!(result.is_ok(), allowed, "{location}: {result:?}"); + let saved = store.list_all(true).unwrap(); + assert_eq!(saved.len(), 1); + assert_eq!( + saved[0], + if allowed { + preview.events[0].clone() + } else { + legacy.clone() + } + ); + } + } + + #[test] + fn shared_store_rejects_distinct_roots_with_the_same_project_name() { + let dir = tempdir().unwrap(); + let mut store = SQLiteMemoryStore::open(dir.path().join("shared.sqlite")).unwrap(); + for (index, parent) in ["first", "second"].into_iter().enumerate() { + let root = dir.path().join(parent).join("project"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("AGENTS.md"), "# Rules\n\nAlways run tests.").unwrap(); + let preview = sync_dox( + None, + DoxSyncActionRequest { + source_root: root, + project: Some("project".to_string()), + dry_run: true, + }, + ) + .unwrap() + .report; + let before = store.list_all(true).unwrap(); + let result = apply_dox_preview(&mut store, &preview); + if index == 0 { + result.unwrap(); + apply_dox_preview(&mut store, &preview).unwrap(); + assert_eq!(store.list_all(true).unwrap().len(), 1); + } else { + assert!(result.is_err()); + assert_eq!(store.list_all(true).unwrap(), before); + } + } + } } diff --git a/crates/tree-ring-memory-cli/src/tui/actions.rs b/crates/tree-ring-memory-cli/src/tui/actions.rs index 3a95675..51ba19f 100644 --- a/crates/tree-ring-memory-cli/src/tui/actions.rs +++ b/crates/tree-ring-memory-cli/src/tui/actions.rs @@ -122,7 +122,7 @@ impl PendingAction { .filter(|event| event.sensitivity != "normal") .count(); let summary = format!( - "Import or update {} DOX summaries from {} AGENTS.md files.\nSource: {}\nStore: {}\nProject: {}\nIncludes {} sensitive; skips {} secret sections; {} warnings.\nStable IDs update existing summaries; source files stay authoritative.", + "Import or update {} DOX summaries from {} AGENTS.md files.\nSource: {}\nStore: {}\nProject: {}\nIncludes {} sensitive; secret source files skipped: {}; {} warnings.\nStable IDs update existing summaries; source files stay authoritative.", preview.memory_count, preview.source_count, preview.root.display(), @@ -163,6 +163,35 @@ impl PendingAction { mod tests { use super::*; + #[test] + fn dox_confirmation_counts_secret_source_files_not_sections() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("AGENTS.md"), + "# Rules\nReview source contracts.\n", + ) + .unwrap(); + std::fs::create_dir(dir.path().join("nested")).unwrap(); + std::fs::write( + dir.path().join("nested/AGENTS.md"), + "# Earlier rules\nUse reviewed configuration.\n# Credentials\nUse key sk-proj-abcdefghijklmnopqrstuvwxyz1234567890\n# Later rules\nRead the source.\n", + ) + .unwrap(); + let preview = tree_ring_memory_core::collect_dox_memories( + &tree_ring_memory_core::DoxSyncRequest::new(dir.path()), + ) + .unwrap(); + assert_eq!(preview.source_count, 2); + assert_eq!(preview.skipped_secret_count, 1); + assert!(preview + .events + .iter() + .all(|event| !event.source.ref_.starts_with("nested/"))); + let pending = PendingAction::sync_dox(preview, "fixture", &dir.path().join(".tree-ring")); + assert!(pending.summary.contains("secret source files skipped: 1")); + assert!(!pending.summary.contains("secret sections")); + } + #[test] fn dangerous_actions_are_explicit_pending_values() { let pending = PendingAction::delete("mem_1".to_string(), "Bad memory".to_string()); diff --git a/crates/tree-ring-memory-cli/src/tui/app.rs b/crates/tree-ring-memory-cli/src/tui/app.rs index 5224a2a..ca64b3c 100644 --- a/crates/tree-ring-memory-cli/src/tui/app.rs +++ b/crates/tree-ring-memory-cli/src/tui/app.rs @@ -31,6 +31,7 @@ pub enum AppMode { pub struct App { root: PathBuf, + launch_root: PathBuf, agent_profile: Option, pub store: SQLiteMemoryStore, watcher: StoreWatcher, @@ -59,7 +60,13 @@ impl App { pub fn new(root: PathBuf, event_stream_path: Option) -> Result { let db_path = root.join("memory.sqlite"); let store = SQLiteMemoryStore::open(&db_path).map_err(|err| err.to_string())?; - Self::from_store(root, event_stream_path, store, None) + Self::from_store( + root, + event_stream_path, + store, + None, + std::env::current_dir().map_err(|error| error.to_string())?, + ) } pub fn new_with_context( @@ -71,7 +78,13 @@ impl App { let db_path = root.join("memory.sqlite"); let store = SQLiteMemoryStore::open_with_context(&db_path, context) .map_err(|err| err.to_string())?; - Self::from_store(root, event_stream_path, store, agent_profile) + Self::from_store( + root, + event_stream_path, + store, + agent_profile, + std::env::current_dir().map_err(|error| error.to_string())?, + ) } fn from_store( @@ -79,10 +92,12 @@ impl App { event_stream_path: Option, store: SQLiteMemoryStore, agent_profile: Option, + launch_root: PathBuf, ) -> Result { let db_path = root.join("memory.sqlite"); let mut app = Self { root, + launch_root, agent_profile, store, watcher: StoreWatcher::new(), @@ -541,7 +556,7 @@ impl App { } fn show_integrations(&mut self) { - let root = project_root_for_memory_root(&self.root); + let root = project_root_for_memory_root(&self.root, &self.launch_root); let report = scan_integrations_action(IntegrationScanRequest { source_root: root }); self.status = format!( "integration scan: {} detected under {}", @@ -554,8 +569,9 @@ impl App { fn preview_dox_sync(&mut self) -> Result<(), String> { self.pending_action = None; - let source_root = std::path::absolute(project_root_for_memory_root(&self.root)) - .map_err(|error| error.to_string())?; + let source_root = + std::path::absolute(project_root_for_memory_root(&self.root, &self.launch_root)) + .map_err(|error| error.to_string())?; let identity_root = std::fs::canonicalize(&source_root).map_err(|error| error.to_string())?; let project = identity_root @@ -575,7 +591,7 @@ impl App { .report; if preview.memory_count == 0 { self.status = format!( - "DOX sync: no eligible summaries in {} ({} files, {} secret sections skipped, {} warnings)", + "DOX sync: no eligible summaries in {} ({} files, {} secret-containing files skipped, {} warnings)", preview.root.display(), preview.source_count, preview.skipped_secret_count, @@ -601,7 +617,7 @@ impl App { } fn show_evidence(&mut self) { - let project_root = project_root_for_memory_root(&self.root); + let project_root = project_root_for_memory_root(&self.root, &self.launch_root); let evidence_dir = certification_dir_for_project(&project_root); let snapshot = load_snapshot(&evidence_dir); self.status = format!("evidence: {}", snapshot.message); @@ -714,14 +730,20 @@ fn resolve_export_path(root: &Path, target: &str) -> Result { Ok(root.join("exports").join(path)) } -fn project_root_for_memory_root(root: &Path) -> PathBuf { +fn project_root_for_memory_root(root: &Path, launch_root: &Path) -> PathBuf { if root.file_name().and_then(|name| name.to_str()) == Some(".tree-ring") { + let root = if root.is_absolute() { + root.to_path_buf() + } else { + launch_root.join(root) + }; root.parent() .filter(|parent| !parent.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")) + .unwrap_or(launch_root) .to_path_buf() } else { - root.to_path_buf() + // A custom database location does not identify the source project. + launch_root.to_path_buf() } } @@ -875,20 +897,72 @@ mod tests { assert!(app.store.list_all(true).unwrap().is_empty()); } + #[test] + fn custom_store_sync_reads_the_launch_project_and_writes_only_the_selected_store() { + let dir = tempdir().unwrap(); + let project = dir.path().join("project-source"); + let memory_root = dir.path().join("shared-storage"); + fs::create_dir(&project).unwrap(); + fs::write( + project.join("AGENTS.md"), + "# Rules\n\nUse launch project instructions.\n", + ) + .unwrap(); + let store = SQLiteMemoryStore::open(memory_root.join("memory.sqlite")).unwrap(); + fs::write( + memory_root.join("AGENTS.md"), + "# Storage notes\n\nDo not import storage directory instructions.\n", + ) + .unwrap(); + let mut app = + App::from_store(memory_root.clone(), None, store, None, project.clone()).unwrap(); + + app.execute_slash_command("/sync").unwrap(); + + let pending = app.pending_action.as_ref().unwrap(); + let ActionKind::SyncDox { preview, .. } = &pending.kind else { + panic!("expected DOX preview") + }; + assert_eq!(preview.root, project); + assert_eq!(preview.source_count, 1); + assert_eq!(preview.events[0].project.as_deref(), Some("project-source")); + assert!(preview.events[0] + .summary + .contains("Use launch project instructions")); + assert!(!preview.events[0].summary.contains("storage directory")); + assert!(app.store.list_all(true).unwrap().is_empty()); + assert!(!project.join(".tree-ring").exists()); + + confirm(&mut app); + + assert_eq!(app.dashboard.total, 1); + assert_eq!(app.store_path(), memory_root.join("memory.sqlite")); + assert!(app.store.list_all(true).unwrap()[0] + .summary + .contains("Use launch project instructions")); + assert!(!project.join(".tree-ring").exists()); + } + #[test] fn relative_memory_root_resolves_to_current_project_for_source_sync() { assert_eq!( - project_root_for_memory_root(Path::new(".tree-ring")), - Path::new(".") + project_root_for_memory_root(Path::new(".tree-ring"), Path::new("/launch")), + Path::new("/launch") ); assert_eq!( - project_root_for_memory_root(Path::new("./.tree-ring")), - Path::new(".") + project_root_for_memory_root(Path::new("./.tree-ring"), Path::new("/launch")), + Path::new("/launch") ); assert_eq!( - project_root_for_memory_root(Path::new("/project/.tree-ring")), + project_root_for_memory_root(Path::new("/project/.tree-ring"), Path::new("/launch")), Path::new("/project") ); + for custom_root in ["memory-cache", "/shared/memory-cache"] { + assert_eq!( + project_root_for_memory_root(Path::new(custom_root), Path::new("/launch")), + Path::new("/launch") + ); + } } #[test] diff --git a/crates/tree-ring-memory-cli/src/tui/render.rs b/crates/tree-ring-memory-cli/src/tui/render.rs index 370e0c9..a6f0407 100644 --- a/crates/tree-ring-memory-cli/src/tui/render.rs +++ b/crates/tree-ring-memory-cli/src/tui/render.rs @@ -912,6 +912,59 @@ mod tests { use super::*; use crate::tui::app::App; + #[test] + fn dox_preview_control_characters_never_enter_terminal_cells_or_change_candidates() { + let dir = tempdir().unwrap(); + std::fs::write( + dir.path().join("AGENTS.md"), + "# Rules\nReview source contracts.\n", + ) + .unwrap(); + let mut app = App::new(dir.path().join(".tree-ring"), None).unwrap(); + app.execute_slash_command("/sync").unwrap(); + let original = if let ActionKind::SyncDox { preview, .. } = + &mut app.pending_action.as_mut().unwrap().kind + { + preview.events[0].source.ref_ = + "SOURCE-START\u{1b}]8;;inert-target\u{7}/AGENTS.md".to_string(); + preview.events[0].summary = + "SUMMARY-START\u{1b}[2J SUMMARY-END\nNEXT-LINE\u{7}\u{0}\u{009b} TEXT-END" + .to_string(); + preview.clone() + } else { + panic!("expected DOX preview"); + }; + let mut terminal = Terminal::new(TestBackend::new(120, 36)).unwrap(); + terminal.draw(|frame| render(frame, &app)).unwrap(); + // Inspect actual rendered cells, not Debug-formatted output that could + // hide control bytes through escaping. Ratatui strips these controls. + let controls = terminal + .backend() + .buffer() + .content + .iter() + .flat_map(|cell| cell.symbol().chars()) + .filter(|character| character.is_control()) + .collect::>(); + assert!(controls.is_empty(), "rendered controls: {controls:?}"); + let output = terminal.backend().to_string(); + for text in [ + "SOURCE-START", + "SUMMARY-START", + "SUMMARY-END", + "NEXT-LINE", + "TEXT-END", + ] { + assert!(output.contains(text), "{output}"); + } + if let ActionKind::SyncDox { preview, .. } = &app.pending_action.as_ref().unwrap().kind { + assert_eq!(preview, &original); + } else { + panic!("rendering changed the pending action"); + } + assert!(app.store.list_all(true).unwrap().is_empty()); + } + #[test] fn long_dox_candidate_can_scroll_to_its_end_with_fixed_confirmation_keys() { let dir = tempdir().unwrap(); diff --git a/crates/tree-ring-memory-core/AGENTS.md b/crates/tree-ring-memory-core/AGENTS.md index 6a8b8fa..1bbd341 100644 --- a/crates/tree-ring-memory-core/AGENTS.md +++ b/crates/tree-ring-memory-core/AGENTS.md @@ -12,6 +12,8 @@ src models, scoring, sensitivity, import/export, maintenance, DOX/Revolve ingest Preserve event validation, schema-compatible serialization, source provenance and sensitivity handling. Adapter ingestion summarizes source material; it does not turn instructions into authority. +DOX keeps stable source IDs and adds a canonical source-root fingerprint in a `dox-root` link. A secret-bearing source file is skipped entirely. A moved or copied root has a different fingerprint; do not silently rebind existing provenance. + ## Work Guidance Change the owning module and its existing tests; coordinate persistence or public CLI effects with sibling crates. diff --git a/crates/tree-ring-memory-core/src/dox.rs b/crates/tree-ring-memory-core/src/dox.rs index b7c1b7d..56fdc6f 100644 --- a/crates/tree-ring-memory-core/src/dox.rs +++ b/crates/tree-ring-memory-core/src/dox.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::fs; use std::path::{Path, PathBuf}; @@ -33,6 +34,7 @@ pub struct DoxSyncReport { pub root: PathBuf, pub source_count: usize, pub memory_count: usize, + /// Files rejected because a generated candidate contained a secret. pub skipped_secret_count: usize, pub warnings: Vec, pub events: Vec, @@ -40,12 +42,15 @@ pub struct DoxSyncReport { pub fn collect_dox_memories(request: &DoxSyncRequest) -> TreeRingResult { let files = discover_agents_files(&request.root, request.max_files)?; + // Discovery must reject a symlinked root before canonicalization is used + // for provenance. This identity does not select or authorize source files. + let root_fingerprint = dox_root_fingerprint(&request.root)?; let mut events = Vec::new(); let mut warnings = Vec::new(); let mut skipped_secret_count = 0; for path in &files { - match events_from_agents_file(request, path) { + match events_from_agents_file(request, path, &root_fingerprint) { Ok(mut file_events) => events.append(&mut file_events), Err(AdapterSkip::Secret) => skipped_secret_count += 1, Err(AdapterSkip::Unreadable(message)) => warnings.push(message), @@ -63,6 +68,21 @@ pub fn collect_dox_memories(request: &DoxSyncRequest) -> TreeRingResult TreeRingResult { + let canonical = fs::canonicalize(root).map_err(|err| sqlite_error(err.to_string()))?; + let directory = if canonical.is_file() { + canonical + .parent() + .ok_or_else(|| sqlite_error("DOX source file parent is unavailable".to_string()))? + } else { + canonical.as_path() + }; + let mut hasher = Sha256::new(); + hasher.update(b"tree-ring-dox-root-v1\0"); + hasher.update(directory.as_os_str().as_encoded_bytes()); + Ok(format!("{:x}", hasher.finalize())) +} + fn discover_agents_files(root: &Path, max_files: usize) -> TreeRingResult> { let mut output = Vec::new(); if !root.exists() { @@ -132,6 +152,7 @@ fn should_skip_dir(path: &Path) -> bool { fn events_from_agents_file( request: &DoxSyncRequest, path: &Path, + root_fingerprint: &str, ) -> Result, AdapterSkip> { let metadata = fs::symlink_metadata(path).map_err(|err| AdapterSkip::Unreadable(err.to_string()))?; @@ -203,6 +224,10 @@ fn events_from_agents_file( link_type: "dox".to_string(), target: relative.clone(), }); + event.links.push(MemoryLink { + link_type: "dox-root".to_string(), + target: root_fingerprint.to_string(), + }); match guard.detect_memory_event_sensitivity(&event) { Ok(sensitivity) => { if sensitivity != "normal" { @@ -409,6 +434,97 @@ mod tests { assert!(report.events.iter().all(|event| event.scope == "dox")); } + fn root_provenance(event: &MemoryEvent) -> &str { + let links = event + .links + .iter() + .filter(|link| link.link_type == "dox-root") + .collect::>(); + assert_eq!(links.len(), 1); + &links[0].target + } + + #[test] + fn equivalent_source_paths_keep_ids_and_root_provenance_stable() { + let dir = tempfile::Builder::new() + .prefix(".dox-root-test-") + .tempdir_in(".") + .unwrap(); + fs::write( + dir.path().join("AGENTS.md"), + "# Rules\nRead source contracts.\n", + ) + .unwrap(); + let absolute = fs::canonicalize(dir.path()).unwrap(); + let relative = PathBuf::from(dir.path().file_name().unwrap()); + let first = collect_dox_memories(&DoxSyncRequest::new(&relative)).unwrap(); + let second = collect_dox_memories(&DoxSyncRequest::new(absolute.join("."))).unwrap(); + let repeated = collect_dox_memories(&DoxSyncRequest::new(&absolute)).unwrap(); + + let event = &first.events[0]; + assert_eq!(event.id, stable_id("dox", "AGENTS.md#rules-2")); + assert_eq!(event.id, second.events[0].id); + assert_eq!(event.id, repeated.events[0].id); + assert_eq!(event.source.ref_, "AGENTS.md#rules-2"); + assert!(event + .links + .iter() + .any(|link| link.link_type == "dox" && link.target == "AGENTS.md")); + let fingerprint = root_provenance(event); + assert_eq!(fingerprint, root_provenance(&second.events[0])); + assert_eq!(fingerprint, root_provenance(&repeated.events[0])); + assert_eq!(fingerprint.len(), 64); + assert!(fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit())); + + let file = collect_dox_memories(&DoxSyncRequest::new(absolute.join("AGENTS.md"))).unwrap(); + assert_eq!(fingerprint, root_provenance(&file.events[0])); + } + + #[test] + fn different_source_roots_with_the_same_project_name_have_distinct_provenance() { + let dir = tempdir().unwrap(); + let roots = [ + dir.path().join("first/project"), + dir.path().join("second/project"), + ]; + let mut reports = Vec::new(); + for root in roots { + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("AGENTS.md"), "# Rules\nRead source contracts.\n").unwrap(); + let mut request = DoxSyncRequest::new(root); + request.project = Some("project".to_string()); + reports.push(collect_dox_memories(&request).unwrap()); + } + + let first = &reports[0].events[0]; + let second = &reports[1].events[0]; + // Legacy source IDs stay stable; the write boundary can now reject + // this collision without overwriting or duplicating either record. + assert_eq!(first.id, second.id); + assert_eq!(first.project, second.project); + assert_ne!(root_provenance(first), root_provenance(second)); + } + + #[cfg(unix)] + #[test] + fn provenance_canonicalization_does_not_accept_a_symlinked_source_root() { + use std::os::unix::fs::symlink; + + let dir = tempdir().unwrap(); + let source = dir.path().join("source"); + fs::create_dir(&source).unwrap(); + fs::write( + source.join("AGENTS.md"), + "# Rules\nRead source contracts.\n", + ) + .unwrap(); + let alias = dir.path().join("alias"); + symlink(&source, &alias).unwrap(); + + let error = collect_dox_memories(&DoxSyncRequest::new(alias)).unwrap_err(); + assert!(error.to_string().contains("cannot be a symlink")); + } + #[test] fn summarizes_agents_without_full_doc_dump() { let dir = tempdir().unwrap(); @@ -462,6 +578,33 @@ mod tests { assert_ne!(report.events[0].source.ref_, report.events[1].source.ref_); } + #[test] + fn secret_skip_count_is_per_file_and_discards_its_earlier_sections() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("AGENTS.md"), + "# Rules\nRead source contracts before editing.\n\n## Example credential\nUse key sk-proj-abcdefghijklmnopqrstuvwxyz1234567890\n\n## Another credential\nUse key sk-proj-abcdefghijklmnopqrstuvwxyz0987654321\n", + ) + .unwrap(); + fs::create_dir(dir.path().join("safe")).unwrap(); + fs::write( + dir.path().join("safe/AGENTS.md"), + "# Verification\nRun focused tests.\n", + ) + .unwrap(); + + let report = collect_dox_memories(&DoxSyncRequest::new(dir.path())).unwrap(); + + assert_eq!(report.source_count, 2); + assert_eq!(report.skipped_secret_count, 1); + assert_eq!(report.memory_count, 1); + assert_eq!( + report.events[0].source.ref_, + "safe/AGENTS.md#verification-2" + ); + assert!(report.warnings.is_empty()); + } + #[cfg(unix)] #[test] fn skips_symlinked_directories() { diff --git a/crates/tree-ring-memory-sqlite/AGENTS.md b/crates/tree-ring-memory-sqlite/AGENTS.md index 06bb7e3..96af57e 100644 --- a/crates/tree-ring-memory-sqlite/AGENTS.md +++ b/crates/tree-ring-memory-sqlite/AGENTS.md @@ -12,6 +12,8 @@ Schema, write transactions, FTS, lifecycle maintenance, policy and session recal Preserve transaction durability, idempotent operations, coordinated-write authorization and schema-v3 writer fencing. Explicit RecallOptions remain conjunctive; SessionRecallScope applies the documented cross-session visibility rules. Filter scope, expiry, supersession, redaction and sensitivity before candidate limits. +DOX batch writes check existing project, source and root provenance within the write transaction. A conflict rejects the whole batch. Adoption of matching legacy records without root provenance requires the caller to verify the source project's own local store. + ## Work Guidance Exercise concurrent writes and old-session recall where relevant. Do not weaken privacy or scope checks to fill a result budget. diff --git a/crates/tree-ring-memory-sqlite/src/lib.rs b/crates/tree-ring-memory-sqlite/src/lib.rs index 6ce3005..bf2150c 100644 --- a/crates/tree-ring-memory-sqlite/src/lib.rs +++ b/crates/tree-ring-memory-sqlite/src/lib.rs @@ -446,6 +446,25 @@ impl SQLiteMemoryStore { } pub fn put_many(&mut self, events: &[MemoryEvent]) -> TreeRingResult<()> { + self.put_many_with_dox_guard(events, None) + } + + /// Persist a DOX batch without overwriting a different source that shares + /// a legacy stable ID. Legacy rows without root provenance may be adopted + /// only when the caller has verified the source project's local store. + pub fn put_dox_many( + &mut self, + events: &[MemoryEvent], + allow_legacy_sources: bool, + ) -> TreeRingResult<()> { + self.put_many_with_dox_guard(events, Some(allow_legacy_sources)) + } + + fn put_many_with_dox_guard( + &mut self, + events: &[MemoryEvent], + dox_guard: Option, + ) -> TreeRingResult<()> { let write_context = self.write_context.clone(); let event_refs = events.iter().collect::>(); write::retry_locked(|| { @@ -456,7 +475,11 @@ impl SQLiteMemoryStore { if let policy::AuthorizationOutcome::Denied(error) = policy::authorize_event_creates( &transaction, &write_context, - "put_many", + if dox_guard.is_some() { + "put_dox_many" + } else { + "put_many" + }, &event_refs, )? { transaction.commit().map_err(sqlite_error_from_rusqlite)?; @@ -474,6 +497,9 @@ impl SQLiteMemoryStore { .map_err(sqlite_error_from_rusqlite)?; for event in events { + if let Some(allow_legacy_sources) = dox_guard { + validate_dox_source_update(&transaction, event, allow_legacy_sources)?; + } write::prepare_memory_write(&transaction, event)?; write::put_with_statements( event, @@ -1736,6 +1762,69 @@ const SEARCH_FILLER_TERMS: &[&str] = &[ "what", ]; +fn dox_root_provenance(event: &MemoryEvent) -> TreeRingResult> { + let mut roots = event + .links + .iter() + .filter(|link| link.link_type == "dox-root"); + let root = roots.next(); + if roots.next().is_some() || root.is_some_and(|link| link.target.trim().is_empty()) { + return Err(TreeRingError::Validation( + "invalid DOX root provenance; expected one non-empty dox-root link; rebuild the source preview or reconcile legacy provenance".to_string(), + )); + } + Ok(root.map(|link| link.target.as_str())) +} + +fn validate_dox_source_update( + connection: &Connection, + event: &MemoryEvent, + allow_legacy_sources: bool, +) -> TreeRingResult<()> { + if event.scope != "dox" || event.source.source_type != "dox" { + return Err(TreeRingError::Validation( + "DOX batch requires DOX-scoped events with DOX source provenance".to_string(), + )); + } + let root = dox_root_provenance(event)?.ok_or_else(|| { + TreeRingError::Validation( + "DOX batch requires one non-empty dox-root link; rebuild the source preview" + .to_string(), + ) + })?; + let existing = connection + .query_row( + "SELECT raw_json FROM memories WHERE id = ?", + params![event.id], + search::event_from_row, + ) + .optional() + .map_err(sqlite_error_from_rusqlite)? + .transpose()?; + let Some(existing) = existing else { + return Ok(()); + }; + if existing.project != event.project + || existing.scope != event.scope + || existing.source.source_type != event.source.source_type + || existing.source.ref_ != event.source.ref_ + { + return Err(TreeRingError::Validation( + "DOX source identity collides with an existing memory; use a separate project store or explicitly reconcile legacy source provenance".to_string(), + )); + } + match dox_root_provenance(&existing)? { + Some(existing_root) if existing_root == root => Ok(()), + None if allow_legacy_sources => Ok(()), + None => Err(TreeRingError::Validation( + "legacy DOX memory has no root provenance in this shared or custom store; use the source project's local .tree-ring or explicitly reconcile legacy source provenance".to_string(), + )), + Some(_) => Err(TreeRingError::Validation( + "DOX root provenance collides with an existing source; use a separate project store or explicitly reconcile source provenance".to_string(), + )), + } +} + #[cfg(test)] mod tests { use super::*; @@ -1744,7 +1833,7 @@ mod tests { thread, }; use tempfile::tempdir; - use tree_ring_memory_core::models::MemorySource; + use tree_ring_memory_core::models::{MemoryLink, MemorySource}; #[test] fn database_path_reports_the_main_filesystem_store() { @@ -2743,6 +2832,203 @@ mod tests { assert_eq!(results[0].ring, "scar"); } + #[test] + fn dox_batch_retries_update_only_the_same_source_without_duplicates() { + let dir = tempdir().unwrap(); + let mut store = SQLiteMemoryStore::open(dir.path().join("memory.sqlite")).unwrap(); + let mut event = dox_fixture("mem_dox_rules", "project", Some('a')); + store.put_dox_many(&[event.clone()], false).unwrap(); + event.summary = "Updated source guidance.".to_string(); + store.put_dox_many(&[event.clone()], false).unwrap(); + assert_eq!(store.list_all(true).unwrap(), vec![event]); + assert_eq!( + store + .search_text("Updated source guidance", true) + .unwrap() + .len(), + 1 + ); + } + + fn dox_fixture(id: &str, project: &str, root: Option) -> MemoryEvent { + let mut event = MemoryEvent::new("Source guidance.", "dox_guidance").unwrap(); + event.id = id.to_string(); + event.scope = "dox".to_string(); + event.project = Some(project.to_string()); + event.source.source_type = "dox".to_string(); + event.source.ref_ = "AGENTS.md#rules-1".to_string(); + if let Some(root) = root { + event.links.push(MemoryLink { + link_type: "dox-root".to_string(), + target: root.to_string().repeat(64), + }); + } + event + } + + #[test] + fn dox_batch_source_collisions_reject_every_write_including_fts() { + for conflict in 0..5 { + let dir = tempdir().unwrap(); + let mut store = SQLiteMemoryStore::open(dir.path().join("memory.sqlite")).unwrap(); + let incoming = dox_fixture("mem_dox_rules", "same-project-name", Some('a')); + let mut existing = incoming.clone(); + match conflict { + 0 => existing.project = Some("different-project".to_string()), + 1 => existing.links[0].target = "b".repeat(64), + 2 => existing.scope = "project".to_string(), + 3 => existing.source.source_type = "manual".to_string(), + 4 => existing.source.ref_ = "other/AGENTS.md#rules-1".to_string(), + _ => unreachable!(), + } + store.put(&existing).unwrap(); + let mut fresh = incoming.clone(); + fresh.id = "mem_fresh_dox".to_string(); + fresh.summary = "Never persist partial DOX batch.".to_string(); + let error = store + .put_dox_many(&[fresh.clone(), incoming], true) + .unwrap_err(); + assert!( + error.to_string().contains("separate project store"), + "{conflict}: {error}" + ); + assert!(store.get(&fresh.id).unwrap().is_none()); + assert_eq!(store.get(&existing.id).unwrap(), Some(existing)); + assert!(store + .search_text("Never persist partial", true) + .unwrap() + .is_empty()); + } + } + + #[test] + fn dox_batch_validates_every_incoming_identity_and_root_before_commit() { + for invalid in 0..5 { + let dir = tempdir().unwrap(); + let mut store = SQLiteMemoryStore::open(dir.path().join("memory.sqlite")).unwrap(); + let fresh = dox_fixture("mem_fresh_dox", "project", Some('a')); + let mut bad = dox_fixture("mem_invalid_dox", "project", Some('a')); + match invalid { + 0 => bad.scope = "project".to_string(), + 1 => bad.source.source_type = "manual".to_string(), + 2 => bad.links.clear(), + 3 => bad.links[0].target = " ".to_string(), + 4 => bad.links.push(bad.links[0].clone()), + _ => unreachable!(), + } + assert!(store.put_dox_many(&[fresh, bad], false).is_err()); + assert!(store.list_all(true).unwrap().is_empty()); + let fts_count: i64 = store + .connection_for_testing() + .query_row("SELECT count(*) FROM memory_fts", [], |row| row.get(0)) + .unwrap(); + assert_eq!(fts_count, 0); + } + } + + #[test] + fn dox_batch_shared_legacy_requires_explicit_local_store_eligibility() { + let dir = tempdir().unwrap(); + let mut store = SQLiteMemoryStore::open(dir.path().join("memory.sqlite")).unwrap(); + let legacy = dox_fixture("mem_dox_rules", "project", None); + store.put(&legacy).unwrap(); + let update = dox_fixture("mem_dox_rules", "project", Some('a')); + let error = store.put_dox_many(&[update.clone()], false).unwrap_err(); + assert!(error + .to_string() + .contains("legacy DOX memory has no root provenance")); + assert_eq!(store.get(&legacy.id).unwrap(), Some(legacy)); + store.put_dox_many(&[update.clone()], true).unwrap(); + store.put_dox_many(&[update.clone()], false).unwrap(); + assert_eq!(store.get(&update.id).unwrap(), Some(update)); + } + + #[test] + fn dox_batch_detects_colliding_ids_within_the_same_transaction() { + let dir = tempdir().unwrap(); + let mut store = SQLiteMemoryStore::open(dir.path().join("memory.sqlite")).unwrap(); + let first = dox_fixture("mem_dox_rules", "same-project-name", Some('a')); + let second = dox_fixture("mem_dox_rules", "same-project-name", Some('b')); + assert!(store.put_dox_many(&[first, second], true).is_err()); + assert!(store.list_all(true).unwrap().is_empty()); + } + + #[test] + fn dox_batch_preserves_coordinated_authorization_and_root_guard() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("memory.sqlite"); + let mut store = SQLiteMemoryStore::open(&db_path).unwrap(); + let grant = store + .enable_coordinated_policy(Some("test-coordinator")) + .unwrap(); + let event = dox_fixture("mem_dox_rules", "project", Some('a')); + assert_authorization_denied(store.put_dox_many(&[event.clone()], true)); + assert!(store.list_all(true).unwrap().is_empty()); + assert!(store + .policy_audit(10) + .unwrap() + .iter() + .any(|row| row.action == "put_dox_many" && row.decision == "denied")); + let context = WriteContext::new(None, Some(&grant.capability), "dox-test").unwrap(); + let mut coordinator = SQLiteMemoryStore::open_with_context(&db_path, context).unwrap(); + coordinator.put_dox_many(&[event.clone()], false).unwrap(); + let collision = dox_fixture("mem_dox_rules", "project", Some('b')); + assert!(coordinator.put_dox_many(&[collision], true).is_err()); + assert_eq!(coordinator.get(&event.id).unwrap(), Some(event)); + } + + #[test] + fn concurrent_dox_batches_cannot_replace_another_root_after_validation() { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("memory.sqlite"); + let store_a = SQLiteMemoryStore::open(&db_path).unwrap(); + let store_b = SQLiteMemoryStore::open(&db_path).unwrap(); + let barrier = Arc::new(Barrier::new(2)); + let workers = [(store_a, 'a'), (store_b, 'b')] + .into_iter() + .map(|(mut store, root)| { + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + let shared = dox_fixture("mem_dox_rules", "same-project-name", Some(root)); + let unique = dox_fixture( + &format!("mem_dox_unique_{root}"), + "same-project-name", + Some(root), + ); + barrier.wait(); + store.put_dox_many(&[unique, shared], false) + }) + }) + .collect::>(); + let outcomes = workers + .into_iter() + .map(|worker| worker.join().unwrap()) + .collect::>(); + assert_eq!(outcomes.iter().filter(|result| result.is_ok()).count(), 1); + assert!(outcomes + .iter() + .filter_map(|result| result.as_ref().err()) + .all(|error| error.to_string().contains("root provenance collides"))); + let store = SQLiteMemoryStore::open(&db_path).unwrap(); + let events = store.list_all(true).unwrap(); + assert_eq!(events.len(), 2); + assert_eq!( + dox_root_provenance(&events[0]).unwrap(), + dox_root_provenance(&events[1]).unwrap() + ); + } + + #[test] + fn ordinary_batch_keeps_existing_upsert_behavior_without_dox_provenance() { + let dir = tempdir().unwrap(); + let mut store = SQLiteMemoryStore::open(dir.path().join("memory.sqlite")).unwrap(); + let event = MemoryEvent::new("Original ordinary memory.", "lesson").unwrap(); + let mut update = event.clone(); + update.summary = "Updated ordinary memory.".to_string(); + store.put_many(&[event, update.clone()]).unwrap(); + assert_eq!(store.list_all(true).unwrap(), vec![update]); + } + #[test] fn put_many_inserts_memory_and_fts_rows_in_one_batch() { let dir = tempdir().unwrap(); From b7b5686641d3cf0e139ac510c8cdd344623ec2d7 Mon Sep 17 00:00:00 2001 From: lazy Date: Tue, 15 Sep 2026 17:11:35 -0400 Subject: [PATCH 2/3] Require guarded DOX writes in plugin and project guidance --- .claude-plugin/marketplace.json | 2 +- .../src/agent_awareness.rs | 45 +++++++++++++++++++ .../.claude-plugin/plugin.json | 2 +- .../.codex-plugin/plugin.json | 2 +- plugins/tree-ring-memory/README.md | 13 ++++-- .../commands/tree-ring-dox-sync.md | 7 ++- .../.codex-plugin/plugin.json | 2 +- .../skills/tree-ring-memory/SKILL.md | 19 ++++++-- scripts/validate-plugin-packages.py | 15 +++++-- skills/tree-ring-memory/SKILL.md | 12 ++++- 10 files changed, 103 insertions(+), 16 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index cfdd557..242f9e1 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,7 +5,7 @@ "url": "https://github.com/TerminallyLazy" }, "description": "Claude Code marketplace for Tree Ring Memory v0.15 verified bootstrap, lifecycle recall, strict automatic capture, and receipt-backed harness readiness.", - "version": "0.3.5", + "version": "0.3.6", "plugins": [ { "name": "tree-ring-memory", diff --git a/crates/tree-ring-memory-cli/src/agent_awareness.rs b/crates/tree-ring-memory-cli/src/agent_awareness.rs index 721fb4f..d29afcf 100644 --- a/crates/tree-ring-memory-cli/src/agent_awareness.rs +++ b/crates/tree-ring-memory-cli/src/agent_awareness.rs @@ -24,6 +24,8 @@ const AGENT_RUNTIME_HEADING: &str = "## Local Runtime Bootstrap And Updates"; const AGENT_RUNTIME_ANCHOR: &str = "## Harness Bridges"; const SKILL_RUNTIME_HEADING: &str = "## Runtime Bootstrap And Updates"; const SKILL_RUNTIME_ANCHOR: &str = "## When To Recall"; +const SKILL_DOX_COMPAT_HEADING: &str = "## DOX Persistence Compatibility"; +const SKILL_DOX_COMPAT_ANCHOR: &str = "## Harness Activation"; const CLI_RUNTIME_HEADING: &str = "Runtime bootstrap and updates:"; const CLI_RUNTIME_ANCHOR: &str = "Core commands:"; const PREFLIGHT_HEADING: &str = "## Harness Preflight"; @@ -218,6 +220,17 @@ pub fn ensure_agent_awareness(root: &Path) -> Result --dry-run` and inspect every summary and source reference. -4. Persist only concise, useful summaries. In a Coordinated store, persistence - requires coordinator authority; dry-run discovery does not. +4. Before persisting, verify the selected CLI is 0.15.11 or newer using + `--version`; older runtimes are preview-only for DOX. After an authorized + upgrade, rerun and review the preview. Persist only concise, useful summaries. + In a Coordinated store, persistence requires coordinator authority; dry-run + discovery does not. 5. Never use the adapter to rewrite a root or child `AGENTS.md`, copy whole contract trees into memory, or weaken child instructions. Re-run the dry run after a source contract changes and re-read the chain before the next edit. +## DOX Persistence Compatibility + +DOX persistence requires Tree Ring CLI 0.15.11 or newer. Check the selected +project-local or PATH binary with `--version` before any DOX write. Older +runtimes may preview with `--dry-run`, but must not persist DOX summaries. +Upgrade through the existing installation scope when authorized, then rerun +and review the preview with the updated binary. This minimum applies only to +DOX persistence: 0.15.11 adds source-root collision checks that reject the +entire conflicting batch instead of overwriting another project's guidance. + ## Harness Activation For a new project, begin with the safe, project-local default: diff --git a/scripts/validate-plugin-packages.py b/scripts/validate-plugin-packages.py index 3957fa3..000bd7e 100644 --- a/scripts/validate-plugin-packages.py +++ b/scripts/validate-plugin-packages.py @@ -68,7 +68,7 @@ def validate_codex() -> None: manifest = load_json(PLUGIN / ".codex-plugin" / "plugin.json") require(manifest.get("name") == "tree-ring-memory", "Codex manifest name is stale") - require(manifest.get("version") == "0.3.7", "Codex manifest version is stale") + require(manifest.get("version") == "0.3.8", "Codex manifest version is stale") require(manifest.get("skills") == "./skills/", "Codex skills path is stale") require(manifest.get("hooks") == "./hooks/codex-hooks.json", "Codex lifecycle hook path is stale") for unsupported in ("mcpServers", "apps"): @@ -92,7 +92,7 @@ def validate_codex() -> None: def validate_claude() -> None: marketplace = load_json(ROOT / ".claude-plugin" / "marketplace.json") require(marketplace.get("name") == "tree-ring-memory", "Claude marketplace name is stale") - require(marketplace.get("version") == "0.3.5", "Claude marketplace version is stale") + require(marketplace.get("version") == "0.3.6", "Claude marketplace version is stale") require(isinstance(marketplace.get("owner"), dict), "Claude marketplace owner is required") entries = marketplace.get("plugins") require(isinstance(entries, list) and len(entries) == 1, "Claude marketplace must contain one plugin") @@ -309,6 +309,12 @@ def validate_codex_skills_only() -> None: def validate_shared_contract() -> None: skill = PLUGIN / "skills" / "tree-ring-memory" / "SKILL.md" + require_markers( + ROOT / "skills" / "tree-ring-memory" / "SKILL.md", + ["## DOX Persistence Compatibility", + "DOX persistence requires Tree Ring CLI 0.15.11 or newer", + "Older runtimes may preview with `--dry-run`, but must not persist DOX summaries"], + ) require_markers( skill, [ @@ -318,6 +324,9 @@ def validate_shared_contract() -> None: "tree-ring update --check", "which -a tree-ring", "DOX Contract Flow", + "## DOX Persistence Compatibility", + "DOX persistence requires Tree Ring CLI 0.15.11 or newer", + "Older runtimes may preview with `--dry-run`, but must not persist DOX summaries", "tree-ring dox sync --source-root --dry-run", "Certification Boundary", "tree-ring integrations certify --source-root .", @@ -334,7 +343,7 @@ def validate_shared_contract() -> None: ) require_markers( PLUGIN / "commands" / "tree-ring-dox-sync.md", - ["--dry-run", "Current source contracts are authoritative", "must not rewrite root or child `AGENTS.md` files"], + ["--dry-run", "Current source contracts are authoritative", "must not rewrite root or child `AGENTS.md` files", "Tree Ring CLI 0.15.11 or newer", "Older runtimes may preview, but must not persist DOX summaries"], ) require_markers( PLUGIN / "commands" / "tree-ring-certify.md", diff --git a/skills/tree-ring-memory/SKILL.md b/skills/tree-ring-memory/SKILL.md index 21031cc..cc053ce 100644 --- a/skills/tree-ring-memory/SKILL.md +++ b/skills/tree-ring-memory/SKILL.md @@ -3,7 +3,7 @@ name: tree-ring-memory description: Guides AI agents in using Tree Ring Memory for durable recall, project decisions, user preferences, warnings, future seeds, privacy-safe memory capture, and lifecycle-aware forgetting. license: MIT metadata: - version: "0.15.1" + version: "0.15.11" tags: "memory, agents, recall, privacy, projects, dox, revolve, skills, cli" triggers: "remember this; recall what we decided; what did we learn; tree ring memory; consolidate memory; forget this; project memory; sync DOX; sync Revolve; evidence loop; multi-agent memory" --- @@ -146,6 +146,16 @@ as a pointer only. Read the project-local `.tree-ring/SKILL.md` and Do not assume a global Tree Ring setup applies to the current repo unless the user explicitly configured it. +## DOX Persistence Compatibility + +DOX persistence requires Tree Ring CLI 0.15.11 or newer. Check the selected +project-local or PATH binary with `--version` before any DOX write. Older +runtimes may preview with `--dry-run`, but must not persist DOX summaries. +Upgrade through the existing installation scope when authorized, then rerun +and review the preview with the updated binary. This minimum applies only to +DOX persistence: 0.15.11 adds source-root collision checks that reject the +entire conflicting batch instead of overwriting another project's guidance. + ## Harness Activation For a new project, begin with the safe, project-local default: From 544af4d8d0b190392c7cfcd824b3b8c687a0c007 Mon Sep 17 00:00:00 2001 From: lazy Date: Tue, 15 Sep 2026 17:21:57 -0400 Subject: [PATCH 3/3] Resolve actual DOX source and store paths before legacy adoption --- README.md | 16 ++ .../src/actions/adapters.rs | 20 ++- .../tests/dox_path_acceptance.rs | 146 ++++++++++++++++++ 3 files changed, 175 insertions(+), 7 deletions(-) create mode 100644 crates/tree-ring-memory-cli/tests/dox_path_acceptance.rs diff --git a/README.md b/README.md index 4fce1cb..b9cab43 100644 --- a/README.md +++ b/README.md @@ -688,6 +688,22 @@ can be updated in the source project's `.tree-ring` store; their earlier locatio cannot be verified. Shared legacy records and fingerprinted records copied from a different root require provenance review before reuse. Sync does not automatically rebind a recorded fingerprint or migrate IDs. + +If a shared legacy store rejects a sync, preserve that store and select an empty, +dedicated store for this source project. For example, when the project's +`.tree-ring` store is unused: + +```bash +tree-ring --root /path/to/project/.tree-ring dox sync --source-root /path/to/project --project project-name --dry-run +# After reviewing the candidates, repeat without --dry-run to save them. +``` + +If that destination already contains conflicting records, choose a new dedicated +directory with `--root` instead. Existing memories remain in the original store; +this creates reviewed summaries from authoritative source files and does not +transfer or relabel old memories. There is no automatic legacy-provenance +migration command. + In Coordinated mode, saving requires the coordinator capability in the terminal environment when the TUI starts; preview remains available without it. diff --git a/crates/tree-ring-memory-cli/src/actions/adapters.rs b/crates/tree-ring-memory-cli/src/actions/adapters.rs index d8f812f..5bfba52 100644 --- a/crates/tree-ring-memory-cli/src/actions/adapters.rs +++ b/crates/tree-ring-memory-cli/src/actions/adapters.rs @@ -62,18 +62,24 @@ pub fn apply_dox_preview( // Older DOX records have no root provenance. Only a source project's own // .tree-ring store can establish that association without guessing which // project originally wrote a shared legacy record. - let source_root = if report.root.is_file() { - report.root.parent().unwrap_or(&report.root) - } else { - &report.root - }; - let source_root = std::fs::canonicalize(source_root).ok(); + // Resolve a bare file spelling such as AGENTS.md before taking its parent; + // its lexical parent is empty, not a canonicalizable project directory. + let source_root = std::fs::canonicalize(&report.root).ok().and_then(|source| { + if source.is_file() { + source.parent().map(|parent| parent.to_path_buf()) + } else { + Some(source) + } + }); let local_store_project = store.database_path().ok().and_then(|database| { + // A local-looking directory or database symlink does not establish + // ownership of an external legacy store. Inspect the resolved target. + let database = std::fs::canonicalize(database).ok()?; let memory_root = database.parent()?; if memory_root.file_name()? != ".tree-ring" { return None; } - std::fs::canonicalize(memory_root.parent()?).ok() + Some(memory_root.parent()?.to_path_buf()) }); let allow_legacy_sources = source_root.is_some() && source_root == local_store_project; store diff --git a/crates/tree-ring-memory-cli/tests/dox_path_acceptance.rs b/crates/tree-ring-memory-cli/tests/dox_path_acceptance.rs new file mode 100644 index 0000000..cfa0f3d --- /dev/null +++ b/crates/tree-ring-memory-cli/tests/dox_path_acceptance.rs @@ -0,0 +1,146 @@ +use std::{ + fs, + path::Path, + process::{Command, Output}, +}; + +use tempfile::tempdir; +use tree_ring_memory_core::{collect_dox_memories, DoxSyncRequest, MemoryEvent}; +use tree_ring_memory_sqlite::SQLiteMemoryStore; + +const PROJECT: &str = "dox-path-acceptance"; + +fn legacy_event(source: &Path) -> MemoryEvent { + let mut request = DoxSyncRequest::new(source); + request.project = Some(PROJECT.to_string()); + let mut event = collect_dox_memories(&request) + .unwrap() + .events + .pop() + .unwrap(); + event.links.retain(|link| link.link_type != "dox-root"); + event +} + +fn write_legacy(database: &Path, event: &MemoryEvent) { + fs::create_dir_all(database.parent().unwrap()).unwrap(); + SQLiteMemoryStore::open(database) + .unwrap() + .put(event) + .unwrap(); +} + +fn sync(project_root: &Path, source_root: &str) -> Output { + Command::new(env!("CARGO_BIN_EXE_tree-ring")) + .current_dir(project_root) + .env("PATH", "") + .env_remove("TREE_RING_AGENT_PROFILE") + .env_remove("TREE_RING_WORKFLOW_ID") + .env_remove("TREE_RING_SESSION_ID") + .env_remove("TREE_RING_OPERATION_ID") + .env_remove("TREE_RING_COORDINATOR_TOKEN") + .args([ + "--json", + "--root", + ".tree-ring", + "dox", + "sync", + "--source-root", + source_root, + "--project", + PROJECT, + ]) + .output() + .unwrap() +} + +#[test] +fn bare_relative_agents_file_adopts_legacy_provenance_in_its_local_store() { + let temp = tempdir().unwrap(); + let project = temp.path().join("Project With Spaces"); + fs::create_dir(&project).unwrap(); + let source = project.join("AGENTS.md"); + fs::write(&source, "# Rules\n\nKeep project guidance.\n").unwrap(); + let legacy = legacy_event(&source); + let database = project.join(".tree-ring/memory.sqlite"); + write_legacy(&database, &legacy); + + for _ in 0..2 { + let output = sync(&project, "AGENTS.md"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let saved = SQLiteMemoryStore::open_read_only(&database) + .unwrap() + .list_all(true) + .unwrap(); + assert_eq!(saved.len(), 1); + assert_eq!(saved[0].id, legacy.id); + assert_eq!(saved[0].summary, legacy.summary); + assert_eq!( + saved[0] + .links + .iter() + .filter(|link| link.link_type == "dox-root") + .count(), + 1 + ); + } +} + +#[cfg(unix)] +#[test] +fn symlinked_store_paths_cannot_adopt_legacy_provenance_or_insert_partial_batches() { + use std::os::unix::fs::symlink; + + for link_directory in [true, false] { + let temp = tempdir().unwrap(); + let project = temp.path().join("project"); + fs::create_dir(&project).unwrap(); + let source = project.join("AGENTS.md"); + fs::write( + &source, + "# Fresh\n\nRun scoped checks.\n\n# Rules\n\nKeep project guidance.\n", + ) + .unwrap(); + let legacy = legacy_event(&source); + let database = temp.path().join("other-project/.tree-ring/memory.sqlite"); + write_legacy(&database, &legacy); + let alias = project.join(".tree-ring"); + if link_directory { + symlink(database.parent().unwrap(), &alias).unwrap(); + } else { + fs::create_dir(&alias).unwrap(); + symlink(&database, alias.join("memory.sqlite")).unwrap(); + } + + // The absolute source spelling isolates store aliasing from the bare + // relative source regression above. Both spellings must remain safe. + let output = sync(&project, source.to_str().unwrap()); + assert!( + !output.status.success(), + "symlinked directory={link_directory} unexpectedly adopted legacy provenance" + ); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("legacy DOX memory has no root provenance"), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let saved = SQLiteMemoryStore::open_read_only(&database) + .unwrap() + .list_all(true) + .unwrap(); + assert_eq!(saved, vec![legacy]); + assert!(fs::symlink_metadata(if link_directory { + alias + } else { + alias.join("memory.sqlite") + }) + .unwrap() + .file_type() + .is_symlink()); + } +}