diff --git a/Cargo.lock b/Cargo.lock
index 62b7983..7eb0d24 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1690,11 +1690,12 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "tree-ring-memory-cli"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"chrono",
"clap",
"ratatui",
+ "rusqlite",
"serde",
"serde_json",
"tempfile",
@@ -1704,7 +1705,7 @@ dependencies = [
[[package]]
name = "tree-ring-memory-core"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"chrono",
"libc",
@@ -1720,13 +1721,15 @@ dependencies = [
[[package]]
name = "tree-ring-memory-sqlite"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"rusqlite",
+ "serde",
"serde_json",
"sha2",
"tempfile",
"tree-ring-memory-core",
+ "uuid",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index ee05dc4..055386e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -7,7 +7,7 @@ members = [
resolver = "2"
[workspace.package]
-version = "0.12.0"
+version = "0.13.0"
edition = "2021"
license = "MIT"
authors = ["TerminallyLazy"]
@@ -19,7 +19,7 @@ clap = { version = "4", features = ["derive", "env"] }
libc = "0.2"
once_cell = "1"
regex = "1"
-rusqlite = { version = "0.32", features = ["bundled"] }
+rusqlite = { version = "0.32", features = ["bundled", "functions"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
diff --git a/README.md b/README.md
index f7a9d1d..402aefd 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@ framework-agnostic and does not replace either protocol.
Tree Ring Memory is in protocol-preview status. Current launch links:
- Launch page:
-- Launch release:
+- Launch release:
- Launch discussion:
- Rust-native CLI article:
- Feedback issue:
@@ -38,6 +38,7 @@ Tree Ring Memory is in protocol-preview status. Current launch links:
- v0.10 added a one-line installer plus Rust-native terminal onboarding with animated terminal tree rings.
- v0.11 made the repo fully Rust-native, wired TUI export/consolidation actions, added DOX/Revolve sync adapters, and added agent-framework discovery.
- v0.12 adds a controlled, retained agent-workflow proof with explicit model identity and exact structured-output checks; it reports observed outcomes without claiming a universal memory advantage.
+- v0.13 adds same-host multi-agent identities and idempotency, opt-in coordinator authorization for shared writes, a protected-write audit, and a schema-v3 fence for old memory inserts, updates, and deletes.
@@ -50,6 +51,8 @@ Tree Ring Memory is in protocol-preview status. Current launch links:
adapters, harness discovery, and terminal UI.
- Same-host multi-agent correlation, scoped recall filters, and idempotent
worker writes through the public CLI.
+- Optional coordinator capability enforcement for shared publication and
+ lifecycle mutations while ordinary workers stay agent-partitioned.
- Evidence artifacts for install size, recall speed, harness readiness, and
recall quality.
- Privacy defaults that block secret-like memory, hide sensitive details, and
@@ -167,7 +170,7 @@ sh install.sh --project --init
sh install.sh --global --install-dir "$HOME/.local"
sh install.sh --no-animation # stable output; kept for explicit script usage
sh install.sh --no-path-update
-sh install.sh --archive-url https://example/tree-ring-memory-0.12.0-darwin-arm64.tar.gz --archive-sha256
+sh install.sh --archive-url https://example/tree-ring-memory-0.13.0-darwin-arm64.tar.gz --archive-sha256
```
After install, rerun onboarding anytime:
@@ -221,6 +224,7 @@ tree-ring audit --audit-type sensitive
tree-ring consolidate --period-type manual --dry-run
tree-ring maintain
tree-ring maintain --apply-expired --repair-fts
+tree-ring policy status
tree-ring dox sync --source-root . --dry-run
tree-ring revolve sync --source-root revolve --dry-run
tree-ring integrations scan --source-root .
@@ -236,6 +240,8 @@ Command ownership is Rust-native:
- `dox sync` and `revolve sync` are read-only source adapters that summarize and point back to authoritative files.
- `integrations scan` discovers nearby agent-framework markers and suggests setup paths without changing their config.
- `export`, `import`, `audit`, `consolidate`, and `maintain` are local maintenance surfaces over the same SQLite store.
+- `policy` manages optional coordinated multi-agent write authorization and its
+ protected-write audit trail.
- `welcome` and `tui` are the terminal onboarding and operator-console surfaces.
## Same-Host Multi-Agent Workflow
@@ -271,9 +277,11 @@ tree-ring --root .tree-ring --json recall "release readiness" \
When writing, `scope=agent` requires `agent_profile`, `scope=workflow` requires
`workflow_id`, and `scope=session` requires `session_id`. A coordinator's
aggregate recall may intentionally omit the agent-profile filter. Project and
-global scopes remain shared. These are routing and consolidation partitions,
-not ACLs; any process with filesystem access to the store can perform
-unfiltered recall.
+global scopes remain shared. These fields are always routing and consolidation
+partitions, not read ACLs; any process with filesystem access to the store can
+perform unfiltered recall. The optional Coordinated policy described below adds
+write authorization to official Rust/CLI paths without changing that read
+boundary.
Pre-0.12 private-scope records that lack the now-required identity are migrated
to a deterministic, per-record `legacy-*` partition and marked for review
instead of being widened into shared scope or becoming unexportable.
@@ -297,10 +305,85 @@ evidence-preserving fan-in.
The bounded acceptance test at
`crates/tree-ring-memory-cli/tests/multi_agent_acceptance.rs` holds a real
SQLite write lock, starts eight real CLI workers, verifies they wait and then
-complete, exercises each recall filter and operation conflict behavior, and
-checks exact row/FTS parity through `tree-ring --json maintain`. This is
-same-host evidence, not sustained-load, crash-recovery, fairness, or distributed
-storage certification.
+complete, exercises each recall filter and operation conflict behavior, checks
+exact row/FTS parity through `tree-ring --json maintain`, and then verifies
+concurrent worker denials, coordinator-authorized publication and promotion,
+capability rotation, and protected-write audit records. This is same-host
+evidence, not sustained-load, crash-recovery, fairness, adversarial local-user,
+or distributed-storage certification.
+
+### Coordinated Write Policy
+
+Every store starts in backward-compatible Open mode. For fan-out/fan-in where
+only a designated coordinator should publish shared conclusions or mutate
+existing memory, explicitly enable Coordinated mode:
+
+```bash
+tree-ring --root .tree-ring policy enable --coordinator release-coordinator
+export TREE_RING_COORDINATOR_TOKEN=''
+tree-ring --root .tree-ring policy status
+tree-ring --root .tree-ring policy audit --limit 100
+```
+
+Enable prints the coordinator capability once. Put it only in
+`TREE_RING_COORDINATOR_TOKEN`; never pass it as a CLI flag or retain it in
+memory events, logs, source refs, scripts, or committed files. Tree Ring stores
+only a hash. `policy status` and `policy audit` are read-only and never reveal
+the capability. Inject the variable only into coordinator processes; explicitly
+remove it from every ordinary worker's environment so fan-out children cannot
+inherit coordinator authority.
+
+In Coordinated mode, an ordinary worker may create only non-heartwood
+`scope=agent` memory whose `agent_profile` matches the worker's write context.
+Set that identity with `--agent-profile ` or
+`TREE_RING_AGENT_PROFILE=`. The coordinator capability is required for:
+
+- project, global, workflow, session, or other shared/non-agent writes
+- heartwood creation or promotion
+- JSONL import and persisted DOX/Revolve adapter results
+- persisted consolidation
+- ring changes and supersede/delete/redact lifecycle operations
+- maintenance with apply or repair flags
+
+Recall, export, policy status/audit, adapter dry-runs, consolidation dry-runs,
+and report-only maintenance remain read-only. Protected allow/deny decisions
+are recorded by `policy audit` without recording the plaintext capability.
+
+Rotate the capability while the current one is exported, then immediately
+replace the environment value with the newly printed capability:
+
+```bash
+tree-ring --root .tree-ring policy rotate --coordinator release-coordinator-next
+export TREE_RING_COORDINATOR_TOKEN=''
+tree-ring --root .tree-ring policy disable
+unset TREE_RING_COORDINATOR_TOKEN
+```
+
+Rotation invalidates the old capability. `policy disable` also requires the
+current capability and returns the store to Open mode.
+
+This policy is operational write authorization in official Rust/CLI store
+paths. It is not a read ACL, an operating-system security boundary, or
+protection against an adversary who controls the local database files or
+process environment. The supported shared root remains one host and a local
+filesystem.
+
+### v0.13 Schema-v3 Upgrade
+
+Treat the first v0.13 open of an existing store as a coordinated, one-way
+upgrade:
+
+1. Stop every Tree Ring CLI, plugin, TUI, and bundled worker using the root.
+2. Checkpoint SQLite WAL state and make a verified backup of the store.
+3. Upgrade every CLI, plugin, and bundled worker before any process reopens it.
+4. Open the root with v0.13, which migrates it to schema v3 and installs the
+ writer-protocol fence.
+
+Do not run v0.12 against an upgraded root. Memory inserts, updates, and deletes
+from old v0.12 writers are fenced; all mixed-version operation is unsupported
+even if an older read or maintenance command appears to work.
+Rollback is supported only by stopping all processes and restoring the complete
+pre-upgrade backup.
## Evidence Loop
@@ -359,7 +442,9 @@ outcome-free files as durable truth.
For both adapters, run `--dry-run` first. Imported memory is a concise recall
aid; the source `AGENTS.md`, Revolve record, evaluation artifact, PR, issue,
-test, or run log remains authoritative.
+test, or run log remains authoritative. In Coordinated mode, persisting either
+adapter's result requires `TREE_RING_COORDINATOR_TOKEN`; dry-run discovery does
+not.
Framework discovery is read-only:
@@ -429,6 +514,7 @@ operators working from a terminal:
```bash
tree-ring tui
tree-ring --root .tree-ring tui --event-stream ./tree-ring-events.jsonl --tick-ms 150
+tree-ring --root .tree-ring tui --agent-profile worker-storage
```
From a source checkout without installing:
@@ -461,6 +547,13 @@ Useful keys and commands:
`/consolidate`, `/export `, `/sync`, `/integrations`, `/stream`, and
`/watch`.
+`--agent-profile` (or `TREE_RING_AGENT_PROFILE`) makes TUI `/remember` create
+agent-scoped memory for that profile. Without a profile, `/remember` keeps its
+Open-mode global default and is therefore rejected for an unprivileged
+Coordinated-mode worker. TUI lifecycle operations such as promote, scar, seed,
+supersede, forget/redact, and persisted consolidation require
+`TREE_RING_COORDINATOR_TOKEN` in Coordinated mode.
+
Destructive or authority-changing operations are confirmation-gated. Sensitive
details stay hidden by default, and secret-like memory is blocked before
storage.
@@ -485,6 +578,7 @@ cargo run -p tree-ring-memory-cli -- import --help
cargo run -p tree-ring-memory-cli -- audit --help
cargo run -p tree-ring-memory-cli -- consolidate --help
cargo run -p tree-ring-memory-cli -- maintain --help
+cargo run -p tree-ring-memory-cli -- policy --help
cargo run -p tree-ring-memory-cli -- dox sync --help
cargo run -p tree-ring-memory-cli -- revolve sync --help
cargo run -p tree-ring-memory-cli -- integrations scan --help
diff --git a/crates/tree-ring-memory-cli/Cargo.toml b/crates/tree-ring-memory-cli/Cargo.toml
index 2b5e349..fd7e503 100644
--- a/crates/tree-ring-memory-cli/Cargo.toml
+++ b/crates/tree-ring-memory-cli/Cargo.toml
@@ -21,8 +21,9 @@ clap.workspace = true
ratatui.workspace = true
serde.workspace = true
serde_json.workspace = true
-tree-ring-memory-core = { path = "../tree-ring-memory-core", version = "0.12.0" }
-tree-ring-memory-sqlite = { path = "../tree-ring-memory-sqlite", version = "0.12.0" }
+tree-ring-memory-core = { path = "../tree-ring-memory-core", version = "0.13.0" }
+tree-ring-memory-sqlite = { path = "../tree-ring-memory-sqlite", version = "0.13.0" }
[dev-dependencies]
+rusqlite.workspace = true
tempfile.workspace = true
diff --git a/crates/tree-ring-memory-cli/src/agent_awareness.rs b/crates/tree-ring-memory-cli/src/agent_awareness.rs
index 7a05685..fb53799 100644
--- a/crates/tree-ring-memory-cli/src/agent_awareness.rs
+++ b/crates/tree-ring-memory-cli/src/agent_awareness.rs
@@ -10,10 +10,16 @@ const CLI_HEADER: &str = "# Tree Ring Memory CLI Quick Reference";
const SKILL_FRONT_MATTER_MARKER: &str = "name: tree-ring-memory";
const AGENT_QUALITY_GATES_HEADING: &str = "## Memory Quality Gates";
const AGENT_QUALITY_GATES_ANCHOR: &str = "## DOX Integration";
+const AGENT_POLICY_HEADING: &str = "## Coordinated Write Policy";
+const AGENT_POLICY_ANCHOR: &str = "## Memory Quality Gates";
const CLI_QUALITY_GATES_HEADING: &str = "Memory quality gates:";
const CLI_QUALITY_GATES_ANCHOR: &str = "Safety rules:";
+const CLI_POLICY_HEADING: &str = "Coordinated write policy:";
+const CLI_POLICY_ANCHOR: &str = "Memory quality gates:";
const SKILL_QUALITY_GATES_HEADING: &str = "## Memory Quality Gates";
const SKILL_QUALITY_GATES_ANCHOR: &str = "## Ring Selection";
+const SKILL_POLICY_HEADING: &str = "## Coordinated Write Policy";
+const SKILL_POLICY_ANCHOR: &str = "## Agent-Mediated Updates";
const CLI_REFERENCE: &str = r#"# Tree Ring Memory CLI Quick Reference
Tree Ring Memory is a local-first memory lifecycle layer for AI agents.
@@ -43,6 +49,7 @@ tree-ring maintain
tree-ring dox sync --source-root . --dry-run
tree-ring revolve sync --source-root revolve --dry-run
tree-ring integrations scan --source-root .
+tree-ring policy status
tree-ring tui
```
@@ -68,6 +75,16 @@ Multi-agent coordination:
- At fan-in, recall with the shared workflow/session and an explicit scope. Scope and identity fields partition and route local memory; they are not access-control boundaries.
- A shared SQLite root supports concurrent processes on one host and a local filesystem. Use per-host stores plus an explicit source-preserving fan-in for cross-host or network-filesystem workflows.
+Coordinated write policy:
+
+- Stores remain in backward-compatible Open mode until a coordinator explicitly runs `tree-ring policy enable --coordinator `.
+- Enable and rotate print a one-time capability. Put it only in `TREE_RING_COORDINATOR_TOKEN`; there is no token CLI flag, and Tree Ring never stores the plaintext capability. Inject it only into coordinator processes and keep it unset for ordinary workers.
+- In Coordinated mode, an ordinary worker may create only non-heartwood `scope=agent` memory whose `agent_profile` matches its `--agent-profile` or `TREE_RING_AGENT_PROFILE`.
+- Shared or non-agent writes, heartwood, imports, DOX/Revolve persistence, consolidation, ring changes, supersede/delete/redact, and applied maintenance require the coordinator capability.
+- `tree-ring policy status` and `tree-ring policy audit --limit 100` are read-only. Rotate with `tree-ring policy rotate --coordinator ` and return to Open mode with `tree-ring policy disable`; both require the current capability.
+- This is operational write authorization in official Rust/CLI paths, not a read ACL or protection against an adversary who controls the local files or process environment.
+- Before opening a pre-v0.13 store with a v0.13/schema-v3 binary, stop every Tree Ring process, checkpoint and back up the store, and upgrade every CLI, plugin, and bundled worker. Schema v3 fences memory inserts, updates, and deletes from old v0.12 writers; all mixed-version operation is unsupported. Roll back only by restoring the backup.
+
Memory quality gates:
- Before substantial project work, recall project constraints, scars, user preferences, and unresolved seeds.
@@ -142,6 +159,27 @@ pub fn ensure_agent_awareness(root: &Path) -> Result` to
+`tree-ring --root {root} tui` (or set `TREE_RING_AGENT_PROFILE`); `/remember`
+then creates agent scope. Lifecycle actions such as promote, scar, seed,
+supersede, forget, redact, and persisted consolidation require
+`TREE_RING_COORDINATOR_TOKEN`.
+
+This policy is operational authorization in official Rust/CLI write paths. It
+is not a read ACL, an OS security boundary, or protection against an adversary
+who controls the database files or process environment. The supported shared
+root remains one host and a local filesystem.
+
+Before a v0.13 binary first upgrades a store to schema v3, stop every Tree Ring
+process, checkpoint and back up the database, and upgrade every CLI, plugin,
+and bundled worker before reopening it. Schema v3 rejects memory inserts,
+updates, and deletes from old v0.12 writers; all mixed-version operation is
+unsupported. Roll back only by stopping all processes and restoring the
+pre-upgrade backup.
+
## Memory Quality Gates
Recall gates:
@@ -440,7 +525,12 @@ mod tests {
assert!(agents.contains("Tree Ring Memory Project Contract"));
assert!(agents.contains("Multi-Agent Coordination"));
assert!(agents.contains("--operation-id"));
- assert!(agents.contains("not authorization boundaries"));
+ assert!(agents.contains("Coordinated Write Policy"));
+ assert!(agents.contains("policy enable --coordinator"));
+ assert!(agents.contains("TREE_RING_COORDINATOR_TOKEN"));
+ assert!(agents.contains("ordinary worker may create only non-heartwood"));
+ assert!(agents.contains("schema v3"));
+ assert!(agents.contains("not a read ACL"));
}
#[test]
@@ -470,6 +560,10 @@ mod tests {
assert!(cli.contains("Multi-agent coordination"));
assert!(cli.contains("--workflow-id"));
assert!(cli.contains("not access-control boundaries"));
+ assert!(cli.contains("Coordinated write policy"));
+ assert!(cli.contains("policy enable --coordinator"));
+ assert!(cli.contains("TREE_RING_COORDINATOR_TOKEN"));
+ assert!(cli.contains("memory inserts, updates, and deletes from old v0.12 writers"));
assert!(cli.contains("Before substantial project work, recall project constraints, scars, user preferences, and unresolved seeds."));
assert!(cli
.contains("Before risky changes, recall warnings and evidence-linked prior failures."));
@@ -546,6 +640,45 @@ mod tests {
assert!(skill.contains(skill_section.trim()));
}
+ #[test]
+ fn generated_backfills_coordinated_policy_into_recognized_stale_generated_files() {
+ let dir = tempdir().unwrap();
+ let root = dir.path().join(".tree-ring");
+ fs::create_dir_all(&root).unwrap();
+ let canonical_agents = agent_contract(&root);
+
+ fs::write(
+ root.join("AGENTS.md"),
+ remove_managed_section(&canonical_agents, AGENT_POLICY_HEADING, AGENT_POLICY_ANCHOR)
+ .unwrap(),
+ )
+ .unwrap();
+ fs::write(
+ root.join("CLI.md"),
+ remove_managed_section(CLI_REFERENCE, CLI_POLICY_HEADING, CLI_POLICY_ANCHOR).unwrap(),
+ )
+ .unwrap();
+ fs::write(
+ root.join("SKILL.md"),
+ remove_managed_section(SKILL_TEMPLATE, SKILL_POLICY_HEADING, SKILL_POLICY_ANCHOR)
+ .unwrap(),
+ )
+ .unwrap();
+
+ ensure_agent_awareness(&root).unwrap();
+
+ let agents = fs::read_to_string(root.join("AGENTS.md")).unwrap();
+ let cli = fs::read_to_string(root.join("CLI.md")).unwrap();
+ let skill = fs::read_to_string(root.join("SKILL.md")).unwrap();
+
+ assert_eq!(agents.matches(AGENT_POLICY_HEADING).count(), 1);
+ assert_eq!(cli.matches(CLI_POLICY_HEADING).count(), 1);
+ assert_eq!(skill.matches(SKILL_POLICY_HEADING).count(), 1);
+ assert!(agents.contains("TREE_RING_COORDINATOR_TOKEN"));
+ assert!(cli.contains("ordinary worker may create only non-heartwood"));
+ assert!(skill.contains("policy enable --coordinator"));
+ }
+
#[test]
fn generated_backfill_recognizes_and_preserves_crlf_skill_files() {
let dir = tempdir().unwrap();
@@ -760,6 +893,9 @@ mod tests {
assert_eq!(second_agents.matches("## Memory Quality Gates").count(), 1);
assert_eq!(second_cli.matches("Memory quality gates:").count(), 1);
assert_eq!(second_skill.matches("## Memory Quality Gates").count(), 1);
+ assert_eq!(second_agents.matches(AGENT_POLICY_HEADING).count(), 1);
+ assert_eq!(second_cli.matches(CLI_POLICY_HEADING).count(), 1);
+ assert_eq!(second_skill.matches(SKILL_POLICY_HEADING).count(), 1);
}
#[test]
diff --git a/crates/tree-ring-memory-cli/src/main.rs b/crates/tree-ring-memory-cli/src/main.rs
index 46a4ad3..d347642 100644
--- a/crates/tree-ring-memory-cli/src/main.rs
+++ b/crates/tree-ring-memory-cli/src/main.rs
@@ -1,12 +1,14 @@
use clap::{Parser, Subcommand};
use std::ffi::OsString;
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
use tree_ring_memory_core::sensitivity::SensitivityGuard;
use tree_ring_memory_core::{
AuditReport, ConsolidationReport, DoxSyncReport, MaintenanceReport, RevolveSyncReport,
};
use tree_ring_memory_core::{MemoryEvent, MemoryLink};
-use tree_ring_memory_sqlite::SQLiteMemoryStore;
+use tree_ring_memory_sqlite::{
+ AuthorizationAuditEvent, PolicyGrant, PolicyStatus, SQLiteMemoryStore, WriteContext,
+};
use actions::adapters::{sync_dox, sync_revolve, DoxSyncActionRequest, RevolveSyncActionRequest};
use actions::audit::{audit_store, AuditActionRequest};
@@ -264,10 +266,21 @@ enum Command {
#[arg(long, help = "rebuild the SQLite FTS index")]
repair_fts: bool,
},
+ #[command(about = "manage coordinated multi-agent write authorization")]
+ Policy {
+ #[command(subcommand)]
+ command: PolicyCommand,
+ },
#[command(about = "open the Rust-native Tree Ring Memory terminal console")]
Tui {
#[arg(long, help = "optional JSONL event stream to light rings in real time")]
event_stream: Option,
+ #[arg(
+ long,
+ env = "TREE_RING_AGENT_PROFILE",
+ help = "agent role or worker identity for TUI writes"
+ )]
+ agent_profile: Option,
#[arg(
long,
default_value_t = 250,
@@ -366,6 +379,29 @@ enum IntegrationCommand {
},
}
+#[derive(Debug, Subcommand)]
+enum PolicyCommand {
+ #[command(about = "enable coordinated mode and print a one-time coordinator capability")]
+ Enable {
+ #[arg(long, help = "human-readable coordinator label")]
+ coordinator: Option,
+ },
+ #[command(about = "show the current store policy")]
+ Status,
+ #[command(about = "rotate and print a one-time coordinator capability")]
+ Rotate {
+ #[arg(long, help = "human-readable coordinator label")]
+ coordinator: Option,
+ },
+ #[command(about = "return the store to open mode")]
+ Disable,
+ #[command(about = "show recent protected-write authorization decisions")]
+ Audit {
+ #[arg(long, default_value_t = 100)]
+ limit: usize,
+ },
+}
+
#[derive(Debug, Clone, clap::ValueEnum)]
enum ForgetMode {
Delete,
@@ -473,13 +509,38 @@ fn run(cli: Cli) -> Result<(), String> {
if let Command::Tui {
event_stream,
+ agent_profile,
tick_ms,
} = cli.command
{
if cli.json {
return Err("--json is not supported with the interactive TUI".to_string());
}
- return tui::run(cli.root, event_stream, tick_ms);
+ let context = write_context(agent_profile.clone(), "tui")?;
+ return tui::run(cli.root, event_stream, tick_ms, context, agent_profile);
+ }
+
+ if let Command::Policy { command } = &cli.command {
+ match command {
+ PolicyCommand::Status => {
+ let store = open_policy_read_only(&db_path)?;
+ let status = store.policy_status().map_err(|err| err.to_string())?;
+ print_policy_status(&status, cli.json)?;
+ return Ok(());
+ }
+ PolicyCommand::Audit { limit } => {
+ if !(1..=1000).contains(limit) {
+ return Err("policy audit limit must be between 1 and 1000".to_string());
+ }
+ let store = open_policy_read_only(&db_path)?;
+ let events = store.policy_audit(*limit).map_err(|err| err.to_string())?;
+ print_policy_audit(&events, cli.json)?;
+ return Ok(());
+ }
+ PolicyCommand::Enable { .. }
+ | PolicyCommand::Rotate { .. }
+ | PolicyCommand::Disable => {}
+ }
}
if let Command::Import {
@@ -603,7 +664,12 @@ fn run(cli: Cli) -> Result<(), String> {
return Ok(());
}
- let mut store = SQLiteMemoryStore::open(&db_path).map_err(|err| err.to_string())?;
+ let context = write_context(
+ command_actor_profile(&cli.command),
+ command_origin(&cli.command),
+ )?;
+ let mut store =
+ SQLiteMemoryStore::open_with_context(&db_path, context).map_err(|err| err.to_string())?;
match cli.command {
Command::Init => {
@@ -824,6 +890,30 @@ fn run(cli: Cli) -> Result<(), String> {
)?;
print_maintenance_report(&report, cli.json)?;
}
+ Command::Policy { command } => match command {
+ PolicyCommand::Enable { coordinator } => {
+ let grant = store
+ .enable_coordinated_policy(coordinator.as_deref())
+ .map_err(|err| err.to_string())?;
+ print_policy_grant(&grant, cli.json)?;
+ }
+ PolicyCommand::Status => unreachable!("policy status returns through read-only open"),
+ PolicyCommand::Rotate { coordinator } => {
+ let grant = store
+ .rotate_coordinator_capability(coordinator.as_deref())
+ .map_err(|err| err.to_string())?;
+ print_policy_grant(&grant, cli.json)?;
+ }
+ PolicyCommand::Disable => {
+ let status = store
+ .disable_coordinated_policy()
+ .map_err(|err| err.to_string())?;
+ print_policy_status(&status, cli.json)?;
+ }
+ PolicyCommand::Audit { .. } => {
+ unreachable!("policy audit returns through read-only open")
+ }
+ },
Command::Tui { .. } => unreachable!("tui returns before opening the scriptable store"),
Command::Welcome { .. } => {
unreachable!("welcome returns before opening the scriptable store")
@@ -874,6 +964,73 @@ fn run(cli: Cli) -> Result<(), String> {
Ok(())
}
+const COORDINATOR_TOKEN_ENV: &str = "TREE_RING_COORDINATOR_TOKEN";
+
+fn open_policy_read_only(db_path: &Path) -> Result {
+ if !db_path.is_file() {
+ return Err(format!(
+ "Tree Ring Memory store is not initialized at {}; policy status and audit never create or migrate a store",
+ db_path.display()
+ ));
+ }
+ SQLiteMemoryStore::open_read_only(db_path).map_err(|err| err.to_string())
+}
+
+fn write_context(
+ actor_profile: Option,
+ origin: impl Into,
+) -> Result {
+ let capability = match std::env::var(COORDINATOR_TOKEN_ENV) {
+ Ok(value) if value.trim().is_empty() => {
+ return Err(format!("{COORDINATOR_TOKEN_ENV} cannot be blank"));
+ }
+ Ok(value) => Some(value),
+ Err(std::env::VarError::NotPresent) => None,
+ Err(std::env::VarError::NotUnicode(_)) => {
+ return Err(format!("{COORDINATOR_TOKEN_ENV} must be valid UTF-8"));
+ }
+ };
+ WriteContext::new(actor_profile, capability.as_deref(), origin).map_err(|err| err.to_string())
+}
+
+fn command_actor_profile(command: &Command) -> Option {
+ match command {
+ Command::Remember { agent_profile, .. }
+ | Command::Evidence { agent_profile, .. }
+ | Command::Recall { agent_profile, .. }
+ | Command::Consolidate { agent_profile, .. } => agent_profile.clone(),
+ _ => None,
+ }
+}
+
+fn command_origin(command: &Command) -> &'static str {
+ match command {
+ Command::Init => "cli:init",
+ Command::Remember { .. } => "cli:remember",
+ Command::Evidence { .. } => "cli:evidence",
+ Command::Recall { .. } => "cli:recall",
+ Command::Forget { .. } => "cli:forget",
+ Command::Export { .. } => "cli:export",
+ Command::Import { .. } => "cli:import",
+ Command::Audit { .. } => "cli:audit",
+ Command::Consolidate { .. } => "cli:consolidate",
+ Command::Maintain { .. } => "cli:maintain",
+ Command::Policy { command } => match command {
+ PolicyCommand::Enable { .. } => "cli:policy-enable",
+ PolicyCommand::Status => "cli:policy-status",
+ PolicyCommand::Rotate { .. } => "cli:policy-rotate",
+ PolicyCommand::Disable => "cli:policy-disable",
+ PolicyCommand::Audit { .. } => "cli:policy-audit",
+ },
+ Command::Tui { .. } => "tui",
+ Command::Welcome { .. } => "cli:welcome",
+ Command::RecallQuality { .. } => "cli:recall-quality",
+ Command::Dox { .. } => "cli:dox",
+ Command::Revolve { .. } => "cli:revolve",
+ Command::Integrations { .. } => "cli:integrations",
+ }
+}
+
struct EvidenceEventRequest {
summary: String,
outcome: String,
@@ -1111,6 +1268,75 @@ fn print_maintenance_report(report: &MaintenanceReport, json_output: bool) -> Re
Ok(())
}
+fn print_policy_grant(grant: &PolicyGrant, json_output: bool) -> Result<(), String> {
+ if json_output {
+ println!(
+ "{}",
+ serde_json::to_string(grant).map_err(|err| err.to_string())?
+ );
+ } else {
+ println!(
+ "Tree Ring Memory policy: mode={:?} coordinator={}",
+ grant.status.mode,
+ escape_terminal_field(grant.status.coordinator_label.as_deref().unwrap_or("-"))
+ );
+ println!("{COORDINATOR_TOKEN_ENV}={}", grant.capability);
+ println!("Store this capability securely; Tree Ring will not show it again.");
+ }
+ Ok(())
+}
+
+fn print_policy_status(status: &PolicyStatus, json_output: bool) -> Result<(), String> {
+ if json_output {
+ println!(
+ "{}",
+ serde_json::to_string(status).map_err(|err| err.to_string())?
+ );
+ } else {
+ println!(
+ "Tree Ring Memory policy: mode={:?} coordinator={} enabled_at={} updated_at={}",
+ status.mode,
+ escape_terminal_field(status.coordinator_label.as_deref().unwrap_or("-")),
+ escape_terminal_field(status.enabled_at.as_deref().unwrap_or("-")),
+ escape_terminal_field(&status.updated_at)
+ );
+ }
+ Ok(())
+}
+
+fn print_policy_audit(events: &[AuthorizationAuditEvent], json_output: bool) -> Result<(), String> {
+ if json_output {
+ println!(
+ "{}",
+ serde_json::to_string(events).map_err(|err| err.to_string())?
+ );
+ } else if events.is_empty() {
+ println!("Tree Ring Memory policy audit: no protected-write decisions recorded");
+ } else {
+ for event in events {
+ println!("{}", format_policy_audit_event(event));
+ }
+ }
+ Ok(())
+}
+
+fn format_policy_audit_event(event: &AuthorizationAuditEvent) -> String {
+ format!(
+ "{} {} action={} actor={} origin={} target={} reason={}",
+ escape_terminal_field(&event.created_at),
+ escape_terminal_field(&event.decision),
+ escape_terminal_field(&event.action),
+ escape_terminal_field(event.actor_profile.as_deref().unwrap_or("-")),
+ escape_terminal_field(&event.origin),
+ escape_terminal_field(event.target_memory_id.as_deref().unwrap_or("-")),
+ escape_terminal_field(&event.reason),
+ )
+}
+
+fn escape_terminal_field(value: &str) -> String {
+ value.chars().flat_map(char::escape_default).collect()
+}
+
fn print_dox_report(
report: &DoxSyncReport,
json_output: bool,
@@ -2182,12 +2408,11 @@ mod tests {
command: Command::Init,
})
.unwrap();
- let store = SQLiteMemoryStore::open(&db_path).unwrap();
- store
- .connection()
+ let connection = rusqlite::Connection::open(&db_path).unwrap();
+ connection
.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")
.unwrap();
- drop(store);
+ drop(connection);
let _ = fs::remove_file(&wal_path);
let _ = fs::remove_file(&shm_path);
assert!(db_path.exists());
@@ -2251,13 +2476,11 @@ mod tests {
},
})
.unwrap();
- let store = SQLiteMemoryStore::open(root.join("memory.sqlite")).unwrap();
- let memories: i64 = store
- .connection()
+ let connection = rusqlite::Connection::open(root.join("memory.sqlite")).unwrap();
+ let memories: i64 = connection
.query_row("SELECT count(*) FROM memories", [], |row| row.get(0))
.unwrap();
- let records: i64 = store
- .connection()
+ let records: i64 = connection
.query_row("SELECT count(*) FROM consolidations", [], |row| row.get(0))
.unwrap();
@@ -2342,6 +2565,115 @@ mod tests {
assert!(!root.exists());
}
+ #[test]
+ fn policy_status_and_audit_missing_root_do_not_create_a_store() {
+ let dir = tempdir().unwrap();
+ let root = dir.path().join(".tree-ring");
+
+ let status_error = run(Cli {
+ root: root.clone(),
+ json: true,
+ command: Command::Policy {
+ command: PolicyCommand::Status,
+ },
+ })
+ .unwrap_err();
+ assert!(status_error.contains("not initialized"));
+ assert!(!root.exists());
+
+ let audit_error = run(Cli {
+ root: root.clone(),
+ json: true,
+ command: Command::Policy {
+ command: PolicyCommand::Audit { limit: 10 },
+ },
+ })
+ .unwrap_err();
+ assert!(audit_error.contains("not initialized"));
+ assert!(!root.exists());
+ }
+
+ #[test]
+ fn policy_status_and_audit_do_not_migrate_or_modify_a_v2_store() {
+ let dir = tempdir().unwrap();
+ let root = dir.path().join(".tree-ring");
+ fs::create_dir_all(&root).unwrap();
+ let db_path = root.join("memory.sqlite");
+ let connection = rusqlite::Connection::open(&db_path).unwrap();
+ connection
+ .execute_batch(
+ "CREATE TABLE sentinel (value TEXT NOT NULL);
+ INSERT INTO sentinel (value) VALUES ('unchanged');
+ PRAGMA user_version=2;",
+ )
+ .unwrap();
+ drop(connection);
+ let original_bytes = fs::read(&db_path).unwrap();
+
+ let status_error = run(Cli {
+ root: root.clone(),
+ json: true,
+ command: Command::Policy {
+ command: PolicyCommand::Status,
+ },
+ })
+ .unwrap_err();
+ assert!(status_error.contains("requires SQLite schema version 3"));
+ assert!(status_error.contains("found version 2"));
+ assert_eq!(fs::read(&db_path).unwrap(), original_bytes);
+
+ let audit_error = run(Cli {
+ root: root.clone(),
+ json: true,
+ command: Command::Policy {
+ command: PolicyCommand::Audit { limit: 10 },
+ },
+ })
+ .unwrap_err();
+ assert!(audit_error.contains("requires SQLite schema version 3"));
+ assert!(audit_error.contains("found version 2"));
+ assert_eq!(fs::read(&db_path).unwrap(), original_bytes);
+
+ let verification = rusqlite::Connection::open_with_flags(
+ &db_path,
+ rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
+ )
+ .unwrap();
+ let version: i64 = verification
+ .query_row("PRAGMA user_version", [], |row| row.get(0))
+ .unwrap();
+ let sentinel: String = verification
+ .query_row("SELECT value FROM sentinel", [], |row| row.get(0))
+ .unwrap();
+ assert_eq!(version, 2);
+ assert_eq!(sentinel, "unchanged");
+ assert!(!db_path.with_extension("sqlite-wal").exists());
+ assert!(!db_path.with_extension("sqlite-shm").exists());
+ }
+
+ #[test]
+ fn policy_audit_human_output_escapes_terminal_controls() {
+ let event = AuthorizationAuditEvent {
+ id: 1,
+ created_at: "2026-07-23\nspoofed".to_string(),
+ action: "delete\u{1b}[31m".to_string(),
+ decision: "denied".to_string(),
+ reason: "missing\ncapability".to_string(),
+ actor_profile: Some("worker\u{202e}spoof".to_string()),
+ origin: "cli:forget".to_string(),
+ target_memory_id: Some("bad\nid\u{1b}[31m".to_string()),
+ };
+
+ let rendered = format_policy_audit_event(&event);
+
+ assert!(!rendered.contains('\n'));
+ assert!(!rendered.contains('\u{1b}'));
+ assert!(!rendered.contains('\u{202e}'));
+ assert!(rendered.contains("\\n"));
+ assert!(rendered.contains("\\u{1b}"));
+ assert!(rendered.contains("\\u{202e}"));
+ }
+
#[test]
fn maintain_apply_expired_deletes_temporary_memory() {
let dir = tempdir().unwrap();
@@ -2380,6 +2712,8 @@ mod tests {
"tui",
"--event-stream",
"events.jsonl",
+ "--agent-profile",
+ "reviewer",
"--tick-ms",
"125",
])
@@ -2388,9 +2722,11 @@ mod tests {
match cli.command {
Command::Tui {
event_stream,
+ agent_profile,
tick_ms,
} => {
assert_eq!(event_stream.unwrap(), PathBuf::from("events.jsonl"));
+ assert_eq!(agent_profile.as_deref(), Some("reviewer"));
assert_eq!(tick_ms, 125);
}
_ => panic!("expected tui command"),
@@ -2460,6 +2796,7 @@ mod tests {
json: true,
command: Command::Tui {
event_stream: None,
+ agent_profile: None,
tick_ms: 250,
},
})
diff --git a/crates/tree-ring-memory-cli/src/tui/app.rs b/crates/tree-ring-memory-cli/src/tui/app.rs
index d8eabb1..a7a4d8c 100644
--- a/crates/tree-ring-memory-cli/src/tui/app.rs
+++ b/crates/tree-ring-memory-cli/src/tui/app.rs
@@ -2,7 +2,7 @@ use std::path::{Component, Path, PathBuf};
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use tree_ring_memory_core::{ConsolidationRequest, MemoryEvent};
-use tree_ring_memory_sqlite::{MemoryRetriever, RecallResult, SQLiteMemoryStore};
+use tree_ring_memory_sqlite::{MemoryRetriever, RecallResult, SQLiteMemoryStore, WriteContext};
use crate::actions::export_import::{export_jsonl, ExportActionRequest};
use crate::actions::integrations::{scan as scan_integrations_action, IntegrationScanRequest};
@@ -30,6 +30,7 @@ pub enum AppMode {
pub struct App {
root: PathBuf,
+ agent_profile: Option,
pub store: SQLiteMemoryStore,
watcher: StoreWatcher,
pub dashboard: DashboardStats,
@@ -53,11 +54,35 @@ pub struct App {
}
impl App {
+ #[cfg(test)]
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)
+ }
+
+ pub fn new_with_context(
+ root: PathBuf,
+ event_stream_path: Option,
+ context: WriteContext,
+ agent_profile: Option,
+ ) -> Result {
+ 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)
+ }
+
+ fn from_store(
+ root: PathBuf,
+ event_stream_path: Option,
+ store: SQLiteMemoryStore,
+ agent_profile: Option,
+ ) -> Result {
+ let db_path = root.join("memory.sqlite");
let mut app = Self {
root,
+ agent_profile,
store,
watcher: StoreWatcher::new(),
dashboard: DashboardStats::empty(),
@@ -138,7 +163,12 @@ impl App {
fn handle_pending_key(&mut self, key: KeyEvent) -> Result<(), String> {
match key.code {
- KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_pending_action(),
+ KeyCode::Char('y') | KeyCode::Char('Y') => {
+ if let Err(error) = self.confirm_pending_action() {
+ self.status = format!("action failed: {error}");
+ }
+ Ok(())
+ }
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
self.pending_action = None;
self.status = "action cancelled".to_string();
@@ -158,7 +188,9 @@ impl App {
let command = self.command_buffer.clone();
self.command_buffer.clear();
self.mode = AppMode::Default;
- self.execute_slash_command(&command)?;
+ if let Err(error) = self.execute_slash_command(&command) {
+ self.status = format!("command failed: {error}");
+ }
}
KeyCode::Backspace => {
self.command_buffer.pop();
@@ -286,15 +318,19 @@ impl App {
self.status = "remember requires a summary".to_string();
return Ok(());
}
+ let (scope, agent_profile) = match &self.agent_profile {
+ Some(agent_profile) => ("agent".to_string(), Some(agent_profile.clone())),
+ None => ("global".to_string(), None),
+ };
let report = remember(
&mut self.store,
RememberRequest {
summary: summary.trim().to_string(),
event_type: "lesson".to_string(),
ring: "cambium".to_string(),
- scope: "global".to_string(),
+ scope,
project: None,
- agent_profile: None,
+ agent_profile,
workflow_id: None,
session_id: None,
operation_id: None,
@@ -613,6 +649,23 @@ mod tests {
assert_eq!(memories[0].scope, "global");
}
+ #[test]
+ fn agent_context_makes_tui_remember_worker_private() {
+ let dir = tempdir().unwrap();
+ let root = dir.path().join(".tree-ring");
+ let context = WriteContext::new(Some("reviewer".to_string()), None, "tui-test").unwrap();
+ let mut app =
+ App::new_with_context(root, None, context, Some("reviewer".to_string())).unwrap();
+
+ app.execute_slash_command("/remember Keep this worker-local")
+ .unwrap();
+
+ let memories = app.store.list_all(false).unwrap();
+ assert_eq!(memories.len(), 1);
+ assert_eq!(memories[0].scope, "agent");
+ assert_eq!(memories[0].agent_profile.as_deref(), Some("reviewer"));
+ }
+
#[test]
fn secret_like_remember_is_blocked() {
let dir = tempdir().unwrap();
@@ -669,6 +722,62 @@ mod tests {
assert_eq!(app.status, "memory not found: mem_missing");
}
+ #[test]
+ fn coordinated_worker_denial_is_visible_without_exiting_the_tui() {
+ let dir = tempdir().unwrap();
+ let root = dir.path().join(".tree-ring");
+ let db_path = root.join("memory.sqlite");
+ let mut setup = SQLiteMemoryStore::open(&db_path).unwrap();
+ setup
+ .enable_coordinated_policy(Some("test-coordinator"))
+ .unwrap();
+ drop(setup);
+
+ let context = WriteContext::new(Some("worker-a".to_string()), None, "tui-test").unwrap();
+ let mut app =
+ App::new_with_context(root, None, context, Some("worker-a".to_string())).unwrap();
+ app.execute_slash_command("/remember Keep this worker-local")
+ .unwrap();
+ app.execute_slash_command("/promote").unwrap();
+
+ app.handle_key(KeyEvent::new(
+ KeyCode::Char('y'),
+ ratatui::crossterm::event::KeyModifiers::NONE,
+ ))
+ .unwrap();
+
+ assert!(app.status.contains("action failed: authorization denied"));
+ assert!(app.pending_action.is_none());
+ assert_eq!(app.store.list_all(false).unwrap()[0].ring, "cambium");
+ }
+
+ #[test]
+ fn coordinated_unprofiled_remember_failure_is_visible_without_exiting_the_tui() {
+ let dir = tempdir().unwrap();
+ let root = dir.path().join(".tree-ring");
+ let db_path = root.join("memory.sqlite");
+ let mut setup = SQLiteMemoryStore::open(&db_path).unwrap();
+ setup
+ .enable_coordinated_policy(Some("test-coordinator"))
+ .unwrap();
+ drop(setup);
+
+ let context = WriteContext::new(None, None, "tui-test").unwrap();
+ let mut app = App::new_with_context(root, None, context, None).unwrap();
+ app.mode = AppMode::Command;
+ app.command_buffer = "/remember Attempt a shared default".to_string();
+
+ app.handle_key(KeyEvent::new(
+ KeyCode::Enter,
+ ratatui::crossterm::event::KeyModifiers::NONE,
+ ))
+ .unwrap();
+
+ assert!(app.status.contains("command failed: authorization denied"));
+ assert_eq!(app.mode, AppMode::Default);
+ assert_eq!(app.dashboard.total, 0);
+ }
+
#[test]
fn slash_rings_switches_exploded_mode() {
let dir = tempdir().unwrap();
diff --git a/crates/tree-ring-memory-cli/src/tui/mod.rs b/crates/tree-ring-memory-cli/src/tui/mod.rs
index b0263fc..de7e8d4 100644
--- a/crates/tree-ring-memory-cli/src/tui/mod.rs
+++ b/crates/tree-ring-memory-cli/src/tui/mod.rs
@@ -15,12 +15,19 @@ use std::time::Duration;
use ratatui::backend::Backend;
use ratatui::crossterm::event as terminal_event;
use ratatui::Terminal;
+use tree_ring_memory_sqlite::WriteContext;
use app::{App, AppMode};
-pub fn run(root: PathBuf, event_stream: Option, tick_ms: u64) -> Result<(), String> {
+pub fn run(
+ root: PathBuf,
+ event_stream: Option,
+ tick_ms: u64,
+ context: WriteContext,
+ agent_profile: Option,
+) -> Result<(), String> {
let tick_rate = Duration::from_millis(tick_ms.clamp(50, 5_000));
- let mut app = App::new(root, event_stream)?;
+ let mut app = App::new_with_context(root, event_stream, context, agent_profile)?;
let mut terminal = ratatui::init();
let result = run_loop(&mut terminal, &mut app, tick_rate);
ratatui::restore();
diff --git a/crates/tree-ring-memory-cli/tests/multi_agent_acceptance.rs b/crates/tree-ring-memory-cli/tests/multi_agent_acceptance.rs
index b3db84d..0604f10 100644
--- a/crates/tree-ring-memory-cli/tests/multi_agent_acceptance.rs
+++ b/crates/tree-ring-memory-cli/tests/multi_agent_acceptance.rs
@@ -8,13 +8,13 @@ use std::{
use serde_json::Value;
use tempfile::tempdir;
use tree_ring_memory_core::MemoryEvent;
-use tree_ring_memory_sqlite::SQLiteMemoryStore;
const WORKER_COUNT: usize = 8;
const PROJECT: &str = "multi-agent-acceptance";
const WORKFLOW: &str = "fanout-acceptance";
const SESSION: &str = "attempt-1";
const QUERY_TOKEN: &str = "swarmtoken";
+const COORDINATOR_TOKEN_ENV: &str = "TREE_RING_COORDINATOR_TOKEN";
#[derive(Debug, Clone)]
struct WriteSpec {
@@ -50,11 +50,8 @@ fn real_cli_processes_preserve_multi_agent_isolation_and_idempotency() {
assert_eq!(init_json["ok"], true);
let worker_specs = (0..WORKER_COUNT).map(WriteSpec::worker).collect::>();
- let lock_holder = SQLiteMemoryStore::open(root.join("memory.sqlite")).unwrap();
- lock_holder
- .connection()
- .execute_batch("BEGIN IMMEDIATE")
- .unwrap();
+ let lock_holder = rusqlite::Connection::open(root.join("memory.sqlite")).unwrap();
+ lock_holder.execute_batch("BEGIN IMMEDIATE").unwrap();
let mut workers = worker_specs
.iter()
.map(|spec| {
@@ -68,7 +65,7 @@ fn real_cli_processes_preserve_multi_agent_isolation_and_idempotency() {
.iter_mut()
.map(|(spec, child)| (spec.agent_profile.clone(), child.try_wait()))
.collect::>();
- lock_holder.connection().execute_batch("COMMIT").unwrap();
+ lock_holder.execute_batch("COMMIT").unwrap();
drop(lock_holder);
for (agent_profile, status) in waiting_statuses {
assert!(
@@ -207,12 +204,260 @@ fn real_cli_processes_preserve_multi_agent_isolation_and_idempotency() {
assert_eq!(report["fts"]["orphan_fts_rows"], 0);
}
+#[test]
+fn coordinated_policy_guards_shared_mutations_across_real_cli_processes() {
+ let temp = tempdir().unwrap();
+ let root = temp.path().join(".tree-ring");
+
+ let init = base_command(&root).arg("init").output().unwrap();
+ assert_success("init", &init);
+
+ let enable = base_command(&root)
+ .arg("policy")
+ .arg("enable")
+ .arg("--coordinator")
+ .arg("acceptance-owner")
+ .output()
+ .unwrap();
+ assert_success("policy enable", &enable);
+ let grant = parse_json("policy enable", &enable);
+ let coordinator_token = grant["capability"]
+ .as_str()
+ .expect("policy enable must return its one-time capability")
+ .to_string();
+ assert!(!coordinator_token.trim().is_empty());
+ assert!(!String::from_utf8_lossy(&enable.stderr).contains(&coordinator_token));
+
+ let status = base_command(&root)
+ .arg("policy")
+ .arg("status")
+ .output()
+ .unwrap();
+ assert_success("policy status", &status);
+ assert_eq!(
+ parse_json("policy status", &status)["mode"]
+ .as_str()
+ .map(str::to_ascii_lowercase)
+ .as_deref(),
+ Some("coordinated")
+ );
+
+ let worker = WriteSpec::worker(100);
+ let worker_write = remember_command(&root, &worker).output().unwrap();
+ assert_success("authorized worker-private write", &worker_write);
+ let worker_memory = parse_memory("authorized worker-private write", &worker_write);
+ assert_eq!(worker_memory.scope, "agent");
+ assert_eq!(
+ worker_memory.agent_profile.as_deref(),
+ Some(worker.agent_profile.as_str())
+ );
+
+ let denied_specs = (0..WORKER_COUNT)
+ .map(|index| WriteSpec {
+ summary: format!("{QUERY_TOKEN} unauthorized shared write {index}."),
+ scope: "project".to_string(),
+ agent_profile: format!("worker-{index}"),
+ workflow_id: WORKFLOW.to_string(),
+ session_id: SESSION.to_string(),
+ operation_id: format!("operation-denied-{index}"),
+ })
+ .collect::>();
+ let denied_children = denied_specs
+ .iter()
+ .map(|spec| (spec, spawn_remember(&root, spec)))
+ .collect::>();
+ for (spec, child) in denied_children {
+ let output = wait_with_output_bounded(child, &spec.operation_id);
+ assert_authorization_denied(&spec.operation_id, &output);
+ }
+
+ let shared_before_coordinator = recall(&root, &[("--scope", "project")]);
+ assert!(
+ shared_before_coordinator.is_empty(),
+ "unauthorized concurrent writes reached shared project memory"
+ );
+
+ let denied_promotion = base_command(&root)
+ .arg("evidence")
+ .arg(format!("{QUERY_TOKEN} worker attempted promotion."))
+ .arg("--outcome")
+ .arg("promoted")
+ .arg("--evidence-ref")
+ .arg("runs/fanout-acceptance/worker-promotion.json")
+ .arg("--project")
+ .arg(PROJECT)
+ .arg("--agent-profile")
+ .arg("worker-3")
+ .arg("--workflow-id")
+ .arg(WORKFLOW)
+ .arg("--session-id")
+ .arg(SESSION)
+ .arg("--operation-id")
+ .arg("operation-denied-promotion")
+ .output()
+ .unwrap();
+ assert_authorization_denied("worker heartwood promotion", &denied_promotion);
+
+ let mut approved_promotion = base_command(&root);
+ approved_promotion
+ .env(COORDINATOR_TOKEN_ENV, &coordinator_token)
+ .arg("evidence")
+ .arg(format!("{QUERY_TOKEN} coordinator approved promotion."))
+ .arg("--outcome")
+ .arg("promoted")
+ .arg("--evidence-ref")
+ .arg("runs/fanout-acceptance/coordinator-promotion.json")
+ .arg("--project")
+ .arg(PROJECT)
+ .arg("--agent-profile")
+ .arg("coordinator")
+ .arg("--workflow-id")
+ .arg(WORKFLOW)
+ .arg("--session-id")
+ .arg(SESSION)
+ .arg("--operation-id")
+ .arg("operation-approved-promotion");
+ let approved_promotion_output = approved_promotion.output().unwrap();
+ assert_success(
+ "coordinator heartwood promotion",
+ &approved_promotion_output,
+ );
+ assert_eq!(
+ parse_memory(
+ "coordinator heartwood promotion",
+ &approved_promotion_output
+ )
+ .ring,
+ "heartwood"
+ );
+
+ let coordinator_spec = WriteSpec {
+ summary: format!("{QUERY_TOKEN} coordinator-approved shared result."),
+ scope: "project".to_string(),
+ agent_profile: "coordinator".to_string(),
+ workflow_id: WORKFLOW.to_string(),
+ session_id: SESSION.to_string(),
+ operation_id: "operation-coordinator-shared".to_string(),
+ };
+ let mut coordinator_write = remember_command(&root, &coordinator_spec);
+ coordinator_write.env(COORDINATOR_TOKEN_ENV, &coordinator_token);
+ let coordinator_output = coordinator_write.output().unwrap();
+ assert_success("coordinator shared write", &coordinator_output);
+ let coordinator_memory = parse_memory("coordinator shared write", &coordinator_output);
+ assert_eq!(coordinator_memory.scope, "project");
+
+ let wrong_token_spec = WriteSpec {
+ summary: format!("{QUERY_TOKEN} wrong-token shared result."),
+ scope: "project".to_string(),
+ agent_profile: "coordinator".to_string(),
+ workflow_id: WORKFLOW.to_string(),
+ session_id: SESSION.to_string(),
+ operation_id: "operation-wrong-token".to_string(),
+ };
+ let mut wrong_token_write = remember_command(&root, &wrong_token_spec);
+ wrong_token_write.env(COORDINATOR_TOKEN_ENV, "not-the-capability");
+ let wrong_token_output = wrong_token_write.output().unwrap();
+ assert_authorization_denied("wrong coordinator token", &wrong_token_output);
+
+ let mut rotate = base_command(&root);
+ rotate
+ .env(COORDINATOR_TOKEN_ENV, &coordinator_token)
+ .arg("policy")
+ .arg("rotate")
+ .arg("--coordinator")
+ .arg("rotated-owner");
+ let rotate_output = rotate.output().unwrap();
+ assert_success("policy rotate", &rotate_output);
+ let rotated_grant = parse_json("policy rotate", &rotate_output);
+ let rotated_token = rotated_grant["capability"]
+ .as_str()
+ .expect("policy rotate must return its one-time capability")
+ .to_string();
+ assert_ne!(rotated_token, coordinator_token);
+
+ let stale_token_spec = WriteSpec {
+ summary: format!("{QUERY_TOKEN} stale-token shared result."),
+ scope: "project".to_string(),
+ agent_profile: "coordinator".to_string(),
+ workflow_id: WORKFLOW.to_string(),
+ session_id: SESSION.to_string(),
+ operation_id: "operation-stale-token".to_string(),
+ };
+ let mut stale_token_write = remember_command(&root, &stale_token_spec);
+ stale_token_write.env(COORDINATOR_TOKEN_ENV, &coordinator_token);
+ let stale_token_output = stale_token_write.output().unwrap();
+ assert_authorization_denied("rotated coordinator token", &stale_token_output);
+
+ let audit = base_command(&root)
+ .arg("policy")
+ .arg("audit")
+ .arg("--limit")
+ .arg("100")
+ .output()
+ .unwrap();
+ assert_success("policy audit", &audit);
+ let audit_json = parse_json("policy audit", &audit);
+ let audit_events = audit_json
+ .as_array()
+ .expect("policy audit JSON must be an array");
+ let denied_events = audit_events
+ .iter()
+ .filter(|event| event["decision"].as_str() == Some("denied"))
+ .collect::>();
+ let allowed_events = audit_events
+ .iter()
+ .filter(|event| event["decision"].as_str() == Some("allowed"))
+ .collect::>();
+ assert_eq!(
+ denied_events.len(),
+ WORKER_COUNT + 3,
+ "every concurrent/shared/promotion denial must be audited exactly once"
+ );
+ assert_eq!(
+ allowed_events.len(),
+ 4,
+ "enable, two coordinator writes, and rotation must be audited exactly once"
+ );
+ for index in 0..WORKER_COUNT {
+ let actor = format!("worker-{index}");
+ assert!(
+ denied_events
+ .iter()
+ .any(|event| event["actor_profile"].as_str() == Some(actor.as_str())),
+ "missing denial audit for {actor}"
+ );
+ }
+ let serialized_audit = serde_json::to_string(audit_events).unwrap();
+ assert!(!serialized_audit.contains(&coordinator_token));
+ assert!(!serialized_audit.contains(&rotated_token));
+
+ let mut disable = base_command(&root);
+ disable
+ .env(COORDINATOR_TOKEN_ENV, &rotated_token)
+ .arg("policy")
+ .arg("disable");
+ let disable_output = disable.output().unwrap();
+ assert_success("policy disable", &disable_output);
+
+ let open_mode_spec = WriteSpec {
+ summary: format!("{QUERY_TOKEN} open-mode shared result."),
+ scope: "project".to_string(),
+ agent_profile: "worker-after-disable".to_string(),
+ workflow_id: WORKFLOW.to_string(),
+ session_id: SESSION.to_string(),
+ operation_id: "operation-open-after-disable".to_string(),
+ };
+ let open_mode_output = remember_command(&root, &open_mode_spec).output().unwrap();
+ assert_success("open mode restored", &open_mode_output);
+}
+
fn base_command(root: &Path) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_tree-ring"));
command
.env_remove("TREE_RING_AGENT_PROFILE")
.env_remove("TREE_RING_WORKFLOW_ID")
.env_remove("TREE_RING_SESSION_ID")
+ .env_remove(COORDINATOR_TOKEN_ENV)
.arg("--root")
.arg(root)
.arg("--json");
@@ -326,3 +571,16 @@ fn assert_success(context: &str, output: &Output) {
String::from_utf8_lossy(&output.stderr)
);
}
+
+fn assert_authorization_denied(context: &str, output: &Output) {
+ assert!(
+ !output.status.success(),
+ "{context} unexpectedly succeeded; stdout={}",
+ String::from_utf8_lossy(&output.stdout)
+ );
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ stderr.to_ascii_lowercase().contains("authoriz"),
+ "{context} returned a non-authorization error: {stderr}"
+ );
+}
diff --git a/crates/tree-ring-memory-core/src/models.rs b/crates/tree-ring-memory-core/src/models.rs
index b686240..102f899 100644
--- a/crates/tree-ring-memory-core/src/models.rs
+++ b/crates/tree-ring-memory-core/src/models.rs
@@ -33,6 +33,8 @@ pub enum TreeRingError {
Validation(String),
#[error("secret-like memory is blocked by policy")]
SensitiveMemoryBlocked,
+ #[error("authorization denied: {0}")]
+ AuthorizationDenied(String),
#[error("storage locked: {0}")]
StorageLocked(String),
#[error("storage error: {0}")]
@@ -248,9 +250,7 @@ fn validate_member(field: &str, value: &str, allowed: &[&str]) -> TreeRingResult
if allowed.contains(&value) {
return Ok(());
}
- Err(TreeRingError::Validation(format!(
- "invalid {field}: {value}"
- )))
+ Err(TreeRingError::Validation(format!("invalid {field}")))
}
fn validate_optional_context_metadata(field: &str, value: Option<&str>) -> TreeRingResult<()> {
diff --git a/crates/tree-ring-memory-core/src/sensitivity.rs b/crates/tree-ring-memory-core/src/sensitivity.rs
index 76efa79..a4664ed 100644
--- a/crates/tree-ring-memory-core/src/sensitivity.rs
+++ b/crates/tree-ring-memory-core/src/sensitivity.rs
@@ -153,6 +153,10 @@ impl SensitivityGuard {
static SECRET_PATTERNS: Lazy> = Lazy::new(|| {
vec![
("openai_key", Regex::new(r"\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b").unwrap()),
+ (
+ "tree_ring_coordinator_capability",
+ Regex::new(r"trcap_v1_[A-Fa-f0-9]{64}").unwrap(),
+ ),
("github_token", Regex::new(r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b").unwrap()),
("github_fine_grained_token", Regex::new(r"\bgithub_pat_[A-Za-z0-9_]{20,}\b").unwrap()),
("aws_access_key", Regex::new(r"\bAKIA[0-9A-Z]{16}\b").unwrap()),
@@ -220,6 +224,23 @@ mod tests {
assert!(err.contains("blocked"));
}
+ #[test]
+ fn blocks_tree_ring_coordinator_capabilities_by_default() {
+ let guard = SensitivityGuard::default();
+ let capability = format!("trcap_v1_{}", "a".repeat(64));
+
+ for candidate in [
+ capability.clone(),
+ format!("worker_{capability}_suffix"),
+ format!("prefix{capability}suffix"),
+ ] {
+ let err = guard.check_or_raise(&candidate).unwrap_err().to_string();
+
+ assert!(err.contains("blocked"));
+ assert!(!err.contains(&capability));
+ }
+ }
+
#[test]
fn redacts_password_assignment() {
let guard = SensitivityGuard::new(false);
diff --git a/crates/tree-ring-memory-sqlite/Cargo.toml b/crates/tree-ring-memory-sqlite/Cargo.toml
index d3c0faf..0abbeaf 100644
--- a/crates/tree-ring-memory-sqlite/Cargo.toml
+++ b/crates/tree-ring-memory-sqlite/Cargo.toml
@@ -13,9 +13,11 @@ categories = ["database-implementations", "development-tools"]
[dependencies]
rusqlite.workspace = true
+serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
-tree-ring-memory-core = { path = "../tree-ring-memory-core", version = "0.12.0" }
+tree-ring-memory-core = { path = "../tree-ring-memory-core", version = "0.13.0" }
+uuid.workspace = true
[dev-dependencies]
tempfile.workspace = true
diff --git a/crates/tree-ring-memory-sqlite/src/lib.rs b/crates/tree-ring-memory-sqlite/src/lib.rs
index 116341c..6aaefe1 100644
--- a/crates/tree-ring-memory-sqlite/src/lib.rs
+++ b/crates/tree-ring-memory-sqlite/src/lib.rs
@@ -11,15 +11,18 @@ use tree_ring_memory_core::{
audit_memories, consolidate_memories, decode_jsonl, encode_jsonl, normalize_import_events,
normalize_legacy_private_scope_identity, plan_maintenance, AuditReport, ConsolidationReport,
ConsolidationRequest, MaintenanceActionType, MaintenanceFtsReport, MaintenanceReport,
- MaintenanceRequest,
+ MaintenanceRequest, SensitivityGuard,
};
mod lifecycle;
+mod policy;
mod schema;
mod search;
mod write;
-const SQLITE_SCHEMA_VERSION: i64 = 2;
+pub use policy::{AuthorizationAuditEvent, PolicyGrant, PolicyMode, PolicyStatus, WriteContext};
+
+const SQLITE_SCHEMA_VERSION: i64 = 3;
#[derive(Debug, Clone)]
pub struct RecallResult {
@@ -63,6 +66,7 @@ impl Default for RecallOptions<'_> {
pub struct SQLiteMemoryStore {
connection: Connection,
+ write_context: WriteContext,
}
#[derive(Debug, Clone, PartialEq)]
@@ -97,23 +101,37 @@ pub struct ImportReport {
impl SQLiteMemoryStore {
pub fn open(path: impl AsRef) -> TreeRingResult {
+ Self::open_with_context(path, WriteContext::anonymous())
+ }
+
+ pub fn open_with_context(
+ path: impl AsRef,
+ context: WriteContext,
+ ) -> TreeRingResult {
let connection = schema::open_connection(path.as_ref())?;
- let store = Self { connection };
+ let store = Self {
+ connection,
+ write_context: context,
+ };
store.migrate()?;
Ok(store)
}
pub fn open_read_only(path: impl AsRef) -> TreeRingResult {
let connection = schema::open_read_only_connection(path.as_ref())?;
- Ok(Self { connection })
+ Ok(Self {
+ connection,
+ write_context: WriteContext::anonymous(),
+ })
}
- pub fn connection(&self) -> &Connection {
+ #[cfg(test)]
+ pub fn connection_for_testing(&self) -> &Connection {
&self.connection
}
pub fn migrate(&self) -> TreeRingResult<()> {
- if schema::user_version(&self.connection)? >= SQLITE_SCHEMA_VERSION {
+ if schema::ensure_supported_schema_version(&self.connection)? == SQLITE_SCHEMA_VERSION {
return Ok(());
}
write::retry_locked(|| {
@@ -122,7 +140,7 @@ impl SQLiteMemoryStore {
TransactionBehavior::Immediate,
)
.map_err(sqlite_error_from_rusqlite)?;
- if schema::user_version(&transaction)? >= SQLITE_SCHEMA_VERSION {
+ if schema::ensure_supported_schema_version(&transaction)? == SQLITE_SCHEMA_VERSION {
transaction.commit().map_err(sqlite_error_from_rusqlite)?;
return Ok(());
}
@@ -184,6 +202,7 @@ impl SQLiteMemoryStore {
"#,
)
.map_err(sqlite_error_from_rusqlite)?;
+ policy::create_policy_schema(&transaction)?;
for (column, definition) in [
("workflow_id", "TEXT"),
("session_id", "TEXT"),
@@ -199,9 +218,8 @@ impl SQLiteMemoryStore {
}
}
normalize_legacy_private_scope_rows(&transaction)?;
- transaction
- .execute_batch(
- r#"
+ let migration_sql = format!(
+ r#"
CREATE INDEX IF NOT EXISTS idx_memories_project
ON memories(project);
CREATE INDEX IF NOT EXISTS idx_memories_agent_profile
@@ -224,8 +242,38 @@ impl SQLiteMemoryStore {
SELECT id
FROM memories
WHERE summary = '[REDACTED]';
+ CREATE TRIGGER IF NOT EXISTS memories_writer_protocol_insert
+ BEFORE INSERT ON memories
+ WHEN tree_ring_writer_protocol() != {writer_protocol_version}
+ BEGIN
+ SELECT RAISE(
+ ABORT,
+ 'Tree Ring writer protocol {writer_protocol_version} required'
+ );
+ END;
+ CREATE TRIGGER IF NOT EXISTS memories_writer_protocol_update
+ BEFORE UPDATE ON memories
+ WHEN tree_ring_writer_protocol() != {writer_protocol_version}
+ BEGIN
+ SELECT RAISE(
+ ABORT,
+ 'Tree Ring writer protocol {writer_protocol_version} required'
+ );
+ END;
+ CREATE TRIGGER IF NOT EXISTS memories_writer_protocol_delete
+ BEFORE DELETE ON memories
+ WHEN tree_ring_writer_protocol() != {writer_protocol_version}
+ BEGIN
+ SELECT RAISE(
+ ABORT,
+ 'Tree Ring writer protocol {writer_protocol_version} required'
+ );
+ END;
"#,
- )
+ writer_protocol_version = schema::WRITER_PROTOCOL_VERSION,
+ );
+ transaction
+ .execute_batch(&migration_sql)
.map_err(sqlite_error_from_rusqlite)?;
transaction
.pragma_update(None, "user_version", SQLITE_SCHEMA_VERSION)
@@ -235,12 +283,110 @@ impl SQLiteMemoryStore {
})
}
+ pub fn enable_coordinated_policy(
+ &mut self,
+ label: Option<&str>,
+ ) -> TreeRingResult {
+ let write_context = self.write_context.clone();
+ write::retry_locked(|| {
+ let transaction = self
+ .connection
+ .transaction_with_behavior(TransactionBehavior::Immediate)
+ .map_err(sqlite_error_from_rusqlite)?;
+ match policy::enable_policy(&transaction, &write_context, label)? {
+ Ok(grant) => {
+ transaction.commit().map_err(sqlite_error_from_rusqlite)?;
+ Ok(grant)
+ }
+ Err(error) => {
+ transaction.commit().map_err(sqlite_error_from_rusqlite)?;
+ Err(error)
+ }
+ }
+ })
+ }
+
+ pub fn rotate_coordinator_capability(
+ &mut self,
+ label: Option<&str>,
+ ) -> TreeRingResult {
+ let write_context = self.write_context.clone();
+ write::retry_locked(|| {
+ let transaction = self
+ .connection
+ .transaction_with_behavior(TransactionBehavior::Immediate)
+ .map_err(sqlite_error_from_rusqlite)?;
+ match policy::rotate_policy_capability(&transaction, &write_context, label)? {
+ Ok(grant) => {
+ transaction.commit().map_err(sqlite_error_from_rusqlite)?;
+ Ok(grant)
+ }
+ Err(error) => {
+ transaction.commit().map_err(sqlite_error_from_rusqlite)?;
+ Err(error)
+ }
+ }
+ })
+ }
+
+ pub fn disable_coordinated_policy(&mut self) -> TreeRingResult {
+ let write_context = self.write_context.clone();
+ write::retry_locked(|| {
+ let transaction = self
+ .connection
+ .transaction_with_behavior(TransactionBehavior::Immediate)
+ .map_err(sqlite_error_from_rusqlite)?;
+ match policy::disable_policy(&transaction, &write_context)? {
+ Ok(status) => {
+ transaction.commit().map_err(sqlite_error_from_rusqlite)?;
+ Ok(status)
+ }
+ Err(error) => {
+ transaction.commit().map_err(sqlite_error_from_rusqlite)?;
+ Err(error)
+ }
+ }
+ })
+ }
+
+ pub fn policy_status(&self) -> TreeRingResult {
+ self.ensure_policy_schema_available()?;
+ policy::policy_status_on_connection(&self.connection)
+ }
+
+ pub fn policy_audit(&self, limit: usize) -> TreeRingResult> {
+ if !(1..=1000).contains(&limit) {
+ return Err(TreeRingError::Validation(
+ "policy audit limit must be between 1 and 1000".to_string(),
+ ));
+ }
+ self.ensure_policy_schema_available()?;
+ policy::policy_audit_on_connection(&self.connection, limit)
+ }
+
+ fn ensure_policy_schema_available(&self) -> TreeRingResult<()> {
+ let version = schema::ensure_supported_schema_version(&self.connection)?;
+ if version < SQLITE_SCHEMA_VERSION {
+ return Err(TreeRingError::Validation(format!(
+ "coordinated policy requires SQLite schema version {SQLITE_SCHEMA_VERSION}; found version {version}; complete the offline v0.13 upgrade first"
+ )));
+ }
+ Ok(())
+ }
+
pub fn put(&mut self, event: &MemoryEvent) -> TreeRingResult<()> {
+ let write_context = self.write_context.clone();
write::retry_locked(|| {
let transaction = self
.connection
- .transaction()
+ .transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(sqlite_error_from_rusqlite)?;
+ if let policy::AuthorizationOutcome::Denied(error) =
+ policy::authorize_event_creates(&transaction, &write_context, "put", &[event])?
+ {
+ transaction.commit().map_err(sqlite_error_from_rusqlite)?;
+ return Err(error);
+ }
write::put_in_transaction(&transaction, event)?;
transaction.commit().map_err(sqlite_error_from_rusqlite)?;
Ok(())
@@ -252,7 +398,8 @@ impl SQLiteMemoryStore {
self.put(event)?;
return Ok(PutOutcome::Created);
};
- event.validate()?;
+ write::validate_memory_for_storage(event)?;
+ let write_context = self.write_context.clone();
write::retry_locked(|| {
let transaction = self
.connection
@@ -264,6 +411,15 @@ impl SQLiteMemoryStore {
transaction.commit().map_err(sqlite_error_from_rusqlite)?;
return Ok(PutOutcome::Existing(existing));
}
+ if let policy::AuthorizationOutcome::Denied(error) = policy::authorize_event_creates(
+ &transaction,
+ &write_context,
+ "put_idempotent",
+ &[event],
+ )? {
+ transaction.commit().map_err(sqlite_error_from_rusqlite)?;
+ return Err(error);
+ }
write::put_in_transaction(&transaction, event)?;
transaction.commit().map_err(sqlite_error_from_rusqlite)?;
Ok(PutOutcome::Created)
@@ -271,11 +427,22 @@ impl SQLiteMemoryStore {
}
pub fn put_many(&mut self, events: &[MemoryEvent]) -> TreeRingResult<()> {
+ let write_context = self.write_context.clone();
+ let event_refs = events.iter().collect::>();
write::retry_locked(|| {
let transaction = self
.connection
- .transaction()
+ .transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(sqlite_error_from_rusqlite)?;
+ if let policy::AuthorizationOutcome::Denied(error) = policy::authorize_event_creates(
+ &transaction,
+ &write_context,
+ "put_many",
+ &event_refs,
+ )? {
+ transaction.commit().map_err(sqlite_error_from_rusqlite)?;
+ return Err(error);
+ }
{
let mut insert_memory = transaction
.prepare(write::UPSERT_MEMORY_SQL)
@@ -524,11 +691,23 @@ impl SQLiteMemoryStore {
}
pub fn supersede(&mut self, old_id: &str, new_id: &str) -> TreeRingResult<()> {
+ let write_context = self.write_context.clone();
write::retry_locked(|| {
let transaction = self
.connection
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(sqlite_error_from_rusqlite)?;
+ if let policy::AuthorizationOutcome::Denied(error) =
+ policy::authorize_coordinator_action(
+ &transaction,
+ &write_context,
+ "supersede",
+ Some(old_id),
+ )?
+ {
+ transaction.commit().map_err(sqlite_error_from_rusqlite)?;
+ return Err(error);
+ }
write::supersede_in_transaction(&transaction, old_id, new_id)?;
transaction.commit().map_err(sqlite_error_from_rusqlite)?;
Ok(())
@@ -536,11 +715,23 @@ impl SQLiteMemoryStore {
}
pub fn delete(&mut self, memory_id: &str) -> TreeRingResult<()> {
+ let write_context = self.write_context.clone();
write::retry_locked(|| {
let transaction = self
.connection
- .transaction()
+ .transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(sqlite_error_from_rusqlite)?;
+ if let policy::AuthorizationOutcome::Denied(error) =
+ policy::authorize_coordinator_action(
+ &transaction,
+ &write_context,
+ "delete",
+ Some(memory_id),
+ )?
+ {
+ transaction.commit().map_err(sqlite_error_from_rusqlite)?;
+ return Err(error);
+ }
write::delete_in_transaction(&transaction, memory_id)?;
transaction.commit().map_err(sqlite_error_from_rusqlite)?;
Ok(())
@@ -548,11 +739,23 @@ impl SQLiteMemoryStore {
}
pub fn redact(&mut self, memory_id: &str) -> TreeRingResult<()> {
+ let write_context = self.write_context.clone();
write::retry_locked(|| {
let transaction = self
.connection
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(sqlite_error_from_rusqlite)?;
+ if let policy::AuthorizationOutcome::Denied(error) =
+ policy::authorize_coordinator_action(
+ &transaction,
+ &write_context,
+ "redact",
+ Some(memory_id),
+ )?
+ {
+ transaction.commit().map_err(sqlite_error_from_rusqlite)?;
+ return Err(error);
+ }
write::redact_in_transaction(&transaction, memory_id)?;
transaction.commit().map_err(sqlite_error_from_rusqlite)?;
Ok(())
@@ -565,6 +768,7 @@ impl SQLiteMemoryStore {
ring: &str,
event_type: &str,
) -> TreeRingResult> {
+ let write_context = self.write_context.clone();
write::retry_locked(|| {
let transaction = self
.connection
@@ -583,6 +787,17 @@ impl SQLiteMemoryStore {
transaction.commit().map_err(sqlite_error_from_rusqlite)?;
return Ok(None);
};
+ if let policy::AuthorizationOutcome::Denied(error) =
+ policy::authorize_coordinator_action(
+ &transaction,
+ &write_context,
+ "change_ring",
+ Some(memory_id),
+ )?
+ {
+ transaction.commit().map_err(sqlite_error_from_rusqlite)?;
+ return Err(error);
+ }
let tombstoned = write::is_redaction_tombstoned(&transaction, memory_id)?;
event.ring = ring.to_string();
if !tombstoned {
@@ -636,6 +851,7 @@ impl SQLiteMemoryStore {
return Ok(report);
}
+ let write_context = self.write_context.clone();
let (inserted_count, replaced_count, skipped_duplicate_count) =
write::retry_locked(|| {
let transaction = self
@@ -645,28 +861,49 @@ impl SQLiteMemoryStore {
let mut inserted_count = 0;
let mut replaced_count = 0;
let mut skipped_duplicate_count = 0;
- let mut written_events = Vec::new();
+ let mut planned_writes = Vec::new();
+ let mut planned_memory_ids = HashSet::new();
for event in &events {
- let exists: bool = transaction
+ let database_exists: bool = transaction
.query_row(
"SELECT EXISTS(SELECT 1 FROM memories WHERE id = ?)",
params![&event.id],
|row| row.get(0),
)
.map_err(sqlite_error_from_rusqlite)?;
+ let exists = database_exists || planned_memory_ids.contains(&event.id);
if exists && !replace_existing {
skipped_duplicate_count += 1;
continue;
}
- write::put_in_transaction(&transaction, event)?;
- written_events.push(event);
if exists {
replaced_count += 1;
} else {
inserted_count += 1;
}
+ planned_memory_ids.insert(event.id.clone());
+ planned_writes.push(event);
+ }
+
+ let target_memory_id = planned_writes
+ .first()
+ .map(|event| event.id.as_str())
+ .or_else(|| events.first().map(|event| event.id.as_str()));
+ let authorization = policy::authorize_coordinator_action(
+ &transaction,
+ &write_context,
+ "import_jsonl",
+ target_memory_id,
+ )?;
+ if let policy::AuthorizationOutcome::Denied(error) = authorization {
+ transaction.commit().map_err(sqlite_error_from_rusqlite)?;
+ return Err(error);
}
- for event in written_events {
+
+ for event in &planned_writes {
+ write::put_in_transaction(&transaction, event)?;
+ }
+ for event in planned_writes {
for old_id in &event.supersedes {
write::supersede_in_transaction(&transaction, old_id, &event.id)?;
}
@@ -689,10 +926,22 @@ impl SQLiteMemoryStore {
&mut self,
request: &ConsolidationRequest,
) -> TreeRingResult {
+ SensitivityGuard::default().detect_text_sensitivity(
+ [
+ request.period_key.as_deref(),
+ request.project.as_deref(),
+ request.agent_profile.as_deref(),
+ request.workflow_id.as_deref(),
+ request.session_id.as_deref(),
+ ]
+ .into_iter()
+ .flatten(),
+ )?;
if request.dry_run {
let events = self.list_all(false)?;
return consolidate_memories(&events, request);
}
+ let write_context = self.write_context.clone();
write::retry_locked(|| {
let transaction = self
.connection
@@ -730,6 +979,17 @@ impl SQLiteMemoryStore {
.iter()
.map(|output| output.memory.clone())
.collect::>();
+ if let policy::AuthorizationOutcome::Denied(error) =
+ policy::authorize_coordinator_action(
+ &transaction,
+ &write_context,
+ "consolidate",
+ output_events.first().map(|event| event.id.as_str()),
+ )?
+ {
+ transaction.commit().map_err(sqlite_error_from_rusqlite)?;
+ return Err(error);
+ }
let supersession_pairs = if request.force {
let previous_outputs = previous_consolidation_outputs_on_connection(
&transaction,
@@ -760,6 +1020,7 @@ impl SQLiteMemoryStore {
let apply_secret_redactions = !request.dry_run && request.apply_secret_redactions;
let repair_fts = !request.dry_run && request.repair_fts;
let needs_transaction = apply_expired || apply_secret_redactions || repair_fts;
+ let write_context = self.write_context.clone();
let mut report = if needs_transaction {
write::retry_locked(|| {
let transaction = self
@@ -770,6 +1031,30 @@ impl SQLiteMemoryStore {
let mut transaction_report = plan_maintenance(&events, request);
transaction_report.fts = fts_report_on_connection(&transaction, false)?;
+ let target_memory_id = transaction_report
+ .actions
+ .iter()
+ .find(|action| {
+ (action.action_type == MaintenanceActionType::RedactSecret
+ && apply_secret_redactions)
+ || (action.action_type == MaintenanceActionType::DeleteExpired
+ && apply_expired)
+ })
+ .map(|action| action.memory_id.as_str());
+ if repair_fts || target_memory_id.is_some() {
+ if let policy::AuthorizationOutcome::Denied(error) =
+ policy::authorize_coordinator_action(
+ &transaction,
+ &write_context,
+ "maintain",
+ target_memory_id,
+ )?
+ {
+ transaction.commit().map_err(sqlite_error_from_rusqlite)?;
+ return Err(error);
+ }
+ }
+
for action in &mut transaction_report.actions {
if action.action_type == MaintenanceActionType::RedactSecret
&& apply_secret_redactions
@@ -1419,11 +1704,11 @@ mod tests {
let dir = tempdir().unwrap();
let store = SQLiteMemoryStore::open(dir.path().join("memory.sqlite")).unwrap();
let journal_mode: String = store
- .connection()
+ .connection_for_testing()
.query_row("PRAGMA journal_mode", [], |row| row.get(0))
.unwrap();
let busy_timeout: i64 = store
- .connection()
+ .connection_for_testing()
.query_row("PRAGMA busy_timeout", [], |row| row.get(0))
.unwrap();
@@ -1437,7 +1722,7 @@ mod tests {
let db_path = dir.path().join("memory.sqlite");
let mut writer = SQLiteMemoryStore::open(&db_path).unwrap();
writer
- .connection()
+ .connection_for_testing()
.execute_batch("PRAGMA wal_autocheckpoint=0; PRAGMA wal_checkpoint(TRUNCATE);")
.unwrap();
let event = MemoryEvent::new("Visible from the live WAL.", "lesson").unwrap();
@@ -1449,12 +1734,12 @@ mod tests {
let reader = SQLiteMemoryStore::open_read_only(&db_path).unwrap();
assert_eq!(reader.get(&event.id).unwrap().unwrap().id, event.id);
let query_only: i64 = reader
- .connection()
+ .connection_for_testing()
.query_row("PRAGMA query_only", [], |row| row.get(0))
.unwrap();
assert_eq!(query_only, 1);
assert!(reader
- .connection()
+ .connection_for_testing()
.execute("CREATE TABLE forbidden_write (id INTEGER)", [])
.is_err());
}
@@ -1468,11 +1753,11 @@ mod tests {
let store = SQLiteMemoryStore::open(&db_path).unwrap();
for column in ["workflow_id", "session_id", "operation_id"] {
- assert!(schema::memory_column_exists(store.connection(), column).unwrap());
+ assert!(schema::memory_column_exists(store.connection_for_testing(), column).unwrap());
}
let indexes: HashSet = {
let mut statement = store
- .connection()
+ .connection_for_testing()
.prepare("SELECT name FROM sqlite_master WHERE type = 'index'")
.unwrap();
statement
@@ -1509,7 +1794,7 @@ mod tests {
assert!(workflow_id.starts_with("legacy-workflow-"));
assert!(migrated.review.needs_review);
let stored_workflow_id: String = store
- .connection()
+ .connection_for_testing()
.query_row(
"SELECT workflow_id FROM memories WHERE id = ?",
params![&legacy.id],
@@ -1586,7 +1871,9 @@ mod tests {
.is_some_and(|identity| identity.starts_with("legacy-session-")));
assert!(loaded.review.needs_review);
assert!(store.export_jsonl(false, false).is_ok());
- assert!(!schema::memory_column_exists(store.connection(), "session_id").unwrap());
+ assert!(
+ !schema::memory_column_exists(store.connection_for_testing(), "session_id").unwrap()
+ );
}
#[test]
@@ -1613,7 +1900,7 @@ mod tests {
let store = SQLiteMemoryStore::open(&db_path).unwrap();
for column in ["workflow_id", "session_id", "operation_id"] {
- assert!(schema::memory_column_exists(store.connection(), column).unwrap());
+ assert!(schema::memory_column_exists(store.connection_for_testing(), column).unwrap());
}
}
@@ -1623,7 +1910,7 @@ mod tests {
let db_path = dir.path().join("memory.sqlite");
let mut writer = SQLiteMemoryStore::open(&db_path).unwrap();
assert_eq!(
- schema::user_version(writer.connection()).unwrap(),
+ schema::user_version(writer.connection_for_testing()).unwrap(),
SQLITE_SCHEMA_VERSION
);
let transaction = writer
@@ -1633,7 +1920,7 @@ mod tests {
let (result_tx, result_rx) = std::sync::mpsc::channel();
let handle = thread::spawn(move || {
let result = SQLiteMemoryStore::open(&db_path)
- .and_then(|store| schema::user_version(store.connection()));
+ .and_then(|store| schema::user_version(store.connection_for_testing()));
result_tx.send(result).unwrap();
});
@@ -1646,6 +1933,681 @@ mod tests {
handle.join().unwrap();
}
+ #[test]
+ fn future_schema_is_rejected_before_writable_configuration_and_for_read_only_open() {
+ let dir = tempdir().unwrap();
+ let db_path = dir.path().join("future.sqlite");
+ let future_version = SQLITE_SCHEMA_VERSION + 1;
+ let connection = Connection::open(&db_path).unwrap();
+ connection
+ .execute_batch(
+ "PRAGMA journal_mode=DELETE; CREATE TABLE sentinel (id INTEGER PRIMARY KEY);",
+ )
+ .unwrap();
+ connection
+ .pragma_update(None, "user_version", future_version)
+ .unwrap();
+ drop(connection);
+
+ let writable_error = SQLiteMemoryStore::open(&db_path)
+ .err()
+ .expect("future writable schema unexpectedly opened");
+ assert!(matches!(
+ writable_error,
+ TreeRingError::Storage(message)
+ if message.contains("unsupported SQLite schema version")
+ && message.contains(&future_version.to_string())
+ ));
+
+ let verification = Connection::open(&db_path).unwrap();
+ let journal_mode: String = verification
+ .query_row("PRAGMA journal_mode", [], |row| row.get(0))
+ .unwrap();
+ assert_eq!(journal_mode.to_ascii_lowercase(), "delete");
+ assert_eq!(schema::user_version(&verification).unwrap(), future_version);
+ drop(verification);
+
+ let read_only_error = SQLiteMemoryStore::open_read_only(&db_path)
+ .err()
+ .expect("future read-only schema unexpectedly opened");
+ assert!(matches!(
+ read_only_error,
+ TreeRingError::Storage(message)
+ if message.contains("unsupported SQLite schema version")
+ && message.contains(&future_version.to_string())
+ ));
+ }
+
+ #[test]
+ fn v2_to_v3_migration_repeats_legacy_private_scope_normalization() {
+ let dir = tempdir().unwrap();
+ let db_path = dir.path().join("memory.sqlite");
+ let mut store = SQLiteMemoryStore::open(&db_path).unwrap();
+ let mut legacy =
+ MemoryEvent::new("Legacy v2 agent identity requires repair.", "lesson").unwrap();
+ legacy.scope = "agent".to_string();
+ legacy.agent_profile = Some("legacy-agent-before-corruption".to_string());
+ store.put(&legacy).unwrap();
+
+ let mut corrupted = legacy.clone();
+ corrupted.agent_profile = Some(" \t ".to_string());
+ corrupted.review = Default::default();
+ store
+ .connection_for_testing()
+ .execute(
+ r#"
+ UPDATE memories
+ SET agent_profile = ?, review_json = ?, raw_json = ?
+ WHERE id = ?
+ "#,
+ params![
+ corrupted.agent_profile.as_deref(),
+ serde_json::to_string(&corrupted.review).unwrap(),
+ serde_json::to_string(&corrupted).unwrap(),
+ &corrupted.id,
+ ],
+ )
+ .unwrap();
+ store
+ .connection_for_testing()
+ .pragma_update(None, "user_version", 2_i64)
+ .unwrap();
+ drop(store);
+
+ let migrated = SQLiteMemoryStore::open(&db_path).unwrap();
+ let repaired = migrated.get(&legacy.id).unwrap().unwrap();
+ assert_eq!(
+ schema::user_version(migrated.connection_for_testing()).unwrap(),
+ SQLITE_SCHEMA_VERSION
+ );
+ assert!(repaired
+ .agent_profile
+ .as_deref()
+ .is_some_and(|identity| identity.starts_with("legacy-agent-")));
+ assert!(repaired.review.needs_review);
+ repaired.validate().unwrap();
+ }
+
+ #[test]
+ fn writer_protocol_triggers_reject_plain_connections_before_memory_mutation() {
+ let dir = tempdir().unwrap();
+ let db_path = dir.path().join("memory.sqlite");
+ let mut store = SQLiteMemoryStore::open(&db_path).unwrap();
+ let protocol: i64 = store
+ .connection_for_testing()
+ .query_row("SELECT tree_ring_writer_protocol()", [], |row| row.get(0))
+ .unwrap();
+ assert_eq!(protocol, schema::WRITER_PROTOCOL_VERSION);
+ let event = MemoryEvent::new("Protected by writer protocol.", "lesson").unwrap();
+ store.put(&event).unwrap();
+ let original = store.get(&event.id).unwrap().unwrap();
+ drop(store);
+
+ let plain = Connection::open(&db_path).unwrap();
+ let insert_error = plain
+ .execute(
+ r#"
+ INSERT INTO memories
+ SELECT
+ 'mem_plain_writer_insert', created_at, updated_at, project,
+ agent_profile, workflow_id, session_id, operation_id, scope,
+ ring, event_type, summary, details, source_json, tags_json,
+ salience, confidence, sensitivity, retention, expires_at,
+ supersedes_json, superseded_by, links_json, review_json, raw_json
+ FROM memories
+ WHERE id = ?
+ "#,
+ params![&event.id],
+ )
+ .unwrap_err();
+ assert!(insert_error
+ .to_string()
+ .contains(schema::WRITER_PROTOCOL_FUNCTION));
+
+ let update_error = plain
+ .execute(
+ "UPDATE memories SET summary = 'plain writer update' WHERE id = ?",
+ params![&event.id],
+ )
+ .unwrap_err();
+ assert!(update_error
+ .to_string()
+ .contains(schema::WRITER_PROTOCOL_FUNCTION));
+
+ let delete_error = plain
+ .execute("DELETE FROM memories WHERE id = ?", params![&event.id])
+ .unwrap_err();
+ assert!(delete_error
+ .to_string()
+ .contains(schema::WRITER_PROTOCOL_FUNCTION));
+ drop(plain);
+
+ let verification = SQLiteMemoryStore::open(&db_path).unwrap();
+ assert_eq!(verification.get(&event.id).unwrap(), Some(original));
+ assert!(verification
+ .get("mem_plain_writer_insert")
+ .unwrap()
+ .is_none());
+ assert_eq!(
+ lifecycle::count_query(
+ verification.connection_for_testing(),
+ "SELECT count(*) FROM memories"
+ )
+ .unwrap(),
+ 1
+ );
+ assert_eq!(
+ lifecycle::count_query(
+ verification.connection_for_testing(),
+ "SELECT count(*) FROM memory_fts"
+ )
+ .unwrap(),
+ 1
+ );
+ }
+
+ #[test]
+ fn coordinated_policy_persists_only_a_hash_and_returns_capability_once() {
+ let dir = tempdir().unwrap();
+ let db_path = dir.path().join("memory.sqlite");
+ let mut store = SQLiteMemoryStore::open(&db_path).unwrap();
+ let initial = store.policy_status().unwrap();
+ assert_eq!(initial.mode, PolicyMode::Open);
+ assert!(initial.coordinator_label.is_none());
+ assert!(initial.enabled_at.is_none());
+
+ let grant = store
+ .enable_coordinated_policy(Some("release-coordinator"))
+ .unwrap();
+ assert_eq!(grant.status.mode, PolicyMode::Coordinated);
+ assert_eq!(
+ grant.status.coordinator_label.as_deref(),
+ Some("release-coordinator")
+ );
+ assert!(grant.status.enabled_at.is_some());
+ assert!(serde_json::to_string(&grant)
+ .unwrap()
+ .contains(&grant.capability));
+ assert!(!format!("{grant:?}").contains(&grant.capability));
+
+ let stored_hash: Vec = store
+ .connection_for_testing()
+ .query_row(
+ "SELECT capability_hash FROM store_policy WHERE id = 1",
+ [],
+ |row| row.get(0),
+ )
+ .unwrap();
+ assert_eq!(stored_hash.len(), 32);
+ assert_ne!(stored_hash.as_slice(), grant.capability.as_bytes());
+ let audit = store.policy_audit(10).unwrap();
+ assert_eq!(audit.len(), 1);
+ assert_eq!(audit[0].action, "policy_enable");
+ assert_eq!(audit[0].decision, "allowed");
+ assert_eq!(audit[0].origin, "anonymous");
+ assert!(matches!(
+ store.policy_audit(0),
+ Err(TreeRingError::Validation(message))
+ if message.contains("between 1 and 1000")
+ ));
+ assert!(matches!(
+ store.policy_audit(usize::MAX),
+ Err(TreeRingError::Validation(message))
+ if message.contains("between 1 and 1000")
+ ));
+ drop(store);
+
+ for path in [
+ db_path.clone(),
+ db_path.with_extension("sqlite-wal"),
+ db_path.with_extension("sqlite-shm"),
+ ] {
+ let Ok(bytes) = std::fs::read(path) else {
+ continue;
+ };
+ assert!(!bytes
+ .windows(grant.capability.len())
+ .any(|window| window == grant.capability.as_bytes()));
+ }
+
+ let reopened = SQLiteMemoryStore::open(&db_path).unwrap();
+ assert_eq!(reopened.policy_status().unwrap(), grant.status);
+ }
+
+ #[test]
+ fn live_capabilities_are_rejected_from_context_labels_and_never_persisted() {
+ let dir = tempdir().unwrap();
+ let db_path = dir.path().join("memory.sqlite");
+ let mut initializer = SQLiteMemoryStore::open(&db_path).unwrap();
+ let grant = initializer
+ .enable_coordinated_policy(Some("safe-coordinator"))
+ .unwrap();
+ drop(initializer);
+ let wrapped_capability = format!("worker_{}_suffix", grant.capability);
+
+ for rejected_context in [
+ WriteContext::new(Some(grant.capability.clone()), None, "context-validation"),
+ WriteContext::new(Some(wrapped_capability.clone()), None, "context-validation"),
+ WriteContext::new(None, None, wrapped_capability.clone()),
+ ] {
+ let error = rejected_context.unwrap_err().to_string();
+ assert!(!error.contains(&grant.capability));
+ assert!(error.contains("secret-like"));
+ }
+
+ let coordinator_context =
+ WriteContext::new(None, Some(&grant.capability), "label-validation").unwrap();
+ let mut coordinator =
+ SQLiteMemoryStore::open_with_context(&db_path, coordinator_context).unwrap();
+ let error = coordinator
+ .rotate_coordinator_capability(Some(&wrapped_capability))
+ .unwrap_err()
+ .to_string();
+ assert!(!error.contains(&grant.capability));
+ assert!(error.contains("secret-like"));
+
+ let mut summary_event = MemoryEvent::new(&wrapped_capability, "lesson").unwrap();
+ summary_event.scope = "project".to_string();
+ summary_event.project = Some("core".to_string());
+ let error = coordinator.put(&summary_event).unwrap_err().to_string();
+ assert!(error.contains("secret-like"));
+ assert!(!error.contains(&grant.capability));
+ assert!(coordinator.get(&summary_event.id).unwrap().is_none());
+
+ let mut metadata_event = MemoryEvent::new("Safe summary.", "lesson").unwrap();
+ metadata_event.scope = "project".to_string();
+ metadata_event.project = Some("core".to_string());
+ metadata_event.source.ref_ = wrapped_capability.clone();
+ metadata_event.operation_id = Some("secret-metadata-attempt".to_string());
+ let error = coordinator
+ .put_idempotent(&metadata_event)
+ .unwrap_err()
+ .to_string();
+ assert!(error.contains("secret-like"));
+ assert!(!error.contains(&grant.capability));
+ assert!(coordinator.get(&metadata_event.id).unwrap().is_none());
+
+ let mut invalid_enum_event = MemoryEvent::new("Safe summary.", "lesson").unwrap();
+ invalid_enum_event.ring = wrapped_capability.clone();
+ invalid_enum_event.operation_id = Some("secret-enum-attempt".to_string());
+ let error = coordinator
+ .put_idempotent(&invalid_enum_event)
+ .unwrap_err()
+ .to_string();
+ assert!(error.contains("secret-like"));
+ assert!(!error.contains(&grant.capability));
+ assert!(coordinator.get(&invalid_enum_event.id).unwrap().is_none());
+
+ let mut batch_safe = MemoryEvent::new("Safe batch member.", "lesson").unwrap();
+ batch_safe.scope = "project".to_string();
+ batch_safe.project = Some("core".to_string());
+ let mut batch_secret = MemoryEvent::new("Secret batch member.", "lesson").unwrap();
+ batch_secret.scope = "project".to_string();
+ batch_secret.project = Some("core".to_string());
+ batch_secret.tags = vec![wrapped_capability.clone()];
+ let error = coordinator
+ .put_many(&[batch_safe.clone(), batch_secret.clone()])
+ .unwrap_err()
+ .to_string();
+ assert!(error.contains("secret-like"));
+ assert!(!error.contains(&grant.capability));
+ assert!(coordinator.get(&batch_safe.id).unwrap().is_none());
+ assert!(coordinator.get(&batch_secret.id).unwrap().is_none());
+
+ let mut baseline = MemoryEvent::new("Safe baseline.", "lesson").unwrap();
+ baseline.scope = "project".to_string();
+ baseline.project = Some("core".to_string());
+ coordinator.put(&baseline).unwrap();
+ let error = coordinator
+ .change_ring(&baseline.id, "outer", &wrapped_capability)
+ .unwrap_err()
+ .to_string();
+ assert!(error.contains("secret-like"));
+ assert!(!error.contains(&grant.capability));
+ assert_eq!(
+ coordinator.get(&baseline.id).unwrap().unwrap().event_type,
+ "lesson"
+ );
+ let error = coordinator
+ .supersede(&baseline.id, &wrapped_capability)
+ .unwrap_err()
+ .to_string();
+ assert!(error.contains("secret-like"));
+ assert!(!error.contains(&grant.capability));
+ assert!(coordinator
+ .get(&baseline.id)
+ .unwrap()
+ .unwrap()
+ .superseded_by
+ .is_none());
+
+ let mut consolidation = ConsolidationRequest::new("manual").unwrap();
+ consolidation.period_key = Some(wrapped_capability.clone());
+ consolidation.project = Some("core".to_string());
+ let error = coordinator
+ .consolidate(&consolidation)
+ .unwrap_err()
+ .to_string();
+ assert!(error.contains("secret-like"));
+ assert!(!error.contains(&grant.capability));
+
+ coordinator.delete(&wrapped_capability).unwrap();
+ let status_json = serde_json::to_string(&coordinator.policy_status().unwrap()).unwrap();
+ let audit_json = serde_json::to_string(&coordinator.policy_audit(100).unwrap()).unwrap();
+ assert!(!status_json.contains(&grant.capability));
+ assert!(!audit_json.contains(&grant.capability));
+ assert!(status_json.contains("safe-coordinator"));
+ drop(coordinator);
+
+ for path in [
+ db_path.clone(),
+ db_path.with_extension("sqlite-wal"),
+ db_path.with_extension("sqlite-shm"),
+ ] {
+ let Ok(bytes) = std::fs::read(path) else {
+ continue;
+ };
+ assert!(!bytes
+ .windows(grant.capability.len())
+ .any(|window| window == grant.capability.as_bytes()));
+ }
+ }
+
+ #[test]
+ fn coordinator_rotation_revokes_the_old_capability_and_disable_requires_the_new_one() {
+ let dir = tempdir().unwrap();
+ let db_path = dir.path().join("memory.sqlite");
+ let mut initializer = SQLiteMemoryStore::open(&db_path).unwrap();
+ assert_authorization_denied(initializer.rotate_coordinator_capability(None));
+ assert!(initializer.policy_audit(10).unwrap().iter().any(|event| {
+ event.action == "policy_rotate"
+ && event.decision == "denied"
+ && event.reason == "coordinated_policy_not_enabled"
+ }));
+ let initial_grant = initializer
+ .enable_coordinated_policy(Some("coordinator-a"))
+ .unwrap();
+ drop(initializer);
+
+ let old_context =
+ WriteContext::new(None, Some(&initial_grant.capability), "policy-rotation").unwrap();
+ let mut old_coordinator =
+ SQLiteMemoryStore::open_with_context(&db_path, old_context).unwrap();
+ let rotated = old_coordinator
+ .rotate_coordinator_capability(Some("coordinator-b"))
+ .unwrap();
+ assert_ne!(rotated.capability, initial_grant.capability);
+ assert_eq!(
+ rotated.status.coordinator_label.as_deref(),
+ Some("coordinator-b")
+ );
+
+ assert_authorization_denied(old_coordinator.disable_coordinated_policy());
+ let audit = old_coordinator.policy_audit(10).unwrap();
+ assert!(audit.iter().any(|event| {
+ event.action == "policy_disable"
+ && event.decision == "denied"
+ && event.reason == "invalid_coordinator_capability"
+ }));
+ drop(old_coordinator);
+
+ let new_context =
+ WriteContext::new(None, Some(&rotated.capability), "policy-disable").unwrap();
+ let mut new_coordinator =
+ SQLiteMemoryStore::open_with_context(&db_path, new_context).unwrap();
+ let disabled = new_coordinator.disable_coordinated_policy().unwrap();
+ assert_eq!(disabled.mode, PolicyMode::Open);
+ assert!(disabled.coordinator_label.is_none());
+ assert!(disabled.enabled_at.is_none());
+ let stored_hash: Option> = new_coordinator
+ .connection_for_testing()
+ .query_row(
+ "SELECT capability_hash FROM store_policy WHERE id = 1",
+ [],
+ |row| row.get(0),
+ )
+ .unwrap();
+ assert!(stored_hash.is_none());
+ }
+
+ #[test]
+ fn coordinated_mode_allows_only_new_matching_non_heartwood_agent_events_without_capability() {
+ let dir = tempdir().unwrap();
+ let db_path = dir.path().join("memory.sqlite");
+ let mut initializer = SQLiteMemoryStore::open(&db_path).unwrap();
+ let grant = initializer
+ .enable_coordinated_policy(Some("coordinator"))
+ .unwrap();
+ drop(initializer);
+
+ let actor_context =
+ WriteContext::new(Some("agent-a".to_string()), None, "agent-worker").unwrap();
+ let mut actor_store =
+ SQLiteMemoryStore::open_with_context(&db_path, actor_context).unwrap();
+ let mut allowed = MemoryEvent::new("Agent-local observation.", "lesson").unwrap();
+ allowed.scope = "agent".to_string();
+ allowed.agent_profile = Some("agent-a".to_string());
+ allowed.ring = "outer".to_string();
+ allowed.operation_id = Some("agent-create-1".to_string());
+ actor_store.put_idempotent(&allowed).unwrap();
+ assert!(matches!(
+ actor_store.put_idempotent(&allowed).unwrap(),
+ PutOutcome::Existing(_)
+ ));
+
+ let mut replacement = allowed.clone();
+ replacement.summary = "Attempted replacement.".to_string();
+ assert_authorization_denied(actor_store.put(&replacement));
+ assert_eq!(
+ actor_store.get(&allowed.id).unwrap().unwrap().summary,
+ allowed.summary
+ );
+
+ let mut heartwood = allowed.clone();
+ heartwood.id = "mem_agent_heartwood_denied".to_string();
+ heartwood.operation_id = Some("agent-heartwood-1".to_string());
+ heartwood.ring = "heartwood".to_string();
+ assert_authorization_denied(actor_store.put(&heartwood));
+
+ let mut other_agent = allowed.clone();
+ other_agent.id = "mem_other_agent_denied".to_string();
+ other_agent.operation_id = Some("other-agent-1".to_string());
+ other_agent.agent_profile = Some("agent-b".to_string());
+ assert_authorization_denied(actor_store.put(&other_agent));
+
+ let mut shared = allowed.clone();
+ shared.id = "mem_shared_scope_denied".to_string();
+ shared.operation_id = Some("shared-scope-1".to_string());
+ shared.scope = "project".to_string();
+ assert_authorization_denied(actor_store.put(&shared));
+
+ let mut batch_allowed = allowed.clone();
+ batch_allowed.id = "mem_batch_allowed_candidate".to_string();
+ batch_allowed.operation_id = Some("batch-agent-1".to_string());
+ let mut batch_denied = shared.clone();
+ batch_denied.id = "mem_batch_denied_candidate".to_string();
+ batch_denied.operation_id = Some("batch-shared-1".to_string());
+ assert_authorization_denied(
+ actor_store.put_many(&[batch_allowed.clone(), batch_denied.clone()]),
+ );
+ assert!(actor_store.get(&batch_allowed.id).unwrap().is_none());
+ assert!(actor_store.get(&batch_denied.id).unwrap().is_none());
+
+ let mut duplicate_first = allowed.clone();
+ duplicate_first.id = "mem_duplicate_agent_batch".to_string();
+ duplicate_first.operation_id = Some("duplicate-agent-1".to_string());
+ let mut duplicate_second = duplicate_first.clone();
+ duplicate_second.summary = "Second write to the same id.".to_string();
+ duplicate_second.operation_id = Some("duplicate-agent-2".to_string());
+ assert_authorization_denied(
+ actor_store.put_many(&[duplicate_first.clone(), duplicate_second]),
+ );
+ assert!(actor_store.get(&duplicate_first.id).unwrap().is_none());
+
+ let mut import_candidate = allowed.clone();
+ import_candidate.id = "mem_agent_import_denied".to_string();
+ import_candidate.operation_id = Some("agent-import-1".to_string());
+ let import_jsonl = encode_jsonl(&[import_candidate.clone()], false).unwrap();
+ assert_authorization_denied(actor_store.import_jsonl(&import_jsonl, false, false));
+ assert!(actor_store.get(&import_candidate.id).unwrap().is_none());
+ drop(actor_store);
+
+ let coordinator_context =
+ WriteContext::new(None, Some(&grant.capability), "coordinator-worker").unwrap();
+ let mut coordinator =
+ SQLiteMemoryStore::open_with_context(&db_path, coordinator_context).unwrap();
+ coordinator.put(&heartwood).unwrap();
+ assert_eq!(
+ coordinator.get(&heartwood.id).unwrap().unwrap().ring,
+ "heartwood"
+ );
+ assert!(coordinator.policy_audit(20).unwrap().iter().any(|event| {
+ event.action == "put"
+ && event.decision == "allowed"
+ && event.reason == "valid_coordinator_capability"
+ && event.target_memory_id.as_deref() == Some(heartwood.id.as_str())
+ }));
+ }
+
+ #[test]
+ fn coordinated_denials_hash_unsafe_or_secret_audit_targets() {
+ let dir = tempdir().unwrap();
+ let db_path = dir.path().join("memory.sqlite");
+ let mut setup = SQLiteMemoryStore::open(&db_path).unwrap();
+ setup
+ .enable_coordinated_policy(Some("coordinator"))
+ .unwrap();
+ drop(setup);
+
+ let context = WriteContext::new(Some("agent-a".to_string()), None, "audit-safety").unwrap();
+ let mut store = SQLiteMemoryStore::open_with_context(&db_path, context).unwrap();
+ let malicious_target = "bad\nid\u{1b}[31m";
+ let capability_target = format!("trcap_v1_{}", "a".repeat(64));
+ assert_authorization_denied(store.delete(malicious_target));
+ assert_authorization_denied(store.delete(&capability_target));
+
+ let audit = store.policy_audit(2).unwrap();
+ assert_eq!(audit.len(), 2);
+ for event in audit {
+ let target = event.target_memory_id.unwrap();
+ assert!(target.starts_with("sha256:"));
+ assert_eq!(target.len(), 71);
+ assert!(!target.chars().any(char::is_control));
+ assert!(!target.contains(malicious_target));
+ assert!(!target.contains(&capability_target));
+ }
+ }
+
+ #[test]
+ fn coordinated_denials_cover_every_mutating_api_and_change_only_the_audit_log() {
+ let dir = tempdir().unwrap();
+ let db_path = dir.path().join("memory.sqlite");
+ let mut setup = SQLiteMemoryStore::open(&db_path).unwrap();
+ let mut baseline = MemoryEvent::new("Protected project decision.", "decision").unwrap();
+ baseline.project = Some("core".to_string());
+ baseline.scope = "project".to_string();
+ setup.put(&baseline).unwrap();
+ let mut replacement_target =
+ MemoryEvent::new("Second protected decision.", "decision").unwrap();
+ replacement_target.project = Some("core".to_string());
+ replacement_target.scope = "project".to_string();
+ setup.put(&replacement_target).unwrap();
+ let mut expired =
+ expired_maintenance_memory("Expired protected cache.", "ephemeral", "outer");
+ expired.project = Some("core".to_string());
+ expired.scope = "project".to_string();
+ setup.put(&expired).unwrap();
+ setup
+ .enable_coordinated_policy(Some("coordinator"))
+ .unwrap();
+ let before = storage_snapshot(setup.connection_for_testing());
+ drop(setup);
+
+ let actor_context =
+ WriteContext::new(Some("agent-a".to_string()), None, "denial-matrix").unwrap();
+ let mut store = SQLiteMemoryStore::open_with_context(&db_path, actor_context).unwrap();
+
+ let mut replacement = baseline.clone();
+ replacement.summary = "Unauthorized replacement.".to_string();
+ assert_authorization_denied(store.put(&replacement));
+
+ let mut idempotent = MemoryEvent::new("Unauthorized idempotent create.", "lesson").unwrap();
+ idempotent.scope = "project".to_string();
+ idempotent.project = Some("core".to_string());
+ idempotent.operation_id = Some("unauthorized-idempotent".to_string());
+ assert_authorization_denied(store.put_idempotent(&idempotent));
+
+ let mut allowed_batch_member =
+ MemoryEvent::new("Allowed batch candidate.", "lesson").unwrap();
+ allowed_batch_member.scope = "agent".to_string();
+ allowed_batch_member.agent_profile = Some("agent-a".to_string());
+ let mut denied_batch_member =
+ MemoryEvent::new("Denied batch candidate.", "lesson").unwrap();
+ denied_batch_member.scope = "project".to_string();
+ denied_batch_member.project = Some("core".to_string());
+ assert_authorization_denied(
+ store.put_many(&[allowed_batch_member.clone(), denied_batch_member.clone()]),
+ );
+
+ let replacement_jsonl = encode_jsonl(&[replacement.clone()], false).unwrap();
+ assert_authorization_denied(store.import_jsonl(&replacement_jsonl, false, true));
+
+ let mut import_supersession =
+ MemoryEvent::new("Agent import with protected supersession.", "lesson").unwrap();
+ import_supersession.scope = "agent".to_string();
+ import_supersession.agent_profile = Some("agent-a".to_string());
+ import_supersession.supersedes = vec![baseline.id.clone()];
+ let supersession_jsonl = encode_jsonl(&[import_supersession.clone()], false).unwrap();
+ assert_authorization_denied(store.import_jsonl(&supersession_jsonl, false, false));
+
+ let mut consolidation = ConsolidationRequest::new("manual").unwrap();
+ consolidation.period_key = Some("coordinated-denial".to_string());
+ consolidation.project = Some("core".to_string());
+ assert_authorization_denied(store.consolidate(&consolidation));
+ assert_authorization_denied(store.change_ring(&baseline.id, "heartwood", "decision"));
+ assert_authorization_denied(store.supersede(&baseline.id, &replacement_target.id));
+ assert_authorization_denied(store.delete(&baseline.id));
+ assert_authorization_denied(store.redact(&baseline.id));
+ assert_authorization_denied(store.maintain(&MaintenanceRequest {
+ dry_run: false,
+ apply_expired: true,
+ project: Some("core".to_string()),
+ ..MaintenanceRequest::default()
+ }));
+
+ let import_dry_run = store.import_jsonl(&replacement_jsonl, true, true).unwrap();
+ assert!(import_dry_run.dry_run);
+ consolidation.dry_run = true;
+ assert_eq!(store.consolidate(&consolidation).unwrap().status, "dry_run");
+ assert!(store.maintain(&MaintenanceRequest::default()).is_ok());
+
+ let after = storage_snapshot(store.connection_for_testing());
+ assert_eq!(after, before);
+ let audit = store.policy_audit(100).unwrap();
+ for action in [
+ "put",
+ "put_idempotent",
+ "put_many",
+ "import_jsonl",
+ "consolidate",
+ "change_ring",
+ "supersede",
+ "delete",
+ "redact",
+ "maintain",
+ ] {
+ assert!(
+ audit
+ .iter()
+ .any(|event| event.action == action && event.decision == "denied"),
+ "missing denial audit for {action}"
+ );
+ }
+ assert!(store.get(&allowed_batch_member.id).unwrap().is_none());
+ assert!(store.get(&denied_batch_member.id).unwrap().is_none());
+ assert!(store.get(&import_supersession.id).unwrap().is_none());
+ }
+
#[test]
fn store_searches_fts() {
let dir = tempdir().unwrap();
@@ -1674,11 +2636,11 @@ mod tests {
store.put_many(&events).unwrap();
let memory_count: i64 = store
- .connection()
+ .connection_for_testing()
.query_row("SELECT count(*) FROM memories", [], |row| row.get(0))
.unwrap();
let fts_count: i64 = store
- .connection()
+ .connection_for_testing()
.query_row("SELECT count(*) FROM memory_fts", [], |row| row.get(0))
.unwrap();
assert_eq!(memory_count, 5);
@@ -1760,7 +2722,7 @@ mod tests {
);
let store = SQLiteMemoryStore::open(&db_path).unwrap();
let rows: i64 = store
- .connection()
+ .connection_for_testing()
.query_row("SELECT count(*) FROM memories", [], |row| row.get(0))
.unwrap();
assert_eq!(rows, 1);
@@ -2020,7 +2982,7 @@ mod tests {
let mut event = MemoryEvent::new("Legacy memory with secret metadata.", "lesson").unwrap();
event.source.ref_ = "sk-proj-abcdefghijklmnopqrstuvwxyz1234567890".to_string();
event.sensitivity = "secret".to_string();
- store.put(&event).unwrap();
+ put_legacy_event_unchecked_for_testing(&mut store, &event);
store.redact(&event.id).unwrap();
@@ -2060,7 +3022,7 @@ mod tests {
assert!(store.get(&retry.id).unwrap().is_none());
assert!(store.put(&retry).is_err());
let scrubbed_columns: [Option; 5] = store
- .connection()
+ .connection_for_testing()
.query_row(
r#"
SELECT project, agent_profile, workflow_id, session_id, operation_id
@@ -2081,7 +3043,7 @@ mod tests {
.unwrap();
assert_eq!(scrubbed_columns, [None, None, None, None, None]);
let (claim_hash, claim_memory_id): (Vec, String) = store
- .connection()
+ .connection_for_testing()
.query_row(
"SELECT namespace_hash, memory_id FROM operation_claims",
[],
@@ -2094,7 +3056,7 @@ mod tests {
.windows("operation-1".len())
.any(|window| window == b"operation-1"));
let rows: i64 = store
- .connection()
+ .connection_for_testing()
.query_row("SELECT count(*) FROM memories", [], |row| row.get(0))
.unwrap();
assert_eq!(rows, 1);
@@ -2123,7 +3085,7 @@ mod tests {
assert_eq!(retained.summary, "[REDACTED]");
assert_eq!(retained.event_type, "redacted");
let tombstones: i64 = store
- .connection()
+ .connection_for_testing()
.query_row(
"SELECT count(*) FROM redaction_tombstones WHERE memory_id = ?",
params![&original.id],
@@ -2178,14 +3140,14 @@ mod tests {
store.delete(&event.id).unwrap();
let claims: i64 = store
- .connection()
+ .connection_for_testing()
.query_row("SELECT count(*) FROM operation_claims", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(claims, 0);
let tombstones: i64 = store
- .connection()
+ .connection_for_testing()
.query_row("SELECT count(*) FROM redaction_tombstones", [], |row| {
row.get(0)
})
@@ -2585,11 +3547,11 @@ mod tests {
let report = store.consolidate(&request).unwrap();
let rows: i64 = store
- .connection()
+ .connection_for_testing()
.query_row("SELECT count(*) FROM memories", [], |row| row.get(0))
.unwrap();
let records: i64 = store
- .connection()
+ .connection_for_testing()
.query_row("SELECT count(*) FROM consolidations", [], |row| row.get(0))
.unwrap();
@@ -2616,11 +3578,11 @@ mod tests {
let report = store.consolidate(&request).unwrap();
let rows: i64 = store
- .connection()
+ .connection_for_testing()
.query_row("SELECT count(*) FROM memories", [], |row| row.get(0))
.unwrap();
let records: i64 = store
- .connection()
+ .connection_for_testing()
.query_row("SELECT count(*) FROM consolidations", [], |row| row.get(0))
.unwrap();
@@ -2651,7 +3613,7 @@ mod tests {
let first = store.consolidate(&request).unwrap();
let second = store.consolidate(&request).unwrap();
let records: i64 = store
- .connection()
+ .connection_for_testing()
.query_row("SELECT count(*) FROM consolidations", [], |row| row.get(0))
.unwrap();
@@ -2713,11 +3675,11 @@ mod tests {
assert_eq!(reports[0].output_memory_ids, reports[1].output_memory_ids);
let store = SQLiteMemoryStore::open(&db_path).unwrap();
let records: i64 = store
- .connection()
+ .connection_for_testing()
.query_row("SELECT count(*) FROM consolidations", [], |row| row.get(0))
.unwrap();
let rows: i64 = store
- .connection()
+ .connection_for_testing()
.query_row("SELECT count(*) FROM memories", [], |row| row.get(0))
.unwrap();
assert_eq!(records, 1);
@@ -2790,7 +3752,7 @@ mod tests {
let second = store.consolidate(&request).unwrap();
let old = store.get(&first.output_memory_ids[0]).unwrap().unwrap();
let records: i64 = store
- .connection()
+ .connection_for_testing()
.query_row("SELECT count(*) FROM consolidations", [], |row| row.get(0))
.unwrap();
@@ -3002,7 +3964,7 @@ mod tests {
let mut secret = MemoryEvent::new("Secret-like memory", "lesson").unwrap();
secret.details = "Use sk-proj-abcdefghijklmnopqrstuvwxyz1234567890".to_string();
store.put(&expired).unwrap();
- store.put(&secret).unwrap();
+ put_legacy_event_unchecked_for_testing(&mut store, &secret);
let report = store.maintain(&MaintenanceRequest::default()).unwrap();
@@ -3092,7 +4054,7 @@ mod tests {
let raw_secret = "sk-proj-abcdefghijklmnopqrstuvwxyz1234567890";
let mut event = MemoryEvent::new("Secret-like memory", "lesson").unwrap();
event.details = format!("Use {raw_secret}");
- store.put(&event).unwrap();
+ put_legacy_event_unchecked_for_testing(&mut store, &event);
let report = store
.maintain(&MaintenanceRequest {
@@ -3119,11 +4081,11 @@ mod tests {
let event = MemoryEvent::new("Repair missing FTS row.", "lesson").unwrap();
store.put(&event).unwrap();
store
- .connection()
+ .connection_for_testing()
.execute("DELETE FROM memory_fts WHERE id = ?", params![&event.id])
.unwrap();
store
- .connection()
+ .connection_for_testing()
.execute(
"INSERT INTO memory_fts (id, summary, details, tags, source_ref) VALUES (?, ?, ?, ?, ?)",
params!["mem_orphan", "orphan", "", "", ""],
@@ -3190,7 +4152,7 @@ mod tests {
store.delete(&new.id).unwrap();
let mismatch_count: i64 = store
- .connection()
+ .connection_for_testing()
.query_row(
r#"
SELECT
@@ -3236,11 +4198,11 @@ mod tests {
let store = SQLiteMemoryStore::open(&db_path).unwrap();
let memory_count: i64 = store
- .connection()
+ .connection_for_testing()
.query_row("SELECT count(*) FROM memories", [], |row| row.get(0))
.unwrap();
let fts_count: i64 = store
- .connection()
+ .connection_for_testing()
.query_row("SELECT count(*) FROM memory_fts", [], |row| row.get(0))
.unwrap();
@@ -3248,6 +4210,120 @@ mod tests {
assert_eq!(fts_count, 80);
}
+ fn assert_authorization_denied(result: TreeRingResult) {
+ assert!(matches!(result, Err(TreeRingError::AuthorizationDenied(_))));
+ }
+
+ type ConsolidationSnapshotRow = (
+ String,
+ String,
+ String,
+ String,
+ String,
+ String,
+ String,
+ String,
+ );
+
+ #[derive(Debug, PartialEq, Eq)]
+ struct StorageSnapshot {
+ memories: Vec<(String, String)>,
+ fts: Vec<(String, String, String, String, String)>,
+ operation_claims: Vec<(Vec, String)>,
+ redaction_tombstones: Vec,
+ consolidations: Vec,
+ }
+
+ fn storage_snapshot(connection: &Connection) -> StorageSnapshot {
+ let memories = {
+ let mut statement = connection
+ .prepare("SELECT id, raw_json FROM memories ORDER BY id")
+ .unwrap();
+ statement
+ .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
+ .unwrap()
+ .map(Result::unwrap)
+ .collect()
+ };
+ let fts = {
+ let mut statement = connection
+ .prepare(
+ "SELECT id, summary, details, tags, source_ref FROM memory_fts ORDER BY id",
+ )
+ .unwrap();
+ statement
+ .query_map([], |row| {
+ Ok((
+ row.get(0)?,
+ row.get(1)?,
+ row.get(2)?,
+ row.get(3)?,
+ row.get(4)?,
+ ))
+ })
+ .unwrap()
+ .map(Result::unwrap)
+ .collect()
+ };
+ let operation_claims = {
+ let mut statement = connection
+ .prepare(
+ "SELECT namespace_hash, memory_id FROM operation_claims ORDER BY memory_id",
+ )
+ .unwrap();
+ statement
+ .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
+ .unwrap()
+ .map(Result::unwrap)
+ .collect()
+ };
+ let redaction_tombstones = {
+ let mut statement = connection
+ .prepare("SELECT memory_id FROM redaction_tombstones ORDER BY memory_id")
+ .unwrap();
+ statement
+ .query_map([], |row| row.get(0))
+ .unwrap()
+ .map(Result::unwrap)
+ .collect()
+ };
+ let consolidations = {
+ let mut statement = connection
+ .prepare(
+ r#"
+ SELECT id, created_at, period_type, period_key,
+ source_memory_ids_json, output_memory_ids_json, status, notes
+ FROM consolidations
+ ORDER BY id
+ "#,
+ )
+ .unwrap();
+ statement
+ .query_map([], |row| {
+ Ok((
+ row.get(0)?,
+ row.get(1)?,
+ row.get(2)?,
+ row.get(3)?,
+ row.get(4)?,
+ row.get(5)?,
+ row.get(6)?,
+ row.get(7)?,
+ ))
+ })
+ .unwrap()
+ .map(Result::unwrap)
+ .collect()
+ };
+ StorageSnapshot {
+ memories,
+ fts,
+ operation_claims,
+ redaction_tombstones,
+ consolidations,
+ }
+ }
+
fn expired_maintenance_memory(summary: &str, retention: &str, ring: &str) -> MemoryEvent {
let mut event = MemoryEvent::new(summary, "lesson").unwrap();
event.retention = retention.to_string();
@@ -3256,6 +4332,27 @@ mod tests {
event
}
+ fn put_legacy_event_unchecked_for_testing(store: &mut SQLiteMemoryStore, event: &MemoryEvent) {
+ let transaction = store
+ .connection
+ .transaction_with_behavior(TransactionBehavior::Immediate)
+ .unwrap();
+ {
+ let mut insert_memory = transaction.prepare(write::UPSERT_MEMORY_SQL).unwrap();
+ let mut delete_fts = transaction
+ .prepare("DELETE FROM memory_fts WHERE id = ?")
+ .unwrap();
+ let mut insert_fts = transaction
+ .prepare(
+ "INSERT INTO memory_fts (id, summary, details, tags, source_ref) VALUES (?, ?, ?, ?, ?)",
+ )
+ .unwrap();
+ write::put_with_statements(event, &mut insert_memory, &mut delete_fts, &mut insert_fts)
+ .unwrap();
+ }
+ transaction.commit().unwrap();
+ }
+
fn create_legacy_database(path: &Path) {
let legacy = Connection::open(path).unwrap();
legacy
diff --git a/crates/tree-ring-memory-sqlite/src/policy.rs b/crates/tree-ring-memory-sqlite/src/policy.rs
new file mode 100644
index 0000000..4ec772a
--- /dev/null
+++ b/crates/tree-ring-memory-sqlite/src/policy.rs
@@ -0,0 +1,690 @@
+use std::{collections::HashSet, fmt};
+
+use rusqlite::{params, Connection, OptionalExtension};
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use tree_ring_memory_core::models::{
+ now_iso, sqlite_error, MemoryEvent, TreeRingError, TreeRingResult,
+};
+use tree_ring_memory_core::SensitivityGuard;
+use uuid::Uuid;
+
+use crate::sqlite_error_from_rusqlite;
+
+const POLICY_SINGLETON_ID: i64 = 1;
+const MAX_CONTEXT_VALUE_LENGTH: usize = 256;
+const MAX_CAPABILITY_LENGTH: usize = 4096;
+const CAPABILITY_HASH_DOMAIN: &[u8] = b"tree-ring-coordinator-capability-v1";
+const AUDIT_TARGET_HASH_DOMAIN: &[u8] = b"tree-ring-authorization-audit-target-v1";
+
+#[derive(Clone, PartialEq, Eq)]
+pub struct WriteContext {
+ pub(crate) actor_profile: Option,
+ pub(crate) capability_hash: Option<[u8; 32]>,
+ pub(crate) origin: String,
+}
+
+impl WriteContext {
+ pub fn new(
+ actor_profile: Option,
+ capability: Option<&str>,
+ origin: impl Into,
+ ) -> TreeRingResult {
+ if let Some(actor_profile) = actor_profile.as_deref() {
+ validate_context_value("actor_profile", actor_profile)?;
+ }
+ let origin = origin.into();
+ validate_context_value("audit origin", &origin)?;
+ let capability_hash = capability
+ .map(|capability| {
+ if capability.is_empty() {
+ return Err(TreeRingError::Validation(
+ "coordinator capability cannot be empty".to_string(),
+ ));
+ }
+ if capability.len() > MAX_CAPABILITY_LENGTH {
+ return Err(TreeRingError::Validation(format!(
+ "coordinator capability must be at most {MAX_CAPABILITY_LENGTH} bytes"
+ )));
+ }
+ Ok(coordinator_capability_hash(capability))
+ })
+ .transpose()?;
+ Ok(Self {
+ actor_profile,
+ capability_hash,
+ origin,
+ })
+ }
+
+ pub(crate) fn anonymous() -> Self {
+ Self {
+ actor_profile: None,
+ capability_hash: None,
+ origin: "anonymous".to_string(),
+ }
+ }
+}
+
+impl fmt::Debug for WriteContext {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("WriteContext")
+ .field("actor_profile", &self.actor_profile)
+ .field("has_capability", &self.capability_hash.is_some())
+ .field("origin", &self.origin)
+ .finish()
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum PolicyMode {
+ Open,
+ Coordinated,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PolicyStatus {
+ pub mode: PolicyMode,
+ pub coordinator_label: Option,
+ pub enabled_at: Option,
+ pub updated_at: String,
+}
+
+#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PolicyGrant {
+ pub capability: String,
+ pub status: PolicyStatus,
+}
+
+impl fmt::Debug for PolicyGrant {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("PolicyGrant")
+ .field("capability", &"[REDACTED]")
+ .field("status", &self.status)
+ .finish()
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AuthorizationAuditEvent {
+ pub id: i64,
+ pub created_at: String,
+ pub action: String,
+ pub decision: String,
+ pub reason: String,
+ pub actor_profile: Option,
+ pub origin: String,
+ pub target_memory_id: Option,
+}
+
+#[derive(Debug)]
+pub(crate) enum AuthorizationOutcome {
+ Allowed,
+ Denied(TreeRingError),
+}
+
+pub(crate) fn create_policy_schema(connection: &Connection) -> TreeRingResult<()> {
+ connection
+ .execute_batch(
+ r#"
+ CREATE TABLE IF NOT EXISTS store_policy (
+ id INTEGER PRIMARY KEY NOT NULL CHECK(id = 1),
+ mode TEXT NOT NULL CHECK(mode IN ('open', 'coordinated')),
+ capability_hash BLOB
+ CHECK(capability_hash IS NULL OR length(capability_hash) = 32),
+ coordinator_label TEXT,
+ enabled_at TEXT,
+ updated_at TEXT NOT NULL,
+ CHECK(
+ (mode = 'open' AND capability_hash IS NULL)
+ OR (mode = 'coordinated' AND capability_hash IS NOT NULL)
+ )
+ );
+ CREATE TABLE IF NOT EXISTS authorization_audit (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ created_at TEXT NOT NULL,
+ action TEXT NOT NULL,
+ decision TEXT NOT NULL CHECK(decision IN ('allowed', 'denied')),
+ reason TEXT NOT NULL,
+ actor_profile TEXT,
+ origin TEXT NOT NULL,
+ target_memory_id TEXT
+ );
+ CREATE INDEX IF NOT EXISTS idx_authorization_audit_created_at
+ ON authorization_audit(created_at DESC, id DESC);
+ "#,
+ )
+ .map_err(sqlite_error_from_rusqlite)?;
+ let now = now_iso();
+ connection
+ .execute(
+ r#"
+ INSERT OR IGNORE INTO store_policy (
+ id, mode, capability_hash, coordinator_label, enabled_at, updated_at
+ ) VALUES (?, 'open', NULL, NULL, NULL, ?)
+ "#,
+ params![POLICY_SINGLETON_ID, now],
+ )
+ .map_err(sqlite_error_from_rusqlite)?;
+ Ok(())
+}
+
+pub(crate) fn policy_status_on_connection(connection: &Connection) -> TreeRingResult {
+ connection
+ .query_row(
+ r#"
+ SELECT mode, coordinator_label, enabled_at, updated_at
+ FROM store_policy
+ WHERE id = ?
+ "#,
+ params![POLICY_SINGLETON_ID],
+ |row| {
+ let mode: String = row.get(0)?;
+ let mode = match mode.as_str() {
+ "open" => PolicyMode::Open,
+ "coordinated" => PolicyMode::Coordinated,
+ _ => {
+ return Err(rusqlite::Error::FromSqlConversionFailure(
+ 0,
+ rusqlite::types::Type::Text,
+ format!("unknown store policy mode {mode:?}").into(),
+ ));
+ }
+ };
+ Ok(PolicyStatus {
+ mode,
+ coordinator_label: row.get(1)?,
+ enabled_at: row.get(2)?,
+ updated_at: row.get(3)?,
+ })
+ },
+ )
+ .map_err(sqlite_error_from_rusqlite)
+}
+
+pub(crate) fn policy_audit_on_connection(
+ connection: &Connection,
+ limit: usize,
+) -> TreeRingResult> {
+ let mut statement = connection
+ .prepare(
+ r#"
+ SELECT id, created_at, action, decision, reason, actor_profile, origin,
+ target_memory_id
+ FROM authorization_audit
+ ORDER BY id DESC
+ LIMIT ?
+ "#,
+ )
+ .map_err(sqlite_error_from_rusqlite)?;
+ let rows = statement
+ .query_map(params![limit as i64], |row| {
+ Ok(AuthorizationAuditEvent {
+ id: row.get(0)?,
+ created_at: row.get(1)?,
+ action: row.get(2)?,
+ decision: row.get(3)?,
+ reason: row.get(4)?,
+ actor_profile: row.get(5)?,
+ origin: row.get(6)?,
+ target_memory_id: row.get(7)?,
+ })
+ })
+ .map_err(sqlite_error_from_rusqlite)?;
+ rows.collect::, _>>()
+ .map_err(sqlite_error_from_rusqlite)
+}
+
+pub(crate) fn enable_policy(
+ connection: &Connection,
+ context: &WriteContext,
+ label: Option<&str>,
+) -> TreeRingResult> {
+ validate_optional_label(label)?;
+ let current = policy_row(connection)?;
+ if current.mode == PolicyMode::Coordinated {
+ insert_audit(
+ connection,
+ context,
+ "policy_enable",
+ "denied",
+ "coordinated_policy_already_enabled",
+ None,
+ )?;
+ return Ok(Err(TreeRingError::AuthorizationDenied(
+ "coordinated policy is already enabled; rotate the coordinator capability instead"
+ .to_string(),
+ )));
+ }
+
+ let capability = generated_coordinator_capability();
+ let capability_hash = coordinator_capability_hash(&capability);
+ let now = now_iso();
+ connection
+ .execute(
+ r#"
+ UPDATE store_policy
+ SET mode = 'coordinated',
+ capability_hash = ?,
+ coordinator_label = ?,
+ enabled_at = ?,
+ updated_at = ?
+ WHERE id = ?
+ "#,
+ params![
+ capability_hash.as_slice(),
+ label,
+ &now,
+ &now,
+ POLICY_SINGLETON_ID
+ ],
+ )
+ .map_err(sqlite_error_from_rusqlite)?;
+ insert_audit(
+ connection,
+ context,
+ "policy_enable",
+ "allowed",
+ "coordinated_policy_enabled",
+ None,
+ )?;
+ Ok(Ok(PolicyGrant {
+ capability,
+ status: policy_status_on_connection(connection)?,
+ }))
+}
+
+pub(crate) fn rotate_policy_capability(
+ connection: &Connection,
+ context: &WriteContext,
+ label: Option<&str>,
+) -> TreeRingResult> {
+ validate_optional_label(label)?;
+ let policy = policy_row(connection)?;
+ if policy.mode == PolicyMode::Open {
+ insert_audit(
+ connection,
+ context,
+ "policy_rotate",
+ "denied",
+ "coordinated_policy_not_enabled",
+ None,
+ )?;
+ return Ok(Err(TreeRingError::AuthorizationDenied(
+ "coordinated policy must be enabled before rotating its capability".to_string(),
+ )));
+ }
+ if let AuthorizationOutcome::Denied(error) =
+ authorize_with_policy(connection, context, "policy_rotate", None, &policy)?
+ {
+ return Ok(Err(error));
+ }
+
+ let capability = generated_coordinator_capability();
+ let capability_hash = coordinator_capability_hash(&capability);
+ let now = now_iso();
+ connection
+ .execute(
+ r#"
+ UPDATE store_policy
+ SET capability_hash = ?,
+ coordinator_label = COALESCE(?, coordinator_label),
+ updated_at = ?
+ WHERE id = ? AND mode = 'coordinated'
+ "#,
+ params![capability_hash.as_slice(), label, &now, POLICY_SINGLETON_ID],
+ )
+ .map_err(sqlite_error_from_rusqlite)?;
+ Ok(Ok(PolicyGrant {
+ capability,
+ status: policy_status_on_connection(connection)?,
+ }))
+}
+
+pub(crate) fn disable_policy(
+ connection: &Connection,
+ context: &WriteContext,
+) -> TreeRingResult> {
+ if let AuthorizationOutcome::Denied(error) =
+ authorize_coordinator_action(connection, context, "policy_disable", None)?
+ {
+ return Ok(Err(error));
+ }
+
+ let now = now_iso();
+ connection
+ .execute(
+ r#"
+ UPDATE store_policy
+ SET mode = 'open',
+ capability_hash = NULL,
+ coordinator_label = NULL,
+ enabled_at = NULL,
+ updated_at = ?
+ WHERE id = ?
+ "#,
+ params![now, POLICY_SINGLETON_ID],
+ )
+ .map_err(sqlite_error_from_rusqlite)?;
+ Ok(Ok(policy_status_on_connection(connection)?))
+}
+
+pub(crate) fn authorize_event_creates(
+ connection: &Connection,
+ context: &WriteContext,
+ action: &str,
+ events: &[&MemoryEvent],
+) -> TreeRingResult {
+ let policy = policy_row(connection)?;
+ if policy.mode == PolicyMode::Open || events.is_empty() {
+ return Ok(AuthorizationOutcome::Allowed);
+ }
+
+ let mut seen_memory_ids = HashSet::new();
+ let protected_target = events
+ .iter()
+ .find_map(|event| {
+ let existing = connection
+ .query_row(
+ "SELECT EXISTS(SELECT 1 FROM memories WHERE id = ?)",
+ params![&event.id],
+ |row| row.get::<_, bool>(0),
+ )
+ .map_err(sqlite_error_from_rusqlite);
+ let repeated_in_request = !seen_memory_ids.insert(event.id.as_str());
+ match existing {
+ Ok(true) => Some(Ok(event.id.as_str())),
+ Ok(false)
+ if !repeated_in_request && unprivileged_create_allowed(context, event) =>
+ {
+ None
+ }
+ Ok(false) => Some(Ok(event.id.as_str())),
+ Err(error) => Some(Err(error)),
+ }
+ })
+ .transpose()?;
+ if protected_target.is_none() {
+ return Ok(AuthorizationOutcome::Allowed);
+ }
+ authorize_with_policy(connection, context, action, protected_target, &policy)
+}
+
+pub(crate) fn authorize_coordinator_action(
+ connection: &Connection,
+ context: &WriteContext,
+ action: &str,
+ target_memory_id: Option<&str>,
+) -> TreeRingResult {
+ let policy = policy_row(connection)?;
+ if policy.mode == PolicyMode::Open {
+ return Ok(AuthorizationOutcome::Allowed);
+ }
+ authorize_with_policy(connection, context, action, target_memory_id, &policy)
+}
+
+fn authorize_with_policy(
+ connection: &Connection,
+ context: &WriteContext,
+ action: &str,
+ target_memory_id: Option<&str>,
+ policy: &PolicyRow,
+) -> TreeRingResult {
+ let supplied = context.capability_hash.as_ref();
+ let valid = supplied
+ .zip(policy.capability_hash.as_ref())
+ .is_some_and(|(supplied, expected)| constant_time_eq(supplied, expected));
+ if valid {
+ insert_audit(
+ connection,
+ context,
+ action,
+ "allowed",
+ "valid_coordinator_capability",
+ target_memory_id,
+ )?;
+ return Ok(AuthorizationOutcome::Allowed);
+ }
+
+ let reason = if supplied.is_some() {
+ "invalid_coordinator_capability"
+ } else {
+ "missing_coordinator_capability"
+ };
+ insert_audit(
+ connection,
+ context,
+ action,
+ "denied",
+ reason,
+ target_memory_id,
+ )?;
+ Ok(AuthorizationOutcome::Denied(
+ TreeRingError::AuthorizationDenied(
+ "coordinator capability required by coordinated store policy".to_string(),
+ ),
+ ))
+}
+
+fn insert_audit(
+ connection: &Connection,
+ context: &WriteContext,
+ action: &str,
+ decision: &str,
+ reason: &str,
+ target_memory_id: Option<&str>,
+) -> TreeRingResult<()> {
+ let target_memory_id = target_memory_id.map(normalize_audit_target);
+ connection
+ .execute(
+ r#"
+ INSERT INTO authorization_audit (
+ created_at, action, decision, reason, actor_profile, origin,
+ target_memory_id
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
+ "#,
+ params![
+ now_iso(),
+ action,
+ decision,
+ reason,
+ context.actor_profile.as_deref(),
+ &context.origin,
+ target_memory_id.as_deref(),
+ ],
+ )
+ .map_err(sqlite_error_from_rusqlite)?;
+ Ok(())
+}
+
+fn normalize_audit_target(value: &str) -> String {
+ let display_safe = !value.is_empty()
+ && value.len() <= MAX_CONTEXT_VALUE_LENGTH
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':'))
+ && SensitivityGuard::default().check_or_raise(value).is_ok();
+ if display_safe {
+ return value.to_string();
+ }
+
+ let mut hasher = Sha256::new();
+ hasher.update(AUDIT_TARGET_HASH_DOMAIN);
+ hasher.update([0]);
+ hasher.update((value.len() as u64).to_be_bytes());
+ hasher.update(value.as_bytes());
+ let digest: [u8; 32] = hasher.finalize().into();
+ let mut encoded = String::with_capacity(7 + digest.len() * 2);
+ encoded.push_str("sha256:");
+ const HEX: &[u8; 16] = b"0123456789abcdef";
+ for byte in digest {
+ encoded.push(HEX[(byte >> 4) as usize] as char);
+ encoded.push(HEX[(byte & 0x0f) as usize] as char);
+ }
+ encoded
+}
+
+#[derive(Debug)]
+struct PolicyRow {
+ mode: PolicyMode,
+ capability_hash: Option<[u8; 32]>,
+}
+
+fn policy_row(connection: &Connection) -> TreeRingResult {
+ let row = connection
+ .query_row(
+ "SELECT mode, capability_hash FROM store_policy WHERE id = ?",
+ params![POLICY_SINGLETON_ID],
+ |row| {
+ let mode: String = row.get(0)?;
+ let capability_hash: Option> = row.get(1)?;
+ Ok((mode, capability_hash))
+ },
+ )
+ .optional()
+ .map_err(sqlite_error_from_rusqlite)?
+ .ok_or_else(|| sqlite_error("store policy singleton row is missing"))?;
+ let mode = match row.0.as_str() {
+ "open" => PolicyMode::Open,
+ "coordinated" => PolicyMode::Coordinated,
+ other => return Err(sqlite_error(format!("unknown store policy mode {other:?}"))),
+ };
+ let capability_hash = row
+ .1
+ .map(|hash| {
+ hash.try_into()
+ .map_err(|_| sqlite_error("store policy capability hash must be 32 bytes"))
+ })
+ .transpose()?;
+ Ok(PolicyRow {
+ mode,
+ capability_hash,
+ })
+}
+
+fn unprivileged_create_allowed(context: &WriteContext, event: &MemoryEvent) -> bool {
+ event.scope == "agent"
+ && event.ring != "heartwood"
+ && context.actor_profile.as_deref() == event.agent_profile.as_deref()
+ && context.actor_profile.is_some()
+}
+
+fn validate_context_value(field: &str, value: &str) -> TreeRingResult<()> {
+ if value.trim().is_empty() {
+ return Err(TreeRingError::Validation(format!(
+ "{field} cannot be blank"
+ )));
+ }
+ if value.chars().count() > MAX_CONTEXT_VALUE_LENGTH {
+ return Err(TreeRingError::Validation(format!(
+ "{field} must be at most {MAX_CONTEXT_VALUE_LENGTH} characters"
+ )));
+ }
+ if value.chars().any(char::is_control) {
+ return Err(TreeRingError::Validation(format!(
+ "{field} cannot contain control characters"
+ )));
+ }
+ SensitivityGuard::default().check_or_raise(value)?;
+ Ok(())
+}
+
+fn validate_optional_label(label: Option<&str>) -> TreeRingResult<()> {
+ if let Some(label) = label {
+ validate_context_value("coordinator label", label)?;
+ }
+ Ok(())
+}
+
+fn generated_coordinator_capability() -> String {
+ format!(
+ "trcap_v1_{}{}",
+ Uuid::new_v4().simple(),
+ Uuid::new_v4().simple()
+ )
+}
+
+fn coordinator_capability_hash(capability: &str) -> [u8; 32] {
+ let mut hasher = Sha256::new();
+ hasher.update(CAPABILITY_HASH_DOMAIN);
+ hasher.update([0]);
+ hasher.update((capability.len() as u64).to_be_bytes());
+ hasher.update(capability.as_bytes());
+ hasher.finalize().into()
+}
+
+fn constant_time_eq(left: &[u8; 32], right: &[u8; 32]) -> bool {
+ left.iter()
+ .zip(right.iter())
+ .fold(0_u8, |difference, (left, right)| {
+ difference | (left ^ right)
+ })
+ == 0
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn write_context_debug_never_contains_capability_or_hash() {
+ let capability = "do-not-log-this-capability";
+ let context =
+ WriteContext::new(Some("agent-a".to_string()), Some(capability), "unit-test").unwrap();
+ let rendered = format!("{context:?}");
+ assert!(!rendered.contains(capability));
+ assert!(!rendered.contains(&format!("{:?}", context.capability_hash.unwrap())));
+ assert!(rendered.contains("has_capability: true"));
+ }
+
+ #[test]
+ fn policy_grant_debug_redacts_one_time_capability() {
+ let grant = PolicyGrant {
+ capability: "do-not-log-this-capability".to_string(),
+ status: PolicyStatus {
+ mode: PolicyMode::Coordinated,
+ coordinator_label: None,
+ enabled_at: Some("2026-01-01T00:00:00Z".to_string()),
+ updated_at: "2026-01-01T00:00:00Z".to_string(),
+ },
+ };
+ let rendered = format!("{grant:?}");
+ assert!(!rendered.contains(&grant.capability));
+ assert!(rendered.contains("[REDACTED]"));
+ }
+
+ #[test]
+ fn write_context_limits_are_measured_in_unicode_characters() {
+ let boundary = "é".repeat(MAX_CONTEXT_VALUE_LENGTH);
+ WriteContext::new(Some(boundary), None, "unit-test").unwrap();
+
+ let oversized = "é".repeat(MAX_CONTEXT_VALUE_LENGTH + 1);
+ let error = WriteContext::new(Some(oversized), None, "unit-test").unwrap_err();
+ assert!(matches!(
+ error,
+ TreeRingError::Validation(message)
+ if message.contains("256 characters")
+ ));
+ }
+
+ #[test]
+ fn audit_targets_preserve_safe_ids_and_hash_unsafe_or_secret_values() {
+ assert_eq!(normalize_audit_target("mem_safe-123"), "mem_safe-123");
+
+ let unsafe_values = [
+ "bad\nid\u{1b}[31m".to_string(),
+ format!("trcap_v1_{}", "a".repeat(64)),
+ "x".repeat(MAX_CONTEXT_VALUE_LENGTH + 1),
+ ];
+ for unsafe_value in &unsafe_values {
+ let normalized = normalize_audit_target(unsafe_value);
+ assert!(normalized.starts_with("sha256:"));
+ assert_eq!(normalized.len(), 71);
+ assert!(!normalized.contains(unsafe_value));
+ assert!(!normalized.chars().any(char::is_control));
+ }
+ }
+}
diff --git a/crates/tree-ring-memory-sqlite/src/schema.rs b/crates/tree-ring-memory-sqlite/src/schema.rs
index 1b80a91..3dedc77 100644
--- a/crates/tree-ring-memory-sqlite/src/schema.rs
+++ b/crates/tree-ring-memory-sqlite/src/schema.rs
@@ -1,16 +1,24 @@
-use rusqlite::{Connection, OpenFlags};
+use rusqlite::{
+ functions::{Context, FunctionFlags},
+ Connection, OpenFlags,
+};
use std::path::Path;
use tree_ring_memory_core::models::{sqlite_error, TreeRingResult};
-use crate::sqlite_error_from_rusqlite;
+use crate::{sqlite_error_from_rusqlite, write, SQLITE_SCHEMA_VERSION};
+
+pub(crate) const WRITER_PROTOCOL_VERSION: i64 = 3;
+pub(crate) const WRITER_PROTOCOL_FUNCTION: &str = "tree_ring_writer_protocol";
pub(crate) fn open_connection(path: &Path) -> TreeRingResult {
if let Some(parent) = parent_dir_to_create(path) {
std::fs::create_dir_all(parent).map_err(|err| sqlite_error(err.to_string()))?;
}
let connection = Connection::open(path).map_err(sqlite_error_from_rusqlite)?;
- configure_writable_connection(&connection)?;
+ register_writer_protocol(&connection)?;
+ ensure_supported_schema_version(&connection)?;
+ write::retry_locked(|| configure_writable_connection(&connection))?;
Ok(connection)
}
@@ -25,6 +33,8 @@ pub(crate) fn open_read_only_connection(path: &Path) -> TreeRingResult TreeRingResult<()>
Ok(())
}
+fn register_writer_protocol(connection: &Connection) -> TreeRingResult<()> {
+ connection
+ .create_scalar_function(
+ WRITER_PROTOCOL_FUNCTION,
+ 0,
+ FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC,
+ |_context: &Context<'_>| Ok(WRITER_PROTOCOL_VERSION),
+ )
+ .map_err(sqlite_error_from_rusqlite)
+}
+
+pub(crate) fn ensure_supported_schema_version(connection: &Connection) -> TreeRingResult {
+ let version = user_version(connection)?;
+ if version > SQLITE_SCHEMA_VERSION {
+ return Err(sqlite_error(format!(
+ "unsupported SQLite schema version {version}; this build supports up to version {SQLITE_SCHEMA_VERSION}"
+ )));
+ }
+ Ok(version)
+}
+
pub(crate) fn parent_dir_to_create(path: &Path) -> Option<&Path> {
path.parent()
.filter(|parent| !parent.as_os_str().is_empty())
diff --git a/crates/tree-ring-memory-sqlite/src/write.rs b/crates/tree-ring-memory-sqlite/src/write.rs
index 06f807f..a02b68d 100644
--- a/crates/tree-ring-memory-sqlite/src/write.rs
+++ b/crates/tree-ring-memory-sqlite/src/write.rs
@@ -3,6 +3,7 @@ use sha2::{Digest, Sha256};
use std::time::Duration;
use tree_ring_memory_core::models::{MemoryEvent, TreeRingError, TreeRingResult};
+use tree_ring_memory_core::SensitivityGuard;
use crate::search;
use crate::sqlite_error_from_rusqlite;
@@ -107,6 +108,7 @@ pub(crate) fn supersede_in_transaction(
old_id: &str,
new_id: &str,
) -> TreeRingResult {
+ SensitivityGuard::default().check_or_raise(new_id)?;
let Some(mut event) = transaction
.query_row(
"SELECT raw_json FROM memories WHERE id = ?",
@@ -157,7 +159,7 @@ pub(crate) fn prepare_memory_write(
transaction: &Transaction<'_>,
event: &MemoryEvent,
) -> TreeRingResult<()> {
- event.validate()?;
+ validate_memory_for_storage(event)?;
let tombstoned: bool = transaction
.query_row(
"SELECT EXISTS(SELECT 1 FROM redaction_tombstones WHERE memory_id = ?)",
@@ -187,6 +189,11 @@ pub(crate) fn prepare_memory_write(
ensure_operation_claim_available(transaction, event)
}
+pub(crate) fn validate_memory_for_storage(event: &MemoryEvent) -> TreeRingResult<()> {
+ SensitivityGuard::default().detect_memory_event_sensitivity(event)?;
+ event.validate()
+}
+
pub(crate) fn is_redaction_tombstoned(
transaction: &Transaction<'_>,
memory_id: &str,
diff --git a/docs/architecture/rust-core-status.md b/docs/architecture/rust-core-status.md
index f4918c2..8c241f7 100644
--- a/docs/architecture/rust-core-status.md
+++ b/docs/architecture/rust-core-status.md
@@ -8,7 +8,9 @@ Python-runtime removal, v0.9 removal of tracked Python source and optional
CPython bindings from the canonical repo, v0.10 installer/onboarding work, and
v0.11 Rust-native source adapters plus framework discovery. The v0.12 line now
also carries explicit same-host multi-agent correlation, partitioning, and
-idempotent-write semantics.
+idempotent-write semantics. The v0.13 line adds an opt-in coordinated write
+policy, protected-write audit, schema-v3 old-memory-mutation fence, and
+forward-schema rejection.
## Current Status
@@ -18,20 +20,38 @@ idempotent-write semantics.
- The portable event model includes optional `workflow_id`, `session_id`, and
`operation_id` alongside `agent_profile`. Agent, workflow, and session scopes
require their matching partition identifier. Scope is routing metadata, not
- an authorization boundary.
-- Rust SQLite crate owns schema-compatible SQLite/FTS storage.
+ a read-authorization boundary.
+- Rust SQLite crate owns schema-compatible SQLite/FTS storage plus `Open` and
+ opt-in `Coordinated` store-policy modes.
+- In Coordinated mode, ordinary contexts may only create non-heartwood
+ agent-scoped events that exactly match their `WriteContext` actor.
+ Shared/non-agent writes, heartwood, imports, persisted DOX/Revolve sync,
+ consolidation, ring/lifecycle mutations, and applied maintenance require a
+ coordinator capability supplied through `TREE_RING_COORDINATOR_TOKEN`.
+- Coordinator capabilities are printed once on enable/rotation, stored only as
+ hashes, never accepted as CLI flags, and never included in policy status or
+ audit output. They must be excluded from ordinary worker environments.
+ Protected allowed/denied decisions are recorded transactionally.
+- Schema v3 registers a writer-protocol function on official connections and
+ fences inserts, updates, and deletes from old v0.12 writers. Writable and
+ read-only opens reject schema versions newer than the current binary.
- SQLite migration and read boundaries normalize identity-less pre-0.12 private
scopes into deterministic per-record legacy partitions marked for review.
Redaction retains a memory-ID tombstone, and replaced operation namespaces
remain claimed until explicit hard deletion.
- Rust CLI owns the full local command surface: init, remember, evidence,
recall, forget, import/export, audit, consolidate, maintain, DOX sync,
- Revolve sync, framework discovery, welcome onboarding, and TUI operation.
+ Revolve sync, coordinated policy management, framework discovery, welcome
+ onboarding, and TUI operation.
- Rust CLI has JSON output for machine-readable adapter use.
- Remember, evidence, recall, and consolidation expose agent/workflow/session
context through CLI flags; agent profile, workflow ID, and session ID also
have `TREE_RING_*` environment defaults. Exact operation retries are stable,
while conflicting operation-key reuse fails closed.
+- `tree-ring policy enable --coordinator `, `status`, `rotate`,
+ `disable`, and `audit` expose the store policy without putting the capability
+ on the command line. Status and audit are read-only; rotation and disable
+ require the current environment capability.
- CLI and TUI durable operations now share action request/report contracts for
behavior-preserving command execution. This keeps CLI output ownership, TUI
state/render ownership, and storage ownership separate while preparing the
@@ -66,6 +86,9 @@ idempotent-write semantics.
real time. The ambient HUD stays portable while richer terminal image protocols
can be added for welcome or expanded views without replacing the live HUD
renderer.
+- TUI `--agent-profile` and `TREE_RING_AGENT_PROFILE` bind `/remember` to
+ agent scope. In Coordinated mode, lifecycle actions require the coordinator
+ capability inherited through `TREE_RING_COORDINATOR_TOKEN`.
- The repository includes `install.sh` for one-line global or project-local
installs, plus `tree-ring welcome` for first-run terminal onboarding.
- The Rust CLI includes `tree-ring dox sync` and `tree-ring revolve sync` as
@@ -114,11 +137,38 @@ public binary as a coordinator would:
- An exact operation retry returns the original memory ID, conflicting reuse
exits nonzero, and the JSON maintenance report proves exact memory-row/FTS
parity with no missing or orphan rows.
+- Concurrent unprivileged workers are denied when they attempt shared
+ publication or promotion, while matching non-heartwood agent-scoped creates
+ continue to succeed.
+- A coordinator capability permits the protected publication/promotion, its
+ rotation invalidates the old capability, and policy audit reports both
+ allowed and denied decisions without exposing either token.
This is bounded evidence for concurrent processes sharing a local SQLite store
on one host. It is not evidence for sustained load, fairness, abrupt-process
-crash recovery, cross-host coordination, NFS/network-filesystem safety, or a
-distributed lock service.
+crash recovery, adversarial local-file access, cross-host coordination,
+NFS/network-filesystem safety, or a distributed lock service.
+
+## v0.13 Schema-v3 Upgrade Boundary
+
+Before first opening an existing root with v0.13:
+
+1. Stop every Tree Ring CLI, TUI, plugin, and bundled worker that uses it.
+2. Checkpoint SQLite WAL state and create a verified store backup.
+3. Upgrade every CLI, plugin, and bundled worker.
+4. Reopen the root with v0.13 to apply schema v3.
+
+Do not use v0.12 on an upgraded root. The schema-v3 trigger fence rejects memory
+inserts, updates, and deletes from old writers; all mixed-version operation is
+unsupported even if an older process appears able to read or run maintenance.
+Roll back only by stopping every process and restoring the complete pre-upgrade
+backup.
+
+Coordinated mode is an optional operational authorization boundary in official
+Rust/CLI write paths. Open remains the backward-compatible default. Neither
+mode is a read ACL or protection from an adversary who controls the SQLite
+files or process environment, and neither broadens the same-host/local-filesystem
+contract.
## Build Commands
@@ -133,6 +183,7 @@ cargo run -p tree-ring-memory-cli -- import --help
cargo run -p tree-ring-memory-cli -- audit --help
cargo run -p tree-ring-memory-cli -- consolidate --help
cargo run -p tree-ring-memory-cli -- maintain --help
+cargo run -p tree-ring-memory-cli -- policy --help
cargo run -p tree-ring-memory-cli -- dox sync --help
cargo run -p tree-ring-memory-cli -- revolve sync --help
cargo run -p tree-ring-memory-cli -- integrations scan --help
@@ -146,14 +197,17 @@ sh scripts/certify-tree-ring.sh
SQLite/FTS storage, transactional row/FTS consistency, redaction, JSONL
import/export filtering and duplicate handling, deterministic audit checks,
deterministic consolidation planning, maintenance planning/application, FTS
- repair, deterministic operation idempotency, and concurrent writes.
+ repair, deterministic operation idempotency, concurrent writes,
+ coordinated-policy authorization/audit, schema-version rejection, and the
+ old-memory-mutation fence.
- Rust CLI tests cover the scriptable init/remember/recall/forget commands and
JSONL import/export/audit/consolidate commands plus the Ratatui TUI model,
stream reader, slash-command parser, store-watch refresh, confirmation-gated
actions, DOX/Revolve sync commands, framework discovery, CLI parsing, and
render-buffer smoke. The process-level multi-agent acceptance test adds
deterministic write-lock contention, routing-filter isolation, idempotency
- conflict handling, and row/FTS parity through the public CLI.
+ conflict handling, concurrent authorization denials, coordinator
+ allow/rotation/audit behavior, and row/FTS parity through the public CLI.
- `crates/tree-ring-memory-sqlite/examples/performance_smoke.rs` provides an
operator-run local insert and recall timing check. It fails if expected
recalls are empty, emits a stable `METRICS_JSON=` line, and enforces
diff --git a/docs/feed.xml b/docs/feed.xml
index f5953cb..28dccfc 100644
--- a/docs/feed.xml
+++ b/docs/feed.xml
@@ -5,12 +5,20 @@
https://terminallylazy.github.io/Tree-Ring-Memory/
- 2026-07-15T22:09:47Z
+ 2026-07-23T20:26:15Z
Tree Ring Memory
https://github.com/TerminallyLazy/Tree-Ring-Memory
+
+ Tree Ring Memory v0.13.0 adds same-host multi-agent guardrails
+ https://github.com/TerminallyLazy/Tree-Ring-Memory/releases/tag/v0.13.0
+
+ 2026-07-23T20:26:15Z
+ Tree Ring Memory v0.13.0 adds same-host agent, workflow, session, and operation correlation; exact-retry idempotency; and opt-in coordinator authorization for shared writes and lifecycle mutations. Schema v3 fences memory inserts, updates, and deletes from old writers; all mixed-version operation is unsupported. These guarantees cover cooperative processes sharing local SQLite on one host, not distributed storage or a read ACL.
+
+
Tree Ring Memory v0.12.0 workflow-proof preview is live
https://github.com/TerminallyLazy/Tree-Ring-Memory/releases/tag/v0.12.0
diff --git a/docs/index.html b/docs/index.html
index f9e273b..080b189 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -28,7 +28,7 @@
"programmingLanguage": "Rust",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "macOS, Linux",
- "version": "0.12.0",
+ "version": "0.13.0",
"image": "https://terminallylazy.github.io/Tree-Ring-Memory/assets/tree-ring-memory-og.png",
"isAccessibleForFree": true
}
diff --git a/docs/integrations/agent-skill.md b/docs/integrations/agent-skill.md
index 97cd18f..0a79f73 100644
--- a/docs/integrations/agent-skill.md
+++ b/docs/integrations/agent-skill.md
@@ -169,9 +169,71 @@ migration/import and marked for review. They remain privately partitioned and
portable instead of being widened into project/global scope.
The identifiers must be nonblank, contain no control characters, and stay at or
-below 256 characters. These fields are routing and correlation metadata, not an
-authorization boundary. A process with filesystem access to the SQLite store
-can read it, so host permissions still control access.
+below 256 characters. These fields are routing and correlation metadata, not a
+read-authorization boundary. A process with filesystem access to the SQLite
+store can read it, so host permissions still control access.
+
+### Coordinated Write Authorization
+
+Stores remain in backward-compatible Open mode unless a coordinator explicitly
+opts in. Enable Coordinated mode for a shared root when ordinary fan-out workers
+should publish only to their own agent partitions:
+
+```bash
+tree-ring --root .tree-ring policy enable --coordinator release-coordinator
+export TREE_RING_COORDINATOR_TOKEN=''
+tree-ring --root .tree-ring policy status
+tree-ring --root .tree-ring policy audit --limit 100
+```
+
+Enable prints the capability exactly once. Supply it only through
+`TREE_RING_COORDINATOR_TOKEN`; there is no token CLI flag. Do not put the token
+in memory, a source ref, a log, a retained command, or a committed file. The
+store keeps only its hash, and policy status/audit never reveal it.
+Inject the variable only into coordinator processes. Ensure every ordinary
+worker is launched with `TREE_RING_COORDINATOR_TOKEN` unset so authority is not
+inherited through fan-out.
+
+In Coordinated mode, an ordinary worker may only create a non-heartwood
+`scope=agent` event whose `agent_profile` matches the `WriteContext` actor. The
+CLI constructs that actor from `--agent-profile` or
+`TREE_RING_AGENT_PROFILE`. Everything else that can publish shared state or
+mutate lifecycle state requires the coordinator capability:
+
+- project, global, workflow, session, or other non-agent creates
+- all heartwood creates and promotions
+- JSONL import and persisted DOX/Revolve sync
+- persisted consolidation
+- ring changes, supersede, delete, and redact
+- applied expiry/secret maintenance and FTS repair
+
+Recall, export, policy status/audit, adapter dry-runs, consolidation dry-runs,
+and report-only maintenance remain read-only. Protected allow and deny
+decisions are written to the policy audit trail without the plaintext token.
+
+Rotate the capability while the current token is in the environment, then
+replace it immediately with the newly printed capability:
+
+```bash
+tree-ring --root .tree-ring policy rotate --coordinator release-coordinator-next
+export TREE_RING_COORDINATOR_TOKEN=''
+tree-ring --root .tree-ring policy disable
+unset TREE_RING_COORDINATOR_TOKEN
+```
+
+The old capability stops authorizing writes after rotation. Disabling also
+requires the current capability and returns the store to Open mode.
+
+For TUI workers, pass `--agent-profile ` to
+`tree-ring --root .tree-ring tui` or set `TREE_RING_AGENT_PROFILE`.
+`/remember` then defaults to `scope=agent` for that worker. Without a profile,
+`/remember` keeps its Open-mode global default and an unprivileged
+Coordinated-mode write is denied. Promote/scar/seed, supersede, forget/redact,
+and persisted consolidation actions require `TREE_RING_COORDINATOR_TOKEN`.
+
+This is operational authorization in official Rust/CLI write paths. It is not
+a read ACL, an OS security boundary, or protection from an adversary who
+controls the local files or process environment.
### Runtime Boundary And Evidence
@@ -187,9 +249,26 @@ evidence-preserving coordinator when work spans hosts.
process-level acceptance test. It launches eight real CLI writers against one
root, exercises profile/workflow/session/scope recall filters, verifies exact
retry and conflicting-key behavior, and checks memory-row/FTS parity through
-the JSON maintenance report. It is evidence for the same-host contract only;
-it is not a sustained-load, crash-recovery, fairness, or distributed-storage
-certification.
+the JSON maintenance report. Its policy phase races concurrent unauthorized
+shared writes, proves ordinary agent-partitioned creates still work, verifies a
+coordinator can publish and promote, rotates the capability, and checks allowed
+and denied audit records. It is evidence for the same-host contract only; it is
+not a sustained-load, crash-recovery, fairness, adversarial-local-user, or
+distributed-storage certification.
+
+### v0.13 Schema-v3 Upgrade
+
+Before any v0.13 process opens an existing root:
+
+1. Stop every Tree Ring CLI, TUI, plugin, and bundled worker using it.
+2. Checkpoint SQLite WAL state and make a verified backup of the store.
+3. Upgrade every CLI, plugin, and bundled worker.
+4. Reopen with v0.13 to migrate the root to schema v3.
+
+Schema v3 fences memory inserts, updates, and deletes from old writers. Do not
+use v0.12 on that root: all mixed-version operation is unsupported even if an
+older read or maintenance command appears to work. Roll back only by stopping
+all processes and restoring the complete pre-upgrade backup.
## Evidence-Driven Improvement
@@ -251,7 +330,9 @@ Revolve adapter rules:
- Ignore outcome-free files as durable truth.
Run `--dry-run` first, inspect the generated memories, then rerun without
-`--dry-run` only when the summaries are useful and source-linked.
+`--dry-run` only when the summaries are useful and source-linked. In
+Coordinated mode, that persisted rerun requires
+`TREE_RING_COORDINATOR_TOKEN`; the dry-run does not.
## Agent Harness Notes
diff --git a/docs/llms.txt b/docs/llms.txt
index aea70a8..0dd2c0f 100644
--- a/docs/llms.txt
+++ b/docs/llms.txt
@@ -5,14 +5,14 @@
Website: https://terminallylazy.github.io/Tree-Ring-Memory/
Repository: https://github.com/TerminallyLazy/Tree-Ring-Memory
-Launch release: https://github.com/TerminallyLazy/Tree-Ring-Memory/releases/tag/v0.12.0
+Launch release: https://github.com/TerminallyLazy/Tree-Ring-Memory/releases/tag/v0.13.0
Launch discussion: https://github.com/TerminallyLazy/Tree-Ring-Memory/discussions/27
Homebrew tap: https://github.com/TerminallyLazy/homebrew-tree-ring
Feedback: https://github.com/TerminallyLazy/Tree-Ring-Memory/issues/26
Feed: https://terminallylazy.github.io/Tree-Ring-Memory/feed.xml
License: MIT
Status: protocol-preview
-Current version: 0.12.0
+Current version: 0.13.0
## Summary
@@ -31,6 +31,8 @@ ideas stay as seeds.
- Deterministic consolidation without requiring an LLM.
- Source-linked evaluated outcomes through `tree-ring evidence`.
- Controlled workflow-proof artifacts with explicit model identity and exact structured-output checks.
+- Same-host multi-agent identity, scoped recall, and exact-retry idempotency.
+- Optional coordinator authorization for shared writes and lifecycle mutations.
- DOX and Revolve sync adapters.
- Read-only agent-framework discovery.
- Terminal onboarding and a Ratatui operator console.
@@ -80,7 +82,7 @@ writes are explicit.
- Rust article: https://terminallylazy.github.io/Tree-Ring-Memory/launch/rust-native-agent-memory-cli.md
- Press kit: https://terminallylazy.github.io/Tree-Ring-Memory/press-kit.md
- Repository: https://github.com/TerminallyLazy/Tree-Ring-Memory
-- Launch release: https://github.com/TerminallyLazy/Tree-Ring-Memory/releases/tag/v0.12.0
+- Launch release: https://github.com/TerminallyLazy/Tree-Ring-Memory/releases/tag/v0.13.0
- Launch discussion: https://github.com/TerminallyLazy/Tree-Ring-Memory/discussions/27
- Homebrew tap: https://github.com/TerminallyLazy/homebrew-tree-ring
- Feedback issue: https://github.com/TerminallyLazy/Tree-Ring-Memory/issues/26
diff --git a/docs/press-kit.md b/docs/press-kit.md
index 638f6d9..63e403e 100644
--- a/docs/press-kit.md
+++ b/docs/press-kit.md
@@ -23,10 +23,10 @@ DOX/Revolve adapters, framework discovery, and a terminal TUI.
- Category: AI agents, developer tools, local-first software, Rust CLI
- License: MIT
- Status: protocol-preview
-- Current version: 0.12.0
+- Current version: 0.13.0
- Website:
- Repository:
-- Launch release:
+- Launch release:
- Launch discussion:
- Homebrew tap:
- Feedback:
@@ -45,6 +45,8 @@ Agent memory should age, not pile up.
- Deterministic consolidation without requiring an LLM.
- Source-linked evaluated outcomes through `tree-ring evidence`.
- Controlled workflow-proof artifacts with explicit model identity and exact structured-output checks.
+- Same-host multi-agent identity, scoped recall, and exact-retry idempotency.
+- Optional coordinator authorization for shared writes and lifecycle mutations.
- DOX and Revolve sync adapters.
- Terminal onboarding and Ratatui operator console.
diff --git a/docs/protocol/memory-event.md b/docs/protocol/memory-event.md
index 98da38c..dfbd8cf 100644
--- a/docs/protocol/memory-event.md
+++ b/docs/protocol/memory-event.md
@@ -35,9 +35,65 @@ Scope establishes a partition invariant:
| `global` | None | Deliberate cross-project memory |
Other supported scopes retain their existing meanings. Scope and identity are
-routing metadata, not an ACL or authentication boundary. Any process with
+routing metadata, not a read ACL or authentication boundary. Any process with
filesystem access to the local store can issue unfiltered recall.
+## Store Write Policy
+
+Write authorization is store-local rather than part of the portable
+`MemoryEvent` JSON. Stores default to `Open`, preserving the existing single
+agent and trusted-process behavior. A coordinator can explicitly enable
+`Coordinated` mode:
+
+```bash
+tree-ring policy enable --coordinator
+export TREE_RING_COORDINATOR_TOKEN=''
+tree-ring policy status
+tree-ring policy audit --limit 100
+```
+
+The capability is accepted only through `TREE_RING_COORDINATOR_TOKEN`; it is
+never a CLI flag or event field. The store persists only a hash. Enable and
+rotate return the plaintext capability once, while status and audit never
+return it. Coordinators must keep the variable out of ordinary worker
+environments; a fan-out child that inherits the valid token inherits
+coordinator write authority.
+
+Official Rust writers open the store with a `WriteContext` containing an
+optional actor profile, an optional coordinator capability, and a bounded audit
+origin. In Coordinated mode, an unauthenticated context may only create a
+non-heartwood event when:
+
+- the event has `scope=agent`
+- the event has a nonblank `agent_profile`
+- that profile exactly matches the `WriteContext` actor
+
+A valid coordinator capability is required for non-agent/shared creates,
+heartwood, import, persisted DOX/Revolve adapter writes, persisted
+consolidation, ring changes, supersession, deletion, redaction, and applied
+maintenance/FTS repair. Authorization is checked inside the same immediate
+transaction as the protected mutation. Denied mutations leave memory, FTS, and
+operation/tombstone state unchanged; allowed and denied protected decisions are
+recorded in the store-local authorization audit without the plaintext token.
+
+Rotate and disable require the current capability:
+
+```bash
+tree-ring policy rotate --coordinator
+export TREE_RING_COORDINATOR_TOKEN=''
+tree-ring policy disable
+unset TREE_RING_COORDINATOR_TOKEN
+```
+
+Rotation invalidates the old capability. Disabling returns the store to Open
+mode. Recall, export, policy status/audit, adapter and consolidation dry-runs,
+and report-only maintenance remain read-only.
+
+This policy is operational authorization in official Rust/CLI write paths. It
+does not create a read ACL, distributed authority, an OS security boundary, or
+protection from an adversary who controls the database files or process
+environment.
+
## Idempotent Writes
When `operation_id` is present, SQLite resolves it inside the
@@ -104,3 +160,19 @@ filesystem. SQLite WAL, a busy timeout, and bounded lock retries handle this
local contention. The protocol does not claim distributed locking, multi-host
database coordination, or safe SQLite sharing over NFS or other network
filesystems.
+
+Schema v3 adds coordinated-policy state, a protected-write audit, and a
+connection-level old-memory-mutation fence. Before a v0.13 process first opens
+an existing store, stop every Tree Ring process, checkpoint and back up the
+database, and upgrade every CLI, plugin, and bundled worker. A v0.12 connection
+does not register the schema-v3 writer protocol, so its memory inserts, updates,
+and deletes are rejected after migration. All mixed-version operation remains
+unsupported, including older reads and maintenance. Roll back only by stopping
+all processes and restoring the complete pre-upgrade backup.
+
+The bounded real-process acceptance test covers concurrent unauthorized worker
+denials, permitted agent-partitioned creates, coordinator-authorized shared
+publication and promotion, capability rotation, audit evidence, and exact
+memory-row/FTS parity. That evidence remains limited to cooperative official
+processes on one host; it is not adversarial filesystem or distributed-system
+certification.
diff --git a/marketing/README.md b/marketing/README.md
index d3d727b..4d1c600 100644
--- a/marketing/README.md
+++ b/marketing/README.md
@@ -309,7 +309,7 @@ python3 marketing/scripts/build-campaign-cards.py
- Repository: `https://github.com/TerminallyLazy/Tree-Ring-Memory`
- Launch page: `https://terminallylazy.github.io/Tree-Ring-Memory/`
-- Launch release: `https://github.com/TerminallyLazy/Tree-Ring-Memory/releases/tag/v0.12.0`
+- Launch release: `https://github.com/TerminallyLazy/Tree-Ring-Memory/releases/tag/v0.13.0`
- Launch discussion: `https://github.com/TerminallyLazy/Tree-Ring-Memory/discussions/27`
- Homebrew tap: `https://github.com/TerminallyLazy/homebrew-tree-ring`
- Agent-Skills.md main repo listing:
diff --git a/marketing/github-release-v0.13.0.md b/marketing/github-release-v0.13.0.md
new file mode 100644
index 0000000..3d7f24a
--- /dev/null
+++ b/marketing/github-release-v0.13.0.md
@@ -0,0 +1,65 @@
+# Tree Ring Memory v0.13.0
+
+Tree Ring Memory v0.13.0 adds a bounded same-host multi-agent contract and an
+optional coordinator policy for shared publication and lifecycle changes.
+
+## What Ships
+
+- Agent, workflow, session, operation, and source correlation through the Rust
+ API and CLI.
+- Exact-retry idempotency with conflict detection.
+- Real multi-process acceptance coverage with eight concurrent CLI workers.
+- Opt-in Coordinated mode: ordinary workers can create only matching
+ non-heartwood agent-scoped memory.
+- Coordinator-only shared/non-agent writes, heartwood, import, persisted
+ adapters and consolidation, lifecycle changes, and applied maintenance.
+- One-time coordinator capabilities supplied only through
+ `TREE_RING_COORDINATOR_TOKEN`, stored as hashes, and blocked from memory and
+ audit/status metadata.
+- Transactional protected-write audit records with unsafe targets hashed and
+ terminal-safe human output.
+- SQLite schema v3 with forward-version rejection and a fence for memory
+ inserts, updates, and deletes from old writers.
+- Read-only policy status and audit commands that never create or migrate a
+ store.
+- Agent-aware TUI writes and visible, nonfatal authorization denials.
+
+## Supported Boundary
+
+The concurrency and authorization evidence covers cooperative Tree Ring Rust
+and CLI processes sharing a SQLite store on one host and a local filesystem. It
+does not establish a read ACL, cross-host coordination, network-filesystem
+safety, or protection from an adversary who controls the database files or
+process environment.
+
+## Upgrade From v0.12
+
+Schema v3 is a coordinated upgrade:
+
+1. Stop every Tree Ring CLI, TUI, plugin, and bundled worker using the root.
+2. Checkpoint SQLite WAL state and make a verified complete store backup.
+3. Upgrade every CLI, plugin, and bundled worker to a v0.13-compatible build.
+4. Reopen the root with v0.13 to migrate it.
+
+Do not run v0.12 against an upgraded store. Memory inserts, updates, and deletes
+from old writers are fenced; all mixed-version operation is unsupported,
+including older maintenance. Roll back only by stopping all processes and
+restoring the complete pre-upgrade backup.
+
+## Install
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/TerminallyLazy/Tree-Ring-Memory/main/install.sh | sh
+```
+
+Homebrew will be updated to the verified v0.13.0 release artifact after the tag
+workflow publishes it.
+
+## Verify
+
+```bash
+tree-ring --version
+tree-ring policy --help
+```
+
+Expected version: `tree-ring 0.13.0`.
diff --git a/skills/tree-ring-memory/SKILL.md b/skills/tree-ring-memory/SKILL.md
index b93f7af..4a161a5 100644
--- a/skills/tree-ring-memory/SKILL.md
+++ b/skills/tree-ring-memory/SKILL.md
@@ -1,7 +1,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.
-version: 0.12.0
+version: 0.13.0
tags: ["memory", "agents", "recall", "privacy", "projects", "dox", "revolve", "skills", "cli"]
triggers:
- "remember this"
@@ -75,6 +75,8 @@ tree-ring integrations scan --source-root .
Run adapter commands with `--dry-run` first. Sync only concise, source-linked
summaries; never treat imported memory as more authoritative than the source
`AGENTS.md`, Revolve record, evaluation, PR, issue, or test artifact.
+In a Coordinated store, persisting an adapter result requires the coordinator
+capability; dry-run discovery does not.
Use the exact CLI commands exposed by the local install:
@@ -239,9 +241,74 @@ recall every worker.
This shared-root pattern is for concurrent processes on one host using a local
filesystem. It is not a distributed lock service and does not claim safe
-cross-host or NFS operation. Scope and identity fields are routing metadata, not
-an ACL; a same-user coordinator can recall across profiles. Use per-host stores
-plus an explicit, evidence-preserving fan-in process when work spans hosts.
+cross-host or NFS operation. Scope and identity fields remain routing metadata,
+not a read ACL; a same-user coordinator can recall across profiles. Use
+per-host stores plus an explicit, evidence-preserving fan-in process when work
+spans hosts.
+
+## Coordinated Write Policy
+
+Stores default to backward-compatible Open mode. For a shared root where only a
+designated coordinator should publish or mutate shared memory, enable the
+optional Coordinated policy:
+
+```bash
+tree-ring --root .tree-ring policy enable --coordinator release-coordinator
+export TREE_RING_COORDINATOR_TOKEN=''
+tree-ring --root .tree-ring policy status
+tree-ring --root .tree-ring policy audit --limit 100
+```
+
+Enable prints the capability once. Put it only in
+`TREE_RING_COORDINATOR_TOKEN`; never pass it as a CLI flag or place it in a
+memory, log, source ref, transcript, or committed file. Tree Ring stores only a
+hash. `policy status` and `policy audit` are read-only and do not reveal the
+capability. Inject it only into coordinator processes, and launch every ordinary
+worker with `TREE_RING_COORDINATOR_TOKEN` unset so fan-out does not inherit
+coordinator authority.
+
+In Coordinated mode, an ordinary worker may only create non-heartwood
+`scope=agent` memory whose `agent_profile` matches its write context. Supply the
+same identity with `--agent-profile ` or
+`TREE_RING_AGENT_PROFILE=`. A coordinator capability is required for:
+
+- project, global, workflow, session, or other shared/non-agent writes
+- heartwood creation or promotion
+- JSONL import and persisted DOX/Revolve sync
+- persisted consolidation
+- ring changes and supersede/delete/redact lifecycle operations
+- maintenance with apply or repair flags
+
+Recall, export, policy status/audit, adapter dry-runs, consolidation dry-runs,
+and report-only maintenance remain read-only. In the TUI, start with
+`--agent-profile ` (or `TREE_RING_AGENT_PROFILE`) so `/remember`
+defaults to agent scope. TUI promote/scar/seed, supersede, forget/redact, and
+persisted consolidation actions require `TREE_RING_COORDINATOR_TOKEN`.
+
+Rotate the capability while the current one is exported, then immediately
+replace the environment value with the newly printed capability:
+
+```bash
+tree-ring --root .tree-ring policy rotate --coordinator release-coordinator-next
+export TREE_RING_COORDINATOR_TOKEN=''
+tree-ring --root .tree-ring policy disable
+unset TREE_RING_COORDINATOR_TOKEN
+```
+
+Rotation invalidates the old capability. Disabling returns the store to Open
+mode and also requires the current capability.
+
+This is operational write authorization enforced by official Rust/CLI store
+paths. It is not a read ACL, an operating-system security boundary, or
+protection from an adversary who controls the local database files or process
+environment.
+
+Before opening an existing store with v0.13/schema v3, stop every Tree Ring
+process, checkpoint and back up the database, and upgrade every CLI, plugin, and
+bundled worker. Do not reopen the upgraded root with v0.12: schema v3 fences
+memory inserts, updates, and deletes from old writers, and all mixed-version
+operation is unsupported. Roll back only by stopping all processes and
+restoring the pre-upgrade backup.
## Agent-Mediated Updates
@@ -268,6 +335,8 @@ If memory is wrong, private, stale, or superseded:
- supersede it when a newer decision replaces it
- prefer explicit reasons for every forget operation
+In Coordinated mode these lifecycle writes require the coordinator capability.
+
Treat redaction as monotonic. Do not try to restore a redacted ID through
replacement import; create a new reviewed memory only if the user deliberately
reintroduces safe content.
diff --git a/templates/dox/AGENTS.md b/templates/dox/AGENTS.md
index 9f36449..a09e24f 100644
--- a/templates/dox/AGENTS.md
+++ b/templates/dox/AGENTS.md
@@ -96,14 +96,64 @@ the same operation and payload returns the original memory; conflicting reuse
of the operation key must fail. Replaced operation namespaces and redacted
memory IDs stay claimed until explicit hard deletion.
-Scope and identity fields are routing partitions, not authorization boundaries.
-A same-user coordinator with filesystem access can recall across worker
-profiles.
+Scope and identity fields are routing partitions, not read authorization
+boundaries. A same-user coordinator with filesystem access can recall across
+worker profiles.
This shared-root contract covers concurrent processes on one host and a local
filesystem. It is not a distributed lock service and does not claim safe
cross-host or NFS database sharing.
+## Coordinated Store Policy
+
+Stores default to backward-compatible Open mode. When only a designated
+coordinator should publish or mutate shared memory, enable the optional
+Coordinated policy:
+
+```bash
+tree-ring --root .tree-ring policy enable --coordinator release-coordinator
+export TREE_RING_COORDINATOR_TOKEN=''
+tree-ring --root .tree-ring policy status
+tree-ring --root .tree-ring policy audit --limit 100
+```
+
+Never pass the capability as a CLI flag or store it in memory, logs, source
+refs, or committed files. Tree Ring prints it once and stores only its hash.
+Inject it only into coordinator processes; launch ordinary workers with
+`TREE_RING_COORDINATOR_TOKEN` unset.
+
+In Coordinated mode, an ordinary worker may create only non-heartwood
+`scope=agent` memory whose `agent_profile` matches `--agent-profile` or
+`TREE_RING_AGENT_PROFILE`. Project/global/workflow/session writes, heartwood,
+imports, persisted DOX/Revolve sync, persisted consolidation, ring changes,
+supersede/delete/redact, and applied maintenance require
+`TREE_RING_COORDINATOR_TOKEN`. Read-only recall/export, policy status/audit,
+adapter and consolidation dry-runs, and report-only maintenance remain
+available without it.
+
+For the TUI, set `--agent-profile ` or
+`TREE_RING_AGENT_PROFILE=` so `/remember` defaults to agent scope.
+Lifecycle actions require the coordinator capability.
+
+Rotate and disable only while the current capability is exported:
+
+```bash
+tree-ring --root .tree-ring policy rotate --coordinator release-coordinator-next
+export TREE_RING_COORDINATOR_TOKEN=''
+tree-ring --root .tree-ring policy disable
+unset TREE_RING_COORDINATOR_TOKEN
+```
+
+This is operational authorization in official Rust/CLI write paths, not a read
+ACL or protection against an adversary who controls local files or the process
+environment.
+
+Before a v0.13/schema-v3 upgrade, stop all Tree Ring processes, checkpoint and
+back up the store, and upgrade every CLI, plugin, and bundled worker before
+reopening it. Schema v3 fences memory inserts, updates, and deletes from old
+v0.12 writers; all mixed-version operation is unsupported. Roll back only by
+restoring the pre-upgrade backup.
+
## Ring Mapping
- Use `cambium` for active task context.