diff --git a/Cargo.lock b/Cargo.lock
index a5af8e7..e989458 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1690,7 +1690,7 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "tree-ring-memory-cli"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"chrono",
"clap",
@@ -1704,9 +1704,10 @@ dependencies = [
[[package]]
name = "tree-ring-memory-core"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"chrono",
+ "libc",
"once_cell",
"regex",
"serde",
@@ -1718,7 +1719,7 @@ dependencies = [
[[package]]
name = "tree-ring-memory-sqlite"
-version = "0.11.0"
+version = "0.12.0"
dependencies = [
"rusqlite",
"serde_json",
diff --git a/Cargo.toml b/Cargo.toml
index 6cb6945..2ab29b3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -7,7 +7,7 @@ members = [
resolver = "2"
[workspace.package]
-version = "0.11.0"
+version = "0.12.0"
edition = "2021"
license = "MIT"
authors = ["TerminallyLazy"]
@@ -16,6 +16,7 @@ repository = "https://github.com/TerminallyLazy/Tree-Ring-Memory"
[workspace.dependencies]
chrono = { version = "0.4", features = ["serde", "clock"] }
clap = { version = "4", features = ["derive"] }
+libc = "0.2"
once_cell = "1"
regex = "1"
rusqlite = { version = "0.32", features = ["bundled"] }
diff --git a/README.md b/README.md
index ae08e12..389f6aa 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:
@@ -37,6 +37,7 @@ Tree Ring Memory is in protocol-preview status. Current launch links:
- v0.9 removed tracked Python source, tests, smoke scripts, and the optional CPython extension from the canonical repo.
- 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.
@@ -164,7 +165,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.11.0-macos-arm64.tar.gz --archive-sha256
+sh install.sh --archive-url https://example/tree-ring-memory-0.12.0-darwin-arm64.tar.gz --archive-sha256
```
After install, rerun onboarding anytime:
@@ -441,19 +442,19 @@ harness checks, import throughput, and 10k/30k recall timing. It writes
`target/tree-ring-certification/evidence-index.json`.
Most recent branch-local certification run, generated at
-`2026-07-09T15:34:52Z`:
-
-- Release binary: 6,352,528 bytes.
-- Project install with init: 6,272 KB.
-- Global install: 6,228 KB.
-- CLI import: 10,000 memories in 5 seconds, about 2,000/sec.
-- 10k recall: 3.643 ms average, 6.499 ms max.
-- 30k recall: 7.608 ms average, 13.634 ms max.
+`2026-07-15T22:02:33Z`:
+
+- Release binary: 6,366,432 bytes.
+- Project install with init: 6,292 KB.
+- Global install: 6,244 KB.
+- CLI import: 10,000 memories in 4 seconds, about 2,500/sec.
+- 10k recall: 3.451 ms average, 6.197 ms max.
+- 30k recall: 7.538 ms average, 13.495 ms max.
- Harness matrix: 5 pass, 1 skip. Codex, Claude Code, OpenCode, Goose, and
Agent Zero/A0 passed; Pi was skipped because only a user-home marker was
present in the fixture.
- Recall quality: 4 queries, 4 pass, 0 fail, 0 needs review; average latency
- 0.118 ms, max latency 0.370 ms.
+ 0.137 ms, max latency 0.418 ms.
- Agent Zero plugin smoke: skipped because `TREE_RING_AGENT_ZERO_ROOT` was not
set.
@@ -464,6 +465,21 @@ The quality report is written to
`target/tree-ring-certification/quality/quality-report.json` with a readable
summary at `target/tree-ring-certification/quality/quality-summary.md`.
+An explicit agent workflow evaluation keeps paired trial workspaces and an
+observed evidence report separate from normal certification and CI. It requires
+a validated explicit model ID and records `codex:` in the paired
+reports:
+
+```bash
+cargo run --locked -p tree-ring-memory-cli --example workflow_proof -- \
+ fixtures/workflow-proof target/tree-ring-certification/workflow-proof \
+ --model
+```
+
+See [agent workflow proof](docs/integrations/agent-workflow-proof.md) for the
+controlled command, retained artifacts, exact structured-outcome checks, and
+interpretation limits.
+
`scripts/package-release.sh` builds the Rust CLI in release mode, creates a
platform tarball under `dist/`, and writes a SHA-256 checksum file. Tag pushes
run the release artifact workflow for Linux and macOS.
diff --git a/crates/tree-ring-memory-cli/Cargo.toml b/crates/tree-ring-memory-cli/Cargo.toml
index c5af14a..2b5e349 100644
--- a/crates/tree-ring-memory-cli/Cargo.toml
+++ b/crates/tree-ring-memory-cli/Cargo.toml
@@ -21,8 +21,8 @@ clap.workspace = true
ratatui.workspace = true
serde.workspace = true
serde_json.workspace = true
-tree-ring-memory-core = { path = "../tree-ring-memory-core", version = "0.11.0" }
-tree-ring-memory-sqlite = { path = "../tree-ring-memory-sqlite", version = "0.11.0" }
+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" }
[dev-dependencies]
tempfile.workspace = true
diff --git a/crates/tree-ring-memory-cli/examples/workflow_proof.rs b/crates/tree-ring-memory-cli/examples/workflow_proof.rs
new file mode 100644
index 0000000..3eceb31
--- /dev/null
+++ b/crates/tree-ring-memory-cli/examples/workflow_proof.rs
@@ -0,0 +1,171 @@
+use std::{ffi::OsString, path::PathBuf};
+
+use tree_ring_memory_cli::workflow_proof::{
+ run_workflow_proof, CodexWorkflowAgent, WorkflowProofReport,
+};
+
+const USAGE: &str =
+ "usage: workflow_proof --model [--codex-bin ]";
+
+enum WorkflowProofCliArgs {
+ Help,
+ Run {
+ fixture_dir: PathBuf,
+ output_dir: PathBuf,
+ codex_binary: PathBuf,
+ model: String,
+ },
+}
+
+fn main() {
+ if let Err(error) = run() {
+ eprintln!("workflow proof failed: {error}");
+ std::process::exit(1);
+ }
+}
+
+fn run() -> Result<(), String> {
+ let args = match parse_cli_args(std::env::args_os().skip(1))? {
+ WorkflowProofCliArgs::Help => {
+ println!("{USAGE}");
+ return Ok(());
+ }
+ WorkflowProofCliArgs::Run {
+ fixture_dir,
+ output_dir,
+ codex_binary,
+ model,
+ } => (fixture_dir, output_dir, codex_binary, model),
+ };
+
+ let (fixture_dir, output_dir, codex_binary, model) = args;
+ let agent = CodexWorkflowAgent::new(codex_binary, model)?;
+ let report = run_workflow_proof(&fixture_dir, &output_dir, &agent)?;
+ print_summary(&report);
+ if !report.tree_ring_complete {
+ return Err(format!(
+ "Tree Ring trials were incomplete after reports were written: {} failed or errored trial(s)",
+ tree_ring_non_passes(&report)
+ ));
+ }
+ Ok(())
+}
+
+fn parse_cli_args(
+ arguments: impl IntoIterator- ,
+) -> Result {
+ let mut args = arguments.into_iter();
+ let Some(first_argument) = args.next() else {
+ return Err(USAGE.to_string());
+ };
+ if first_argument == "--help" {
+ return Ok(WorkflowProofCliArgs::Help);
+ }
+
+ let fixture_dir = PathBuf::from(first_argument);
+ let output_dir = args
+ .next()
+ .map(PathBuf::from)
+ .ok_or_else(|| USAGE.to_string())?;
+ let mut codex_binary = PathBuf::from("codex");
+ let mut codex_binary_supplied = false;
+ let mut model = None;
+
+ while let Some(argument) = args.next() {
+ if argument == "--codex-bin" {
+ if codex_binary_supplied {
+ return Err(USAGE.to_string());
+ }
+ codex_binary = args
+ .next()
+ .map(PathBuf::from)
+ .ok_or_else(|| USAGE.to_string())?;
+ codex_binary_supplied = true;
+ } else if argument == "--model" {
+ if model.is_some() {
+ return Err(USAGE.to_string());
+ }
+ let value = args.next().ok_or_else(|| USAGE.to_string())?;
+ model = Some(
+ value
+ .into_string()
+ .map_err(|_| "model must be valid UTF-8".to_string())?,
+ );
+ } else {
+ return Err(USAGE.to_string());
+ }
+ }
+
+ let model = model
+ .map(|model| model.trim().to_string())
+ .filter(|model| !model.is_empty())
+ .ok_or_else(|| USAGE.to_string())?;
+
+ Ok(WorkflowProofCliArgs::Run {
+ fixture_dir,
+ output_dir,
+ codex_binary,
+ model,
+ })
+}
+
+fn print_summary(report: &WorkflowProofReport) {
+ println!(
+ "workflow proof evaluated: {} scenario(s), {} trial(s), observed Tree Ring wins over no-memory: {}, observed Tree Ring wins over raw-memory: {}",
+ report.scenario_count,
+ report.trial_count,
+ report.tree_ring_wins_over_no_memory,
+ report.tree_ring_wins_over_raw_memory
+ );
+}
+
+fn tree_ring_non_passes(report: &WorkflowProofReport) -> usize {
+ report
+ .arm_summaries
+ .iter()
+ .find(|summary| summary.arm == tree_ring_memory_core::WorkflowArm::TreeRing)
+ .map(|summary| summary.fail_count + summary.error_count)
+ .unwrap_or(report.scenario_count)
+}
+
+#[cfg(test)]
+mod tests {
+ use std::ffi::OsString;
+
+ use super::{parse_cli_args, WorkflowProofCliArgs, USAGE};
+
+ #[test]
+ fn parse_cli_args_recognizes_help() {
+ assert!(matches!(
+ parse_cli_args([OsString::from("--help")]),
+ Ok(WorkflowProofCliArgs::Help)
+ ));
+ }
+
+ #[test]
+ fn parse_cli_args_rejects_missing_model() {
+ assert_eq!(
+ parse_cli_args([OsString::from("fixtures"), OsString::from("output")])
+ .err()
+ .as_deref(),
+ Some(USAGE)
+ );
+ }
+
+ #[test]
+ fn parse_cli_args_rejects_blank_model() {
+ for model in ["", " "] {
+ assert_eq!(
+ parse_cli_args([
+ OsString::from("fixtures"),
+ OsString::from("output"),
+ OsString::from("--model"),
+ OsString::from(model),
+ ])
+ .err()
+ .as_deref(),
+ Some(USAGE)
+ );
+ }
+ }
+}
diff --git a/crates/tree-ring-memory-cli/src/lib.rs b/crates/tree-ring-memory-cli/src/lib.rs
new file mode 100644
index 0000000..872e7d4
--- /dev/null
+++ b/crates/tree-ring-memory-cli/src/lib.rs
@@ -0,0 +1 @@
+pub mod workflow_proof;
diff --git a/crates/tree-ring-memory-cli/src/workflow_proof.rs b/crates/tree-ring-memory-cli/src/workflow_proof.rs
new file mode 100644
index 0000000..afa2e9a
--- /dev/null
+++ b/crates/tree-ring-memory-cli/src/workflow_proof.rs
@@ -0,0 +1,918 @@
+use std::collections::BTreeSet;
+use std::ffi::OsStr;
+use std::fs;
+use std::path::{Component, Path, PathBuf};
+use std::process::Command;
+
+use serde::{Deserialize, Serialize};
+use serde_json::json;
+use tree_ring_memory_core::{
+ evaluate_workspace, now_iso, parse_workflow_scenario, MemoryEvent, WorkflowAgentRequest,
+ WorkflowAgentResponse, WorkflowArm, WorkflowFileCheckReport, WorkflowMemoryContext,
+ WorkflowScenario,
+};
+use tree_ring_memory_sqlite::{MemoryRetriever, SQLiteMemoryStore};
+
+const REPORT_SCHEMA_VERSION: u8 = 1;
+const RECALL_LIMIT: usize = 8;
+const CODEX_SCHEMA_FILE: &str = ".tree-ring-workflow-schema.json";
+const CODEX_RESPONSE_FILE: &str = ".tree-ring-workflow-response.json";
+const TREE_RING_CONTEXT_ERROR: &str = "tree_ring_context_error";
+
+pub trait WorkflowAgent {
+ fn execute(&self, request: &WorkflowAgentRequest) -> Result;
+
+ fn evidence_identity(&self) -> String {
+ "unspecified-agent".to_string()
+ }
+}
+
+pub struct CodexWorkflowAgent {
+ binary: PathBuf,
+ model: String,
+}
+
+impl CodexWorkflowAgent {
+ pub fn new(binary: PathBuf, model: String) -> Result {
+ let model = model.trim();
+ if model.is_empty() {
+ return Err("codex workflow model is required".to_string());
+ }
+ let binary = resolve_codex_binary(&binary)?;
+ Ok(Self {
+ binary,
+ model: model.to_string(),
+ })
+ }
+}
+
+fn resolve_codex_binary(binary: &Path) -> Result {
+ let path = std::env::var_os("PATH").unwrap_or_default();
+ resolve_codex_binary_from_path(binary, path.as_os_str())
+}
+
+fn resolve_codex_binary_from_path(binary: &Path, path: &OsStr) -> Result {
+ if binary.as_os_str().is_empty() {
+ return Err("codex workflow binary is required".to_string());
+ }
+
+ let candidate = if is_bare_executable_name(binary) {
+ std::env::split_paths(path)
+ .map(|directory| directory.join(binary))
+ .find(|candidate| candidate.is_file())
+ .ok_or_else(|| {
+ format!(
+ "codex workflow executable `{}` was not found on PATH",
+ binary.display()
+ )
+ })?
+ } else {
+ binary.to_path_buf()
+ };
+
+ fs::canonicalize(&candidate).map_err(|error| {
+ format!(
+ "codex workflow executable `{}` could not be resolved: {error}",
+ candidate.display()
+ )
+ })
+}
+
+fn is_bare_executable_name(binary: &Path) -> bool {
+ let mut components = binary.components();
+ matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
+}
+
+impl WorkflowAgent for CodexWorkflowAgent {
+ fn evidence_identity(&self) -> String {
+ format!("codex:{}", self.model)
+ }
+
+ fn execute(&self, request: &WorkflowAgentRequest) -> Result {
+ fs::create_dir_all(&request.workspace_root)
+ .map_err(|error| format!("workspace_create_error: {error}"))?;
+
+ let schema_path = request.workspace_root.join(CODEX_SCHEMA_FILE);
+ let response_path = request.workspace_root.join(CODEX_RESPONSE_FILE);
+ let schema = serde_json::to_string_pretty(&codex_response_schema())
+ .map_err(|error| format!("codex_schema_encode_error: {error}"))?;
+ fs::write(&schema_path, schema)
+ .map_err(|error| format!("codex_schema_write_error: {error}"))?;
+ if response_path.exists() {
+ fs::remove_file(&response_path)
+ .map_err(|error| format!("codex_response_cleanup_error: {error}"))?;
+ }
+
+ let prompt = codex_prompt(request)?;
+ let mut command = Command::new(&self.binary);
+ command
+ .arg("exec")
+ .arg("--ephemeral")
+ .arg("--sandbox")
+ .arg("workspace-write")
+ .arg("--cd")
+ .arg(&request.workspace_root)
+ .arg("--output-schema")
+ .arg(&schema_path)
+ .arg("--output-last-message")
+ .arg(&response_path)
+ .arg("--model")
+ .arg(&self.model);
+ let status = command
+ .arg(prompt)
+ .status()
+ .map_err(|error| format!("codex_exec_spawn_error: {error}"))?;
+ if !status.success() {
+ return Err(format!("codex_exec_failed: {status}"));
+ }
+
+ let output = fs::read_to_string(&response_path)
+ .map_err(|error| format!("codex_response_read_error: {error}"))?;
+ let response = serde_json::from_str::(&output)
+ .map_err(|error| format!("codex_response_parse_error: {error}"))?;
+ response
+ .validate()
+ .map_err(|error| format!("codex_response_validation_error: {error}"))?;
+ Ok(response)
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum WorkflowProofTrialStatus {
+ Pass,
+ Fail,
+ Error,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct WorkflowProofTrialReport {
+ pub arm: WorkflowArm,
+ pub workspace: String,
+ pub memory_context: Vec,
+ pub agent_response: Option,
+ pub file_checks: Vec,
+ pub status: WorkflowProofTrialStatus,
+ pub errors: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct WorkflowProofScenarioReport {
+ pub name: String,
+ pub scenario_id: String,
+ pub trials: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct WorkflowProofArmSummary {
+ pub arm: WorkflowArm,
+ pub pass_count: usize,
+ pub fail_count: usize,
+ pub error_count: usize,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct WorkflowProofReport {
+ pub schema_version: u8,
+ pub generated_at: String,
+ pub agent_identity: String,
+ pub scenario_count: usize,
+ pub trial_count: usize,
+ pub arm_summaries: Vec,
+ pub scenarios: Vec,
+ pub tree_ring_wins_over_no_memory: usize,
+ pub tree_ring_wins_over_raw_memory: usize,
+ pub tree_ring_complete: bool,
+}
+
+pub fn run_workflow_proof(
+ fixture_dir: &Path,
+ output_dir: &Path,
+ agent: &impl WorkflowAgent,
+) -> Result {
+ run_workflow_proof_with_tree_ring_context_builder(
+ fixture_dir,
+ output_dir,
+ agent,
+ recalled_memories,
+ )
+}
+
+fn run_workflow_proof_with_tree_ring_context_builder(
+ fixture_dir: &Path,
+ output_dir: &Path,
+ agent: &impl WorkflowAgent,
+ tree_ring_context_builder: impl Fn(&WorkflowScenario) -> Result, String>,
+) -> Result {
+ fs::create_dir_all(output_dir)
+ .map_err(|error| format!("output_directory_create_error: {error}"))?;
+ let output_dir = fs::canonicalize(output_dir).map_err(|error| {
+ format!(
+ "output_directory_canonicalize_error {}: {error}",
+ output_dir.display()
+ )
+ })?;
+
+ let fixture_paths = sorted_fixture_paths(fixture_dir)?;
+ if fixture_paths.is_empty() {
+ return Err("no workflow scenario JSON files found".to_string());
+ }
+
+ let mut scenario_ids = BTreeSet::new();
+ let mut scenarios = Vec::with_capacity(fixture_paths.len());
+ for path in fixture_paths {
+ let input = fs::read_to_string(&path)
+ .map_err(|error| format!("workflow_fixture_read_error {}: {error}", path.display()))?;
+ let scenario = parse_workflow_scenario(&input)
+ .map_err(|error| format!("workflow_fixture_parse_error {}: {error}", path.display()))?;
+ let scenario_id = unique_scenario_id(&scenario.name, &mut scenario_ids);
+ scenarios.push(run_scenario(
+ &scenario,
+ &scenario_id,
+ &output_dir,
+ agent,
+ &tree_ring_context_builder,
+ )?);
+ }
+
+ let report = summarize(agent.evidence_identity(), scenarios);
+ write_reports(&output_dir, &report)?;
+ Ok(report)
+}
+
+fn run_scenario(
+ scenario: &WorkflowScenario,
+ scenario_id: &str,
+ output_dir: &Path,
+ agent: &impl WorkflowAgent,
+ tree_ring_context_builder: &impl Fn(&WorkflowScenario) -> Result, String>,
+) -> Result {
+ let raw_memory = visible_seed_memories(scenario);
+ let mut trials = Vec::with_capacity(3);
+ trials.push(run_trial(
+ scenario,
+ scenario_id,
+ WorkflowArm::NoMemory,
+ Vec::new(),
+ output_dir,
+ agent,
+ )?);
+ trials.push(run_trial(
+ scenario,
+ scenario_id,
+ WorkflowArm::RawMemory,
+ raw_memory,
+ output_dir,
+ agent,
+ )?);
+ let tree_ring_trial = match tree_ring_context_builder(scenario) {
+ Ok(tree_ring_memory) => run_trial(
+ scenario,
+ scenario_id,
+ WorkflowArm::TreeRing,
+ tree_ring_memory,
+ output_dir,
+ agent,
+ )?,
+ Err(_) => run_tree_ring_context_error_trial(scenario, scenario_id, output_dir)?,
+ };
+ trials.push(tree_ring_trial);
+
+ Ok(WorkflowProofScenarioReport {
+ name: scenario.name.clone(),
+ scenario_id: scenario_id.to_string(),
+ trials,
+ })
+}
+
+fn run_tree_ring_context_error_trial(
+ scenario: &WorkflowScenario,
+ scenario_id: &str,
+ output_dir: &Path,
+) -> Result {
+ let arm = WorkflowArm::TreeRing;
+ let workspace = trial_workspace(output_dir, scenario_id, &arm);
+ materialize_workspace(&workspace, scenario)?;
+ let file_checks = evaluate_workspace(scenario, &workspace);
+
+ Ok(WorkflowProofTrialReport {
+ arm,
+ workspace: workspace_report_path(&workspace, output_dir),
+ memory_context: Vec::new(),
+ agent_response: None,
+ file_checks,
+ status: WorkflowProofTrialStatus::Error,
+ errors: vec![TREE_RING_CONTEXT_ERROR.to_string()],
+ })
+}
+
+fn run_trial(
+ scenario: &WorkflowScenario,
+ scenario_id: &str,
+ arm: WorkflowArm,
+ memory_context: Vec,
+ output_dir: &Path,
+ agent: &impl WorkflowAgent,
+) -> Result {
+ let workspace = trial_workspace(output_dir, scenario_id, &arm);
+ materialize_workspace(&workspace, scenario)?;
+
+ let request = WorkflowAgentRequest::new(
+ scenario_id.to_string(),
+ arm.clone(),
+ scenario.task.clone(),
+ workspace.clone(),
+ memory_context.clone(),
+ );
+ let mut errors = Vec::new();
+ let agent_response = match agent.execute(&request) {
+ Ok(response) => {
+ if let Err(error) = response.validate() {
+ errors.push(format!("agent_response_validation_error: {error}"));
+ }
+ let known_memory_ids = memory_context
+ .iter()
+ .map(|memory| memory.id.as_str())
+ .collect::>();
+ for memory_id in &response.used_memory_ids {
+ if !known_memory_ids.contains(memory_id.as_str()) {
+ errors.push(format!("used_memory_id_not_in_context: {memory_id}"));
+ }
+ }
+ Some(response)
+ }
+ Err(error) => {
+ errors.push(format!("agent_execution_error: {error}"));
+ None
+ }
+ };
+ let file_checks = evaluate_workspace(scenario, &workspace);
+ let status = if errors.is_empty() {
+ if file_checks.iter().all(|check| check.passed) {
+ WorkflowProofTrialStatus::Pass
+ } else {
+ WorkflowProofTrialStatus::Fail
+ }
+ } else {
+ WorkflowProofTrialStatus::Error
+ };
+
+ Ok(WorkflowProofTrialReport {
+ arm,
+ workspace: workspace_report_path(&workspace, output_dir),
+ memory_context,
+ agent_response,
+ file_checks,
+ status,
+ errors,
+ })
+}
+
+fn trial_workspace(output_dir: &Path, scenario_id: &str, arm: &WorkflowArm) -> PathBuf {
+ output_dir
+ .join("trials")
+ .join(scenario_id)
+ .join(arm_directory(arm))
+ .join("workspace")
+}
+
+fn workspace_report_path(workspace: &Path, output_dir: &Path) -> String {
+ workspace
+ .strip_prefix(output_dir)
+ .unwrap_or(workspace)
+ .display()
+ .to_string()
+}
+
+fn visible_seed_memories(scenario: &WorkflowScenario) -> Vec {
+ scenario
+ .seed_memories
+ .iter()
+ .filter(|memory| memory.sensitivity == "normal" && memory.superseded_by.is_none())
+ .map(project_memory)
+ .collect()
+}
+
+fn recalled_memories(scenario: &WorkflowScenario) -> Result, String> {
+ let mut store = SQLiteMemoryStore::open(":memory:")
+ .map_err(|error| format!("workflow_store_open_error: {error}"))?;
+ store
+ .put_many(&scenario.seed_memories)
+ .map_err(|error| format!("workflow_seed_write_error: {error}"))?;
+ let recalled = MemoryRetriever::new(&store)
+ .recall(
+ &scenario.task,
+ None,
+ None,
+ None,
+ None,
+ None,
+ false,
+ false,
+ RECALL_LIMIT,
+ false,
+ )
+ .map_err(|error| format!("workflow_recall_error: {error}"))?;
+ Ok(recalled
+ .into_iter()
+ .map(|result| project_memory(&result.memory))
+ .collect())
+}
+
+fn project_memory(memory: &MemoryEvent) -> WorkflowMemoryContext {
+ WorkflowMemoryContext {
+ id: memory.id.clone(),
+ summary: memory.summary.clone(),
+ details: memory.details.clone(),
+ ring: memory.ring.clone(),
+ event_type: memory.event_type.clone(),
+ source_ref: memory.source.ref_.clone(),
+ confidence: memory.confidence,
+ }
+}
+
+fn materialize_workspace(workspace: &Path, scenario: &WorkflowScenario) -> Result<(), String> {
+ if workspace.exists() {
+ fs::remove_dir_all(workspace)
+ .map_err(|error| format!("workspace_cleanup_error {}: {error}", workspace.display()))?;
+ }
+ fs::create_dir_all(workspace)
+ .map_err(|error| format!("workspace_create_error {}: {error}", workspace.display()))?;
+
+ for file in &scenario.workspace_files {
+ let path = workspace.join(&file.path);
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent).map_err(|error| {
+ format!(
+ "workspace_parent_create_error {}: {error}",
+ parent.display()
+ )
+ })?;
+ }
+ fs::write(&path, &file.content)
+ .map_err(|error| format!("workspace_file_write_error {}: {error}", path.display()))?;
+ }
+ Ok(())
+}
+
+fn sorted_fixture_paths(fixture_dir: &Path) -> Result, String> {
+ let entries = fs::read_dir(fixture_dir).map_err(|error| {
+ format!(
+ "fixture_directory_read_error {}: {error}",
+ fixture_dir.display()
+ )
+ })?;
+ let mut paths = entries
+ .map(|entry| {
+ entry.map(|entry| entry.path()).map_err(|error| {
+ format!(
+ "fixture_directory_read_error {}: {error}",
+ fixture_dir.display()
+ )
+ })
+ })
+ .collect::, _>>()?;
+ paths.retain(|path| path.is_file() && path.extension() == Some(OsStr::new("json")));
+ paths.sort();
+ Ok(paths)
+}
+
+fn unique_scenario_id(name: &str, used: &mut BTreeSet) -> String {
+ let base = safe_scenario_name(name);
+ let mut candidate = base.clone();
+ let mut suffix = 2;
+ while !used.insert(candidate.clone()) {
+ candidate = format!("{base}-{suffix}");
+ suffix += 1;
+ }
+ candidate
+}
+
+fn safe_scenario_name(name: &str) -> String {
+ let name = name
+ .chars()
+ .map(|character| {
+ if character.is_ascii_alphanumeric() {
+ character.to_ascii_lowercase()
+ } else {
+ '-'
+ }
+ })
+ .collect::();
+ let name = name.trim_matches('-');
+ if name.is_empty() {
+ "scenario".to_string()
+ } else {
+ name.to_string()
+ }
+}
+
+fn arm_directory(arm: &WorkflowArm) -> &'static str {
+ match arm {
+ WorkflowArm::NoMemory => "no_memory",
+ WorkflowArm::RawMemory => "raw_memory",
+ WorkflowArm::TreeRing => "tree_ring",
+ }
+}
+
+fn summarize(
+ agent_identity: String,
+ scenarios: Vec,
+) -> WorkflowProofReport {
+ let arm_summaries = [
+ WorkflowArm::NoMemory,
+ WorkflowArm::RawMemory,
+ WorkflowArm::TreeRing,
+ ]
+ .into_iter()
+ .map(|arm| summarize_arm(&scenarios, arm))
+ .collect::>();
+ let tree_ring_wins_over_no_memory = scenarios
+ .iter()
+ .filter(|scenario| observed_win(scenario, WorkflowArm::NoMemory))
+ .count();
+ let tree_ring_wins_over_raw_memory = scenarios
+ .iter()
+ .filter(|scenario| observed_win(scenario, WorkflowArm::RawMemory))
+ .count();
+ let tree_ring_complete = !scenarios.is_empty()
+ && scenarios.iter().all(|scenario| {
+ status_for(scenario, &WorkflowArm::TreeRing) == Some(WorkflowProofTrialStatus::Pass)
+ });
+ let scenario_count = scenarios.len();
+ let trial_count = scenarios.iter().map(|scenario| scenario.trials.len()).sum();
+
+ WorkflowProofReport {
+ schema_version: REPORT_SCHEMA_VERSION,
+ generated_at: now_iso(),
+ agent_identity,
+ scenario_count,
+ trial_count,
+ arm_summaries,
+ scenarios,
+ tree_ring_wins_over_no_memory,
+ tree_ring_wins_over_raw_memory,
+ tree_ring_complete,
+ }
+}
+
+fn summarize_arm(
+ scenarios: &[WorkflowProofScenarioReport],
+ arm: WorkflowArm,
+) -> WorkflowProofArmSummary {
+ let mut pass_count = 0;
+ let mut fail_count = 0;
+ let mut error_count = 0;
+ for scenario in scenarios {
+ match status_for(scenario, &arm) {
+ Some(WorkflowProofTrialStatus::Pass) => pass_count += 1,
+ Some(WorkflowProofTrialStatus::Fail) => fail_count += 1,
+ Some(WorkflowProofTrialStatus::Error) => error_count += 1,
+ None => error_count += 1,
+ }
+ }
+ WorkflowProofArmSummary {
+ arm,
+ pass_count,
+ fail_count,
+ error_count,
+ }
+}
+
+fn observed_win(scenario: &WorkflowProofScenarioReport, control: WorkflowArm) -> bool {
+ status_for(scenario, &WorkflowArm::TreeRing) == Some(WorkflowProofTrialStatus::Pass)
+ && status_for(scenario, &control) == Some(WorkflowProofTrialStatus::Fail)
+}
+
+fn status_for(
+ scenario: &WorkflowProofScenarioReport,
+ arm: &WorkflowArm,
+) -> Option {
+ scenario
+ .trials
+ .iter()
+ .find(|trial| &trial.arm == arm)
+ .map(|trial| trial.status.clone())
+}
+
+fn write_reports(output_dir: &Path, report: &WorkflowProofReport) -> Result<(), String> {
+ let json = serde_json::to_string_pretty(report)
+ .map_err(|error| format!("workflow_report_encode_error: {error}"))?;
+ fs::write(output_dir.join("workflow-proof-report.json"), json)
+ .map_err(|error| format!("workflow_report_write_error: {error}"))?;
+ fs::write(
+ output_dir.join("workflow-proof-summary.md"),
+ markdown_summary(report),
+ )
+ .map_err(|error| format!("workflow_summary_write_error: {error}"))?;
+ Ok(())
+}
+
+fn markdown_summary(report: &WorkflowProofReport) -> String {
+ let mut lines = vec![
+ "# Tree Ring Workflow Proof Summary".to_string(),
+ String::new(),
+ format!("- agent identity: {}", report.agent_identity),
+ format!("- Tree Ring complete: {}", report.tree_ring_complete),
+ format!(
+ "- observed Tree Ring wins over no-memory: {}",
+ report.tree_ring_wins_over_no_memory
+ ),
+ format!(
+ "- observed Tree Ring wins over raw-memory: {}",
+ report.tree_ring_wins_over_raw_memory
+ ),
+ format!("- scenarios: {}", report.scenario_count),
+ format!("- trials: {}", report.trial_count),
+ String::new(),
+ "## Arm summaries".to_string(),
+ String::new(),
+ ];
+ for summary in &report.arm_summaries {
+ lines.push(format!(
+ "- {}: pass={}, fail={}, error={}",
+ arm_directory(&summary.arm),
+ summary.pass_count,
+ summary.fail_count,
+ summary.error_count
+ ));
+ }
+ lines.extend([String::new(), "## Scenarios".to_string(), String::new()]);
+ for scenario in &report.scenarios {
+ let statuses = scenario
+ .trials
+ .iter()
+ .map(|trial| {
+ format!(
+ "{}={}",
+ arm_directory(&trial.arm),
+ status_label(&trial.status)
+ )
+ })
+ .collect::>()
+ .join(", ");
+ lines.push(format!("- `{}`: {statuses}", scenario.name));
+ }
+ lines.push(String::new());
+ lines.join("\n")
+}
+
+fn status_label(status: &WorkflowProofTrialStatus) -> &'static str {
+ match status {
+ WorkflowProofTrialStatus::Pass => "pass",
+ WorkflowProofTrialStatus::Fail => "fail",
+ WorkflowProofTrialStatus::Error => "error",
+ }
+}
+
+fn codex_response_schema() -> serde_json::Value {
+ json!({
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["summary", "used_memory_ids"],
+ "properties": {
+ "summary": { "type": "string" },
+ "used_memory_ids": {
+ "type": "array",
+ "items": { "type": "string" }
+ }
+ }
+ })
+}
+
+fn codex_prompt(request: &WorkflowAgentRequest) -> Result {
+ let memory_context = serde_json::to_string_pretty(&request.memory_context)
+ .map_err(|error| format!("codex_prompt_encode_error: {error}"))?;
+ Ok(format!(
+ "work only in the workspace.\n\
+use source/task files over memory when they conflict.\n\
+do not seek validators or fixtures.\n\
+return only the response schema fields `summary` and `used_memory_ids`.\n\n\
+task:\n{}\n\n\
+memory_context:\n{}",
+ request.task, memory_context
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ use std::env;
+ #[cfg(unix)]
+ use std::os::unix::fs::symlink;
+ use std::sync::Mutex;
+
+ use super::*;
+ use tempfile::tempdir;
+
+ struct ControlAgent {
+ calls: Mutex>,
+ }
+
+ impl ControlAgent {
+ fn calls(&self) -> Vec {
+ self.calls.lock().unwrap().clone()
+ }
+ }
+
+ impl WorkflowAgent for ControlAgent {
+ fn execute(&self, request: &WorkflowAgentRequest) -> Result {
+ self.calls.lock().unwrap().push(request.arm.clone());
+ if request.arm == WorkflowArm::RawMemory {
+ fs::write(
+ request.workspace_root.join("decision.json"),
+ r#"{"action":"use_seeded_control_decision","rationale":"The visible control memory resolves the choice."}"#,
+ )
+ .map_err(|error| error.to_string())?;
+ }
+ Ok(WorkflowAgentResponse {
+ summary: "finished the control task".to_string(),
+ used_memory_ids: request
+ .memory_context
+ .first()
+ .map(|memory| vec![memory.id.clone()])
+ .unwrap_or_default(),
+ })
+ }
+ }
+
+ #[test]
+ fn preserves_controls_and_reports_when_tree_ring_context_setup_fails() {
+ let fixtures = tempdir().unwrap();
+ let output = tempdir().unwrap();
+ write_context_failure_fixture(fixtures.path());
+ let agent = ControlAgent {
+ calls: Mutex::new(Vec::new()),
+ };
+ let forced_error = "untrusted tree-ring store failure detail";
+
+ let report = run_workflow_proof_with_tree_ring_context_builder(
+ fixtures.path(),
+ output.path(),
+ &agent,
+ |_| Err(forced_error.to_string()),
+ )
+ .unwrap();
+
+ assert_eq!(report.scenario_count, 1);
+ assert_eq!(report.trial_count, 3);
+ assert!(!report.tree_ring_complete);
+ assert_eq!(
+ agent.calls(),
+ vec![WorkflowArm::NoMemory, WorkflowArm::RawMemory]
+ );
+
+ let trials = &report.scenarios[0].trials;
+ assert_eq!(trials[0].status, WorkflowProofTrialStatus::Fail);
+ assert_eq!(trials[1].status, WorkflowProofTrialStatus::Pass);
+ assert_eq!(trials[2].status, WorkflowProofTrialStatus::Error);
+ assert_eq!(trials[1].file_checks[0].path, "decision.json");
+ assert_eq!(
+ trials[1].file_checks[0]
+ .json_fields
+ .as_ref()
+ .and_then(|fields| fields.get("/action")),
+ Some(&json!("use_seeded_control_decision"))
+ );
+ assert_eq!(trials[2].errors, vec!["tree_ring_context_error"]);
+ assert!(trials[2].agent_response.is_none());
+ assert!(trials[2].memory_context.is_empty());
+
+ for arm in ["no_memory", "raw_memory", "tree_ring"] {
+ assert!(output
+ .path()
+ .join("trials/context-failure")
+ .join(arm)
+ .join("workspace")
+ .is_dir());
+ }
+ let report_path = output.path().join("workflow-proof-report.json");
+ let persisted_json = fs::read_to_string(report_path).unwrap();
+ let persisted = serde_json::from_str::(&persisted_json).unwrap();
+ assert_eq!(persisted, report);
+ assert!(!persisted_json.contains(forced_error));
+ assert!(output.path().join("workflow-proof-summary.md").is_file());
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn resolves_output_directory_before_deriving_workspaces_and_keeps_report_paths_relative() {
+ let fixtures = tempdir().unwrap();
+ let physical_output = tempdir().unwrap();
+ let output_parent = tempdir().unwrap();
+ let linked_output = output_parent.path().join("workflow-proof-output");
+ symlink(physical_output.path(), &linked_output).unwrap();
+ write_context_failure_fixture(fixtures.path());
+ let agent = ControlAgent {
+ calls: Mutex::new(Vec::new()),
+ };
+
+ let report = run_workflow_proof_with_tree_ring_context_builder(
+ fixtures.path(),
+ &linked_output,
+ &agent,
+ |_| Err("forced tree-ring context failure".to_string()),
+ )
+ .unwrap();
+
+ let raw_memory = &report.scenarios[0].trials[1];
+ assert_eq!(raw_memory.status, WorkflowProofTrialStatus::Pass);
+ assert_eq!(
+ raw_memory.workspace,
+ "trials/context-failure/raw_memory/workspace"
+ );
+ assert!(!std::path::Path::new(&raw_memory.workspace).is_absolute());
+
+ let resolved_output = fs::canonicalize(&linked_output).unwrap();
+ assert!(resolved_output.join(&raw_memory.workspace).is_dir());
+ assert!(resolved_output.join("workflow-proof-report.json").is_file());
+ assert!(resolved_output.join("workflow-proof-summary.md").is_file());
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn resolves_a_bare_codex_name_from_the_supplied_path_and_canonicalizes_the_symlink() {
+ let directory = tempdir().unwrap();
+ let release = directory.path().join("release");
+ let bin = directory.path().join("bin");
+ fs::create_dir_all(&release).unwrap();
+ fs::create_dir_all(&bin).unwrap();
+
+ let target = release.join("codex");
+ fs::write(&target, "fake codex").unwrap();
+ symlink(&target, bin.join("codex")).unwrap();
+ let path = env::join_paths([bin]).unwrap();
+
+ let resolved = resolve_codex_binary_from_path(Path::new("codex"), path.as_os_str())
+ .expect("bare Codex name should resolve from the supplied PATH");
+
+ assert_eq!(resolved, fs::canonicalize(target).unwrap());
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn canonicalizes_an_explicit_codex_binary_symlink() {
+ let directory = tempdir().unwrap();
+ let target = directory.path().join("codex-release");
+ let alias = directory.path().join("codex");
+ fs::write(&target, "fake codex").unwrap();
+ symlink(&target, &alias).unwrap();
+
+ let agent = CodexWorkflowAgent::new(alias, "test-model".to_string())
+ .expect("explicit Codex path should resolve its symlink");
+
+ assert_eq!(agent.binary, fs::canonicalize(target).unwrap());
+ }
+
+ #[test]
+ fn reports_a_clear_error_when_a_bare_codex_name_is_not_on_the_supplied_path() {
+ let directory = tempdir().unwrap();
+ let path = env::join_paths([directory.path()]).unwrap();
+
+ let error = resolve_codex_binary_from_path(Path::new("codex"), path.as_os_str())
+ .expect_err("missing bare Codex binary must fail");
+
+ assert_eq!(
+ error,
+ "codex workflow executable `codex` was not found on PATH"
+ );
+ }
+
+ fn write_context_failure_fixture(fixture_dir: &Path) {
+ fs::write(
+ fixture_dir.join("context-failure.json"),
+ r#"{
+ "name": "context failure",
+ "task": "Inspect the visible control memory and workspace, choose an action from decision-format.json, and write valid decision.json with action and rationale.",
+ "seed_memories": [
+ {
+ "id": "mem_control",
+ "created_at": "2026-07-12T00:00:00Z",
+ "updated_at": "2026-07-12T00:00:00Z",
+ "scope": "global",
+ "ring": "cambium",
+ "event_type": "decision",
+ "summary": "Use the seeded control decision.",
+ "details": "A normal visible seed for the raw-memory control.",
+ "source": {"type": "test", "ref": "test://context-failure"},
+ "salience": 0.9,
+ "confidence": 0.9,
+ "sensitivity": "normal",
+ "retention": "normal"
+ }
+ ],
+ "workspace_files": [
+ {"path": "task.md", "content": "A visible control note may resolve this workflow choice."},
+ {"path": "decision-format.json", "content": "{\n \"output_file\": \"decision.json\",\n \"required_fields\": [\"action\", \"rationale\"],\n \"action\": {\n \"type\": \"string\",\n \"enum\": [\n \"use_seeded_control_decision\",\n \"defer_without_memory\"\n ]\n },\n \"rationale\": {\n \"type\": \"string\"\n }\n}\n"}
+ ],
+ "expected_files": [
+ {"path": "decision.json", "json_fields": {"/action": "use_seeded_control_decision"}}
+ ]
+}"#,
+ )
+ .unwrap();
+ }
+}
diff --git a/crates/tree-ring-memory-cli/tests/workflow_proof.rs b/crates/tree-ring-memory-cli/tests/workflow_proof.rs
new file mode 100644
index 0000000..23a732a
--- /dev/null
+++ b/crates/tree-ring-memory-cli/tests/workflow_proof.rs
@@ -0,0 +1,601 @@
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::sync::Mutex;
+
+use tempfile::tempdir;
+use tree_ring_memory_cli::workflow_proof::{
+ run_workflow_proof, CodexWorkflowAgent, WorkflowAgent, WorkflowProofTrialStatus,
+};
+use tree_ring_memory_core::{WorkflowAgentRequest, WorkflowAgentResponse, WorkflowArm};
+
+const TARGET_MEMORY_ID: &str = "mem_quality_no_background_writer";
+const WORKFLOW_NO_BACKGROUND_MEMORY_ID: &str = "mem_workflow_no_background_writer";
+const SCAR_MEMORY_ID: &str = "mem_workflow_cache_migration_scar";
+const CURRENT_CLI_MEMORY_ID: &str = "mem_workflow_current_cli_contract";
+
+struct FakeAgent {
+ requests: Mutex>,
+ cite_unknown_memory: bool,
+}
+
+impl FakeAgent {
+ fn new(cite_unknown_memory: bool) -> Self {
+ Self {
+ requests: Mutex::new(Vec::new()),
+ cite_unknown_memory,
+ }
+ }
+
+ fn requests(&self) -> Vec {
+ self.requests.lock().unwrap().clone()
+ }
+}
+
+impl WorkflowAgent for FakeAgent {
+ fn execute(&self, request: &WorkflowAgentRequest) -> Result {
+ self.requests.lock().unwrap().push(request.clone());
+
+ let decision = request.memory_context.iter().find_map(|memory| {
+ structured_action_for_memory(&memory.id).map(|action| (memory.id.clone(), action))
+ });
+ if let Some((_, action)) = &decision {
+ fs::write(
+ request.workspace_root.join("decision.json"),
+ serde_json::json!({
+ "action": action,
+ "rationale": "The selected memory resolves the workspace tradeoff."
+ })
+ .to_string(),
+ )
+ .map_err(|error| error.to_string())?;
+ }
+
+ Ok(WorkflowAgentResponse {
+ summary: "completed the requested workspace task".to_string(),
+ used_memory_ids: if self.cite_unknown_memory {
+ vec!["memory-not-in-context".to_string()]
+ } else {
+ decision
+ .map(|(memory_id, _)| vec![memory_id])
+ .unwrap_or_default()
+ },
+ })
+ }
+}
+
+#[test]
+fn paired_runner_keeps_controls_and_records_observed_lift() {
+ let fixtures = tempdir().unwrap();
+ let output = tempdir().unwrap();
+ write_no_background_writer_fixture(fixtures.path());
+ let agent = FakeAgent::new(false);
+
+ let report = run_workflow_proof(fixtures.path(), output.path(), &agent).unwrap();
+
+ assert_eq!(report.scenario_count, 1);
+ assert_eq!(report.trial_count, 3);
+ assert_eq!(report.tree_ring_wins_over_no_memory, 1);
+ assert_eq!(report.tree_ring_wins_over_raw_memory, 0);
+ assert!(report.tree_ring_complete);
+ assert_eq!(report.agent_identity, "unspecified-agent");
+
+ let scenario = &report.scenarios[0];
+ let no_memory = trial_for(scenario, WorkflowArm::NoMemory);
+ assert_eq!(no_memory.status, WorkflowProofTrialStatus::Fail);
+ assert!(no_memory.file_checks.iter().any(|check| !check.passed));
+
+ let raw_memory = trial_for(scenario, WorkflowArm::RawMemory);
+ assert_eq!(raw_memory.status, WorkflowProofTrialStatus::Pass);
+ let tree_ring = trial_for(scenario, WorkflowArm::TreeRing);
+ assert_eq!(tree_ring.status, WorkflowProofTrialStatus::Pass);
+
+ let requests = agent.requests();
+ assert_eq!(requests.len(), 3);
+ let no_memory_request = request_for(&requests, WorkflowArm::NoMemory);
+ assert!(no_memory_request.memory_context.is_empty());
+ for arm in [WorkflowArm::RawMemory, WorkflowArm::TreeRing] {
+ let request = request_for(&requests, arm);
+ assert_eq!(
+ request
+ .memory_context
+ .iter()
+ .map(|memory| memory.id.as_str())
+ .collect::>(),
+ vec![TARGET_MEMORY_ID]
+ );
+ assert_eq!(
+ request.memory_context[0].source_ref,
+ "test://workflow-proof"
+ );
+ }
+
+ for arm in ["no_memory", "raw_memory", "tree_ring"] {
+ assert!(output
+ .path()
+ .join("trials/no-background-writer")
+ .join(arm)
+ .join("workspace")
+ .is_dir());
+ }
+ let report_json = fs::read_to_string(output.path().join("workflow-proof-report.json")).unwrap();
+ assert!(report_json.contains("\"agent_identity\": \"unspecified-agent\""));
+ let summary = fs::read_to_string(output.path().join("workflow-proof-summary.md")).unwrap();
+ assert!(summary.contains("- agent identity: unspecified-agent"));
+}
+
+#[test]
+fn stale_cli_fixture_injects_current_contract_and_omits_superseded_contract() {
+ let fixtures = tempdir().unwrap();
+ let output = tempdir().unwrap();
+ let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .join("fixtures/workflow-proof/stale-cli-contract.json");
+ fs::copy(&fixture, fixtures.path().join("stale-cli-contract.json"))
+ .unwrap_or_else(|error| panic!("copy {}: {error}", fixture.display()));
+ let agent = FakeAgent::new(false);
+
+ let report = run_workflow_proof(fixtures.path(), output.path(), &agent).unwrap();
+
+ let scenario = &report.scenarios[0];
+ let raw_memory = trial_for(scenario, WorkflowArm::RawMemory);
+ assert_eq!(
+ memory_ids(&raw_memory.memory_context),
+ ["mem_workflow_current_cli_contract"]
+ );
+ assert_eq!(raw_memory.status, WorkflowProofTrialStatus::Pass);
+ assert_eq!(
+ action_in_trial_workspace(output.path(), raw_memory),
+ "require_event_type",
+ "the current contract must determine the structured outcome"
+ );
+
+ let tree_ring = trial_for(scenario, WorkflowArm::TreeRing);
+ assert_eq!(
+ memory_ids(&tree_ring.memory_context),
+ ["mem_workflow_current_cli_contract"]
+ );
+ assert!(!memory_ids(&tree_ring.memory_context).contains(&"mem_workflow_stale_cli_contract"));
+ assert_eq!(tree_ring.status, WorkflowProofTrialStatus::Pass);
+ assert_eq!(
+ action_in_trial_workspace(output.path(), tree_ring),
+ "require_event_type"
+ );
+}
+
+#[test]
+fn shipped_workflow_fixtures_use_structured_action_outcomes_without_leaking_validators() {
+ let fixture_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .join("fixtures/workflow-proof");
+
+ let fixture_expectations: [(&str, &str, &str, &[&str]); 3] = [
+ (
+ "no-background-writer",
+ "require_explicit_durable_request",
+ "enable_persistent_writer",
+ &["durable", "workflow", "persistent"],
+ ),
+ (
+ "scar-recovery",
+ "rollback_cache_migration",
+ "retry_cache_migration",
+ &["cache migration", "stale state"],
+ ),
+ (
+ "stale-cli-contract",
+ "require_event_type",
+ "preserve_legacy_invocation",
+ &["current command-line guidance", "recording a memory"],
+ ),
+ ];
+
+ for (fixture_name, expected_action, alternative_action, query_terms) in fixture_expectations {
+ let path = fixture_dir.join(format!("{fixture_name}.json"));
+ let input = fs::read_to_string(&path)
+ .unwrap_or_else(|error| panic!("read {}: {error}", path.display()));
+ let scenario = tree_ring_memory_core::parse_workflow_scenario(&input)
+ .unwrap_or_else(|error| panic!("parse {}: {error}", path.display()));
+
+ assert!(scenario.task.contains("decision-format.json"));
+ assert!(scenario.task.contains("decision.json"));
+ assert!(scenario.task.contains("action"));
+ assert!(scenario.task.contains("rationale"));
+ assert!(!scenario.task.contains(expected_action));
+ for query_term in query_terms {
+ assert!(
+ scenario.task.contains(query_term),
+ "{fixture_name} task must retain the retriever term {query_term:?}"
+ );
+ }
+ assert_eq!(scenario.expected_files.len(), 1);
+
+ let expected_file = &scenario.expected_files[0];
+ assert_eq!(expected_file.path, "decision.json");
+ assert!(expected_file.contains.is_none());
+ assert_eq!(
+ expected_file
+ .json_fields
+ .as_ref()
+ .and_then(|fields| fields.get("/action")),
+ Some(&serde_json::json!(expected_action))
+ );
+ assert_eq!(expected_file.json_fields.as_ref().unwrap().len(), 1);
+
+ let format_file = scenario
+ .workspace_files
+ .iter()
+ .find(|file| file.path == "decision-format.json")
+ .unwrap_or_else(|| panic!("{fixture_name} must materialize decision-format.json"));
+ let format = serde_json::from_str::(&format_file.content)
+ .unwrap_or_else(|error| panic!("parse format for {fixture_name}: {error}"));
+ assert_eq!(format["output_file"], "decision.json");
+ assert_eq!(
+ format["required_fields"],
+ serde_json::json!(["action", "rationale"])
+ );
+ assert_eq!(
+ format["action"]["enum"],
+ serde_json::json!([expected_action, alternative_action])
+ );
+ assert_eq!(format["rationale"]["type"], "string");
+
+ let raw_memory_context = scenario
+ .seed_memories
+ .iter()
+ .filter(|memory| memory.sensitivity == "normal" && memory.superseded_by.is_none())
+ .map(|memory| tree_ring_memory_core::WorkflowMemoryContext {
+ id: memory.id.clone(),
+ summary: memory.summary.clone(),
+ details: memory.details.clone(),
+ ring: memory.ring.to_string(),
+ event_type: memory.event_type.to_string(),
+ source_ref: memory.source.ref_.clone(),
+ confidence: memory.confidence,
+ })
+ .collect();
+ let request = WorkflowAgentRequest::new(
+ scenario.name,
+ WorkflowArm::RawMemory,
+ scenario.task,
+ PathBuf::from("/tmp/workflow-proof"),
+ raw_memory_context,
+ );
+ let request_json = serde_json::to_string(&request).unwrap();
+ assert!(!request_json.contains(expected_action));
+ assert!(!request_json.contains(alternative_action));
+ assert!(!request_json.contains("/action"));
+ assert!(!request_json.contains("expected_files"));
+ }
+}
+
+#[test]
+fn shipped_workflow_fixtures_recall_the_memory_that_resolves_each_structured_outcome() {
+ let fixture_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .join("fixtures/workflow-proof");
+
+ for (fixture_name, required_memory_id, expected_action) in [
+ (
+ "no-background-writer",
+ "mem_workflow_no_background_writer",
+ "require_explicit_durable_request",
+ ),
+ ("scar-recovery", SCAR_MEMORY_ID, "rollback_cache_migration"),
+ (
+ "stale-cli-contract",
+ CURRENT_CLI_MEMORY_ID,
+ "require_event_type",
+ ),
+ ] {
+ let fixtures = tempdir().unwrap();
+ let output = tempdir().unwrap();
+ let fixture = fixture_dir.join(format!("{fixture_name}.json"));
+ fs::copy(
+ &fixture,
+ fixtures.path().join(format!("{fixture_name}.json")),
+ )
+ .unwrap_or_else(|error| panic!("copy {}: {error}", fixture.display()));
+
+ let report = run_workflow_proof(fixtures.path(), output.path(), &FakeAgent::new(false))
+ .unwrap_or_else(|error| panic!("run {fixture_name}: {error}"));
+ let scenario = &report.scenarios[0];
+
+ for arm in [WorkflowArm::RawMemory, WorkflowArm::TreeRing] {
+ let trial = trial_for(scenario, arm.clone());
+ assert!(
+ memory_ids(&trial.memory_context).contains(&required_memory_id),
+ "{fixture_name} {arm:?} must receive {required_memory_id}"
+ );
+ assert_eq!(trial.status, WorkflowProofTrialStatus::Pass);
+ assert_eq!(
+ action_in_trial_workspace(output.path(), trial),
+ expected_action
+ );
+ }
+ }
+}
+
+#[test]
+fn unknown_cited_memory_is_recorded_as_a_trial_error() {
+ let fixtures = tempdir().unwrap();
+ let output = tempdir().unwrap();
+ write_no_background_writer_fixture(fixtures.path());
+ let agent = FakeAgent::new(true);
+
+ let report = run_workflow_proof(fixtures.path(), output.path(), &agent).unwrap();
+
+ let scenario = &report.scenarios[0];
+ let tree_ring = trial_for(scenario, WorkflowArm::TreeRing);
+ assert_eq!(tree_ring.status, WorkflowProofTrialStatus::Error);
+ assert!(tree_ring
+ .errors
+ .iter()
+ .any(|error| error.contains("memory-not-in-context")));
+ assert_eq!(report.tree_ring_wins_over_no_memory, 0);
+ assert!(!report.tree_ring_complete);
+}
+
+#[cfg(unix)]
+#[test]
+fn codex_adapter_uses_request_context_and_required_model() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let workspace = tempdir().unwrap();
+ let binary = workspace.path().join("fake-codex");
+ write_fake_codex(&binary, TARGET_MEMORY_ID);
+ let mut permissions = fs::metadata(&binary).unwrap().permissions();
+ permissions.set_mode(0o755);
+ fs::set_permissions(&binary, permissions).unwrap();
+
+ let request = WorkflowAgentRequest::new(
+ "adapter scenario".to_string(),
+ WorkflowArm::TreeRing,
+ "Create the requested decision file.".to_string(),
+ workspace.path().to_path_buf(),
+ vec![tree_ring_memory_core::WorkflowMemoryContext {
+ id: TARGET_MEMORY_ID.to_string(),
+ summary: "No background writer".to_string(),
+ details: "Require an explicit request before starting one.".to_string(),
+ ring: "cambium".to_string(),
+ event_type: "decision".to_string(),
+ source_ref: "test://workflow-proof".to_string(),
+ confidence: 0.95,
+ }],
+ );
+
+ let agent = CodexWorkflowAgent::new(binary.clone(), "test-model".to_string()).unwrap();
+ assert_eq!(agent.evidence_identity(), "codex:test-model");
+ let response = agent.execute(&request).unwrap();
+
+ assert_eq!(response.used_memory_ids, vec![TARGET_MEMORY_ID]);
+ let arguments = fake_codex_arguments(&binary);
+ assert_eq!(
+ arguments
+ .iter()
+ .take(10)
+ .map(String::as_str)
+ .collect::>(),
+ vec![
+ "exec",
+ "--ephemeral",
+ "--sandbox",
+ "workspace-write",
+ "--cd",
+ workspace.path().to_str().unwrap(),
+ "--output-schema",
+ workspace
+ .path()
+ .join(".tree-ring-workflow-schema.json")
+ .to_str()
+ .unwrap(),
+ "--output-last-message",
+ workspace
+ .path()
+ .join(".tree-ring-workflow-response.json")
+ .to_str()
+ .unwrap(),
+ ]
+ );
+ assert_eq!(
+ arguments[10..12]
+ .iter()
+ .map(String::as_str)
+ .collect::>(),
+ vec!["--model", "test-model"]
+ );
+ assert_eq!(arguments.len(), 13);
+ let prompt = &arguments[12];
+ assert!(prompt.contains("work only in the workspace"));
+ assert!(prompt.contains("use source/task files over memory when they conflict"));
+ assert!(prompt.contains("do not seek validators or fixtures"));
+ assert!(prompt.contains("Create the requested decision file."));
+ assert!(prompt.contains("memory_context"));
+ assert!(!prompt.contains("expected_files"));
+ assert!(workspace
+ .path()
+ .join(".tree-ring-workflow-schema.json")
+ .is_file());
+}
+
+#[test]
+fn codex_adapter_rejects_blank_model_at_construction() {
+ let error = CodexWorkflowAgent::new(PathBuf::from("codex"), " \t ".to_string())
+ .err()
+ .expect("blank model must be rejected");
+
+ assert_eq!(error, "codex workflow model is required");
+}
+
+fn trial_for(
+ scenario: &tree_ring_memory_cli::workflow_proof::WorkflowProofScenarioReport,
+ arm: WorkflowArm,
+) -> &tree_ring_memory_cli::workflow_proof::WorkflowProofTrialReport {
+ scenario
+ .trials
+ .iter()
+ .find(|trial| trial.arm == arm)
+ .unwrap_or_else(|| panic!("missing {arm:?} trial"))
+}
+
+fn request_for(requests: &[WorkflowAgentRequest], arm: WorkflowArm) -> &WorkflowAgentRequest {
+ requests
+ .iter()
+ .find(|request| request.arm == arm)
+ .unwrap_or_else(|| panic!("missing {arm:?} request"))
+}
+
+fn memory_ids(memories: &[tree_ring_memory_core::WorkflowMemoryContext]) -> Vec<&str> {
+ memories.iter().map(|memory| memory.id.as_str()).collect()
+}
+
+fn structured_action_for_memory(memory_id: &str) -> Option<&'static str> {
+ match memory_id {
+ TARGET_MEMORY_ID | WORKFLOW_NO_BACKGROUND_MEMORY_ID => {
+ Some("require_explicit_durable_request")
+ }
+ SCAR_MEMORY_ID => Some("rollback_cache_migration"),
+ CURRENT_CLI_MEMORY_ID => Some("require_event_type"),
+ _ => None,
+ }
+}
+
+fn action_in_trial_workspace(
+ output_root: &Path,
+ trial: &tree_ring_memory_cli::workflow_proof::WorkflowProofTrialReport,
+) -> String {
+ let decision_path = output_root.join(&trial.workspace).join("decision.json");
+ let decision = fs::read_to_string(&decision_path)
+ .unwrap_or_else(|error| panic!("read {}: {error}", decision_path.display()));
+ serde_json::from_str::(&decision)
+ .unwrap_or_else(|error| panic!("parse {}: {error}", decision_path.display()))["action"]
+ .as_str()
+ .unwrap_or_else(|| panic!("{} lacks string action", decision_path.display()))
+ .to_string()
+}
+
+fn write_no_background_writer_fixture(fixture_dir: &Path) {
+ fs::write(
+ fixture_dir.join("no-background-writer.json"),
+ r#"{
+ "name": "no background writer",
+ "task": "Inspect the durable workflow request about a persistent helper, choose an action from decision-format.json, and write valid decision.json with action and rationale.",
+ "seed_memories": [
+ {
+ "id": "mem_quality_no_background_writer",
+ "created_at": "2026-07-12T00:00:00Z",
+ "updated_at": "2026-07-12T00:00:00Z",
+ "scope": "global",
+ "ring": "cambium",
+ "event_type": "decision",
+ "summary": "No background writer without an explicit request.",
+ "details": "The workflow owner must explicitly request a background writer.",
+ "source": {
+ "type": "test",
+ "ref": "test://workflow-proof",
+ "quote": "This quote must never reach the agent request."
+ },
+ "salience": 0.9,
+ "confidence": 0.95,
+ "sensitivity": "normal",
+ "retention": "normal"
+ },
+ {
+ "id": "mem_sensitive_hidden",
+ "created_at": "2026-07-12T00:00:00Z",
+ "updated_at": "2026-07-12T00:00:00Z",
+ "scope": "global",
+ "ring": "cambium",
+ "event_type": "note",
+ "summary": "Sensitive memory must not be exposed.",
+ "details": "Hidden from normal-memory proof arms.",
+ "source": {
+ "type": "test",
+ "ref": "test://sensitive"
+ },
+ "salience": 0.9,
+ "confidence": 0.95,
+ "sensitivity": "private",
+ "retention": "normal"
+ },
+ {
+ "id": "mem_superseded_hidden",
+ "created_at": "2026-07-12T00:00:00Z",
+ "updated_at": "2026-07-12T00:00:00Z",
+ "scope": "global",
+ "ring": "cambium",
+ "event_type": "note",
+ "summary": "Superseded memory must not be exposed.",
+ "details": "Hidden from normal-memory proof arms.",
+ "source": {
+ "type": "test",
+ "ref": "test://superseded"
+ },
+ "salience": 0.9,
+ "confidence": 0.95,
+ "sensitivity": "normal",
+ "retention": "normal",
+ "superseded_by": "mem_quality_no_background_writer"
+ }
+ ],
+ "workspace_files": [
+ {
+ "path": "task.md",
+ "content": "A maintainer is considering a persistent helper that makes durable workflow changes between explicit sessions. Decide whether it should run."
+ },
+ {
+ "path": "decision-format.json",
+ "content": "{\n \"output_file\": \"decision.json\",\n \"required_fields\": [\"action\", \"rationale\"],\n \"action\": {\n \"type\": \"string\",\n \"enum\": [\n \"require_explicit_durable_request\",\n \"enable_persistent_writer\"\n ]\n },\n \"rationale\": {\n \"type\": \"string\"\n }\n}\n"
+ }
+ ],
+ "expected_files": [
+ {
+ "path": "decision.json",
+ "json_fields": {
+ "/action": "require_explicit_durable_request"
+ }
+ }
+ ]
+}"#,
+ )
+ .unwrap();
+}
+
+#[cfg(unix)]
+fn write_fake_codex(binary: &Path, used_memory_id: &str) {
+ let used_memory_ids = if used_memory_id.is_empty() {
+ "[]".to_string()
+ } else {
+ format!("[\"{used_memory_id}\"]")
+ };
+ fs::write(
+ binary,
+ format!(
+ "#!/bin/sh\n\
+capture=\"$0.args\"\n\
+output=\"\"\n\
+for argument in \"$@\"; do\n\
+ printf '%s\\n---ARG---\\n' \"$argument\" >> \"$capture\"\n\
+done\n\
+while [ \"$#\" -gt 0 ]; do\n\
+ if [ \"$1\" = \"--output-last-message\" ]; then\n\
+ shift\n\
+ output=\"$1\"\n\
+ fi\n\
+ shift\n\
+done\n\
+printf '%s' '{{\"summary\":\"adapter response\",\"used_memory_ids\":{used_memory_ids}}}' > \"$output\"\n"
+ ),
+ )
+ .unwrap();
+}
+
+#[cfg(unix)]
+fn fake_codex_arguments(binary: &Path) -> Vec {
+ let capture = PathBuf::from(format!("{}.args", binary.display()));
+ let arguments = fs::read_to_string(capture).unwrap();
+ arguments
+ .split("\n---ARG---\n")
+ .filter(|argument| !argument.is_empty())
+ .map(|argument| argument.trim_end_matches('\n').to_string())
+ .collect()
+}
diff --git a/crates/tree-ring-memory-core/Cargo.toml b/crates/tree-ring-memory-core/Cargo.toml
index 9e58f6a..7c8d1d0 100644
--- a/crates/tree-ring-memory-core/Cargo.toml
+++ b/crates/tree-ring-memory-core/Cargo.toml
@@ -13,6 +13,7 @@ categories = ["development-tools"]
[dependencies]
chrono.workspace = true
+libc.workspace = true
once_cell.workspace = true
regex.workspace = true
serde.workspace = true
diff --git a/crates/tree-ring-memory-core/src/lib.rs b/crates/tree-ring-memory-core/src/lib.rs
index f92f60b..57e9414 100644
--- a/crates/tree-ring-memory-core/src/lib.rs
+++ b/crates/tree-ring-memory-core/src/lib.rs
@@ -8,6 +8,7 @@ pub mod quality;
pub mod recall;
pub mod revolve;
pub mod sensitivity;
+pub mod workflow;
pub use audit::{audit_memories, AuditFinding, AuditReport, AuditSeverity, AuditType, AUDIT_TYPES};
pub use consolidation::{
@@ -36,3 +37,8 @@ pub use quality::{
pub use recall::{RecallRanking, RecallScore, RecallScorer};
pub use revolve::{collect_revolve_memories, RevolveSyncReport, RevolveSyncRequest};
pub use sensitivity::{SensitivityGuard, SensitivityResult};
+pub use workflow::{
+ evaluate_workspace, parse_workflow_scenario, WorkflowAgentRequest, WorkflowAgentResponse,
+ WorkflowArm, WorkflowFileCheckReport, WorkflowFileExpectation, WorkflowMemoryContext,
+ WorkflowScenario, WorkflowWorkspaceFile,
+};
diff --git a/crates/tree-ring-memory-core/src/workflow.rs b/crates/tree-ring-memory-core/src/workflow.rs
new file mode 100644
index 0000000..dd5a66e
--- /dev/null
+++ b/crates/tree-ring-memory-core/src/workflow.rs
@@ -0,0 +1,922 @@
+use std::{
+ collections::{BTreeMap, HashSet},
+ fs,
+ io::Read,
+ path::{Path, PathBuf},
+};
+
+#[cfg(unix)]
+use std::{
+ ffi::{CString, OsStr},
+ os::{
+ fd::{AsRawFd, FromRawFd},
+ unix::{ffi::OsStrExt, fs::MetadataExt},
+ },
+ path::Component,
+};
+
+use serde::{de::Deserializer, Deserialize, Serialize};
+use serde_json::Value;
+
+use crate::models::{
+ MemoryEvent, MemoryLink, MemoryReview, MemorySource, TreeRingError, TreeRingResult,
+};
+
+const WORKFLOW_SCHEMA_VERSION: u8 = 1;
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum WorkflowArm {
+ NoMemory,
+ RawMemory,
+ TreeRing,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct WorkflowScenario {
+ pub name: String,
+ pub task: String,
+ #[serde(default, deserialize_with = "deserialize_seed_memories")]
+ pub seed_memories: Vec,
+ #[serde(default)]
+ pub workspace_files: Vec,
+ #[serde(default)]
+ pub expected_files: Vec,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct WorkflowSeedMemory {
+ id: String,
+ created_at: String,
+ updated_at: String,
+ #[serde(default)]
+ project: Option,
+ #[serde(default)]
+ agent_profile: Option,
+ #[serde(default = "default_workflow_seed_scope")]
+ scope: String,
+ #[serde(default = "default_workflow_seed_ring")]
+ ring: String,
+ event_type: String,
+ summary: String,
+ #[serde(default)]
+ details: String,
+ #[serde(default)]
+ source: WorkflowSeedMemorySource,
+ #[serde(default)]
+ tags: Vec,
+ #[serde(default = "default_workflow_seed_score")]
+ salience: f64,
+ #[serde(default = "default_workflow_seed_score")]
+ confidence: f64,
+ #[serde(default = "default_workflow_seed_sensitivity")]
+ sensitivity: String,
+ #[serde(default = "default_workflow_seed_retention")]
+ retention: String,
+ #[serde(default)]
+ expires_at: Option,
+ #[serde(default)]
+ supersedes: Vec,
+ #[serde(default)]
+ superseded_by: Option,
+ #[serde(default)]
+ links: Vec,
+ #[serde(default)]
+ review: WorkflowSeedMemoryReview,
+}
+
+impl From for MemoryEvent {
+ fn from(seed_memory: WorkflowSeedMemory) -> Self {
+ Self {
+ id: seed_memory.id,
+ created_at: seed_memory.created_at,
+ updated_at: seed_memory.updated_at,
+ project: seed_memory.project,
+ agent_profile: seed_memory.agent_profile,
+ scope: seed_memory.scope,
+ ring: seed_memory.ring,
+ event_type: seed_memory.event_type,
+ summary: seed_memory.summary,
+ details: seed_memory.details,
+ source: seed_memory.source.into(),
+ tags: seed_memory.tags,
+ salience: seed_memory.salience,
+ confidence: seed_memory.confidence,
+ sensitivity: seed_memory.sensitivity,
+ retention: seed_memory.retention,
+ expires_at: seed_memory.expires_at,
+ supersedes: seed_memory.supersedes,
+ superseded_by: seed_memory.superseded_by,
+ links: seed_memory.links.into_iter().map(Into::into).collect(),
+ review: seed_memory.review.into(),
+ }
+ }
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct WorkflowSeedMemorySource {
+ #[serde(rename = "type", default = "default_workflow_seed_source_type")]
+ source_type: String,
+ #[serde(rename = "ref", default)]
+ ref_: String,
+ #[serde(default)]
+ quote: String,
+}
+
+impl Default for WorkflowSeedMemorySource {
+ fn default() -> Self {
+ Self {
+ source_type: default_workflow_seed_source_type(),
+ ref_: String::new(),
+ quote: String::new(),
+ }
+ }
+}
+
+impl From for MemorySource {
+ fn from(source: WorkflowSeedMemorySource) -> Self {
+ Self {
+ source_type: source.source_type,
+ ref_: source.ref_,
+ quote: source.quote,
+ }
+ }
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct WorkflowSeedMemoryLink {
+ #[serde(rename = "type")]
+ link_type: String,
+ target: String,
+}
+
+impl From for MemoryLink {
+ fn from(link: WorkflowSeedMemoryLink) -> Self {
+ Self {
+ link_type: link.link_type,
+ target: link.target,
+ }
+ }
+}
+
+#[derive(Debug, Default, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct WorkflowSeedMemoryReview {
+ #[serde(default)]
+ needs_review: bool,
+ #[serde(default)]
+ review_reason: Option,
+ #[serde(default)]
+ reviewed_at: Option,
+ #[serde(default)]
+ reviewed_by: Option,
+}
+
+impl From for MemoryReview {
+ fn from(review: WorkflowSeedMemoryReview) -> Self {
+ Self {
+ needs_review: review.needs_review,
+ review_reason: review.review_reason,
+ reviewed_at: review.reviewed_at,
+ reviewed_by: review.reviewed_by,
+ }
+ }
+}
+
+impl WorkflowScenario {
+ pub fn validate(&self) -> TreeRingResult<()> {
+ validate_nonblank("workflow scenario name", &self.name)?;
+ validate_nonblank("workflow scenario task", &self.task)?;
+
+ if self.expected_files.is_empty() {
+ return Err(TreeRingError::Validation(
+ "workflow scenario requires at least one expected_file".to_string(),
+ ));
+ }
+
+ let mut workspace_paths = HashSet::new();
+ for (index, file) in self.workspace_files.iter().enumerate() {
+ let path_key = canonical_workflow_path(
+ &format!("workflow scenario workspace_files[{index}].path"),
+ &file.path,
+ )?;
+ if !workspace_paths.insert(path_key) {
+ return Err(TreeRingError::Validation(format!(
+ "workflow scenario workspace_files[{index}] duplicates path {}",
+ file.path
+ )));
+ }
+ }
+
+ let mut expected_file_checks = HashSet::new();
+ for (index, expectation) in self.expected_files.iter().enumerate() {
+ let path_key = canonical_workflow_path(
+ &format!("workflow scenario expected_files[{index}].path"),
+ &expectation.path,
+ )?;
+ let check_key = match expectation
+ .check_mode(&format!("workflow scenario expected_files[{index}]"))?
+ {
+ WorkflowFileCheckMode::Contains(contains) => format!("contains:{contains}"),
+ WorkflowFileCheckMode::JsonFields(json_fields) => {
+ format!("json_fields:{}", serde_json::to_string(json_fields)?)
+ }
+ };
+ if !expected_file_checks.insert((path_key, check_key)) {
+ return Err(TreeRingError::Validation(format!(
+ "workflow scenario expected_files[{index}] duplicates path and check"
+ )));
+ }
+ }
+
+ let mut seed_memory_ids = HashSet::new();
+ for (index, memory) in self.seed_memories.iter().enumerate() {
+ validate_nonblank(
+ &format!("workflow scenario seed_memories[{index}].id"),
+ &memory.id,
+ )?;
+ if !seed_memory_ids.insert(memory.id.as_str()) {
+ return Err(TreeRingError::Validation(format!(
+ "workflow scenario seed_memories[{index}] duplicates memory id {}",
+ memory.id
+ )));
+ }
+ memory.validate()?;
+ }
+
+ Ok(())
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct WorkflowWorkspaceFile {
+ pub path: String,
+ pub content: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct WorkflowFileExpectation {
+ pub path: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub contains: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub json_fields: Option>,
+}
+
+impl WorkflowFileExpectation {
+ fn check_mode(&self, field: &str) -> TreeRingResult> {
+ match (&self.contains, &self.json_fields) {
+ (Some(contains), None) => {
+ validate_nonblank(&format!("{field}.contains"), contains)?;
+ Ok(WorkflowFileCheckMode::Contains(contains))
+ }
+ (None, Some(json_fields)) => {
+ if json_fields.is_empty() {
+ return Err(TreeRingError::Validation(format!(
+ "{field}.json_fields requires at least one JSON pointer"
+ )));
+ }
+ for pointer in json_fields.keys() {
+ validate_json_pointer(&format!("{field}.json_fields"), pointer)?;
+ }
+ Ok(WorkflowFileCheckMode::JsonFields(json_fields))
+ }
+ _ => Err(TreeRingError::Validation(format!(
+ "{field} requires exactly one check mode: contains or json_fields"
+ ))),
+ }
+ }
+}
+
+enum WorkflowFileCheckMode<'a> {
+ Contains(&'a str),
+ JsonFields(&'a BTreeMap),
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct WorkflowMemoryContext {
+ pub id: String,
+ pub summary: String,
+ pub details: String,
+ pub ring: String,
+ pub event_type: String,
+ pub source_ref: String,
+ pub confidence: f64,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct WorkflowAgentRequest {
+ pub schema_version: u8,
+ pub scenario_id: String,
+ pub arm: WorkflowArm,
+ pub task: String,
+ pub workspace_root: PathBuf,
+ pub memory_context: Vec,
+}
+
+impl WorkflowAgentRequest {
+ pub fn new(
+ scenario_id: String,
+ arm: WorkflowArm,
+ task: String,
+ workspace_root: PathBuf,
+ memory_context: Vec,
+ ) -> Self {
+ Self {
+ schema_version: WORKFLOW_SCHEMA_VERSION,
+ scenario_id,
+ arm,
+ task,
+ workspace_root,
+ memory_context,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct WorkflowAgentResponse {
+ pub summary: String,
+ #[serde(default)]
+ pub used_memory_ids: Vec,
+}
+
+impl WorkflowAgentResponse {
+ pub fn validate(&self) -> TreeRingResult<()> {
+ validate_nonblank("workflow agent response summary", &self.summary)?;
+
+ let mut used_memory_ids = HashSet::new();
+ for (index, memory_id) in self.used_memory_ids.iter().enumerate() {
+ validate_nonblank(
+ &format!("workflow agent response used_memory_ids[{index}]"),
+ memory_id,
+ )?;
+ if !used_memory_ids.insert(memory_id.as_str()) {
+ return Err(TreeRingError::Validation(format!(
+ "workflow agent response used_memory_ids[{index}] duplicates memory id {memory_id}"
+ )));
+ }
+ }
+
+ Ok(())
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct WorkflowFileCheckReport {
+ pub path: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub contains: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub json_fields: Option>,
+ pub exists: bool,
+ pub passed: bool,
+}
+
+pub fn parse_workflow_scenario(input: &str) -> TreeRingResult {
+ let scenario: WorkflowScenario = serde_json::from_str(input)?;
+ scenario.validate()?;
+ Ok(scenario)
+}
+
+pub fn evaluate_workspace(
+ scenario: &WorkflowScenario,
+ workspace_root: &Path,
+) -> Vec {
+ scenario
+ .expected_files
+ .iter()
+ .map(|expectation| {
+ let (exists, passed) = if let Ok(path_key) =
+ canonical_workflow_path("workflow file expectation path", &expectation.path)
+ {
+ match read_regular_workspace_file(workspace_root, &path_key) {
+ Some(content) => (true, evaluate_file_content(expectation, &content)),
+ None => (false, false),
+ }
+ } else {
+ (false, false)
+ };
+
+ WorkflowFileCheckReport {
+ path: expectation.path.clone(),
+ contains: expectation.contains.clone(),
+ json_fields: expectation.json_fields.clone(),
+ exists,
+ passed,
+ }
+ })
+ .collect()
+}
+
+fn read_regular_workspace_file(workspace_root: &Path, path_key: &str) -> Option {
+ let mut file = open_regular_workspace_file(workspace_root, path_key)?;
+ let mut content = String::new();
+ file.read_to_string(&mut content).ok()?;
+ Some(content)
+}
+
+#[cfg(unix)]
+fn open_regular_workspace_file(workspace_root: &Path, path_key: &str) -> Option {
+ let mut directory = open_workspace_directory_no_follow(workspace_root)?;
+ let mut segments = path_key.split('/').peekable();
+
+ while let Some(segment) = segments.next() {
+ let is_final = segments.peek().is_none();
+ let file = open_child_no_follow(&directory, OsStr::new(segment), is_final)?;
+
+ if is_final {
+ let metadata = file.metadata().ok()?;
+ // A link count above one can make the output name an alias for data outside the
+ // workspace. Workflow artifacts do not need hard-link semantics, so reject them.
+ return (metadata.is_file() && metadata.nlink() == 1).then_some(file);
+ }
+
+ if !file.metadata().ok()?.is_dir() {
+ return None;
+ }
+ directory = file;
+ }
+
+ None
+}
+
+#[cfg(not(unix))]
+fn open_regular_workspace_file(_workspace_root: &Path, _path_key: &str) -> Option {
+ // The evaluator fails closed until a descriptor-relative, no-follow implementation is
+ // available for this platform. A pathname-based fallback would reintroduce a TOCTOU read.
+ None
+}
+
+#[cfg(unix)]
+fn open_workspace_directory_no_follow(workspace_root: &Path) -> Option {
+ if workspace_root.as_os_str().is_empty() {
+ return None;
+ }
+
+ let is_absolute = workspace_root.is_absolute();
+ let mut directory = open_trusted_directory_anchor(is_absolute)?;
+
+ for component in workspace_root.components() {
+ match component {
+ Component::RootDir if is_absolute => {}
+ Component::CurDir => {}
+ Component::Normal(segment) => {
+ let next_directory = open_child_no_follow(&directory, segment, false)?;
+ if !next_directory.metadata().ok()?.is_dir() {
+ return None;
+ }
+ directory = next_directory;
+ }
+ Component::RootDir | Component::ParentDir | Component::Prefix(_) => return None,
+ }
+ }
+
+ Some(directory)
+}
+
+#[cfg(unix)]
+fn open_trusted_directory_anchor(is_absolute: bool) -> Option {
+ let anchor = CString::new(if is_absolute { "/" } else { "." }).ok()?;
+ let flags =
+ libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_NONBLOCK;
+ let descriptor = unsafe {
+ // SAFETY: `anchor` is one of the NUL-terminated paths "/" or ".", and the returned
+ // descriptor is immediately owned by `File`, which closes it on every return path.
+ // Workspace paths are never opened by pathname.
+ libc::open(anchor.as_ptr(), flags)
+ };
+ let directory = file_from_descriptor(descriptor)?;
+ directory.metadata().ok()?.is_dir().then_some(directory)
+}
+
+#[cfg(unix)]
+fn open_child_no_follow(directory: &fs::File, segment: &OsStr, is_final: bool) -> Option {
+ let segment = CString::new(segment.as_bytes()).ok()?;
+ let mut flags = libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK;
+ if !is_final {
+ flags |= libc::O_DIRECTORY;
+ }
+ let descriptor = unsafe {
+ // SAFETY: `directory` owns a live directory descriptor, and `segment` is a NUL-terminated
+ // single path component. The returned descriptor is immediately owned by `File`.
+ libc::openat(directory.as_raw_fd(), segment.as_ptr(), flags)
+ };
+ file_from_descriptor(descriptor)
+}
+
+#[cfg(unix)]
+fn file_from_descriptor(descriptor: libc::c_int) -> Option {
+ if descriptor < 0 {
+ return None;
+ }
+
+ Some(unsafe {
+ // SAFETY: `open` and `openat` return an owned descriptor when it is non-negative.
+ fs::File::from_raw_fd(descriptor)
+ })
+}
+
+fn validate_nonblank(field: &str, value: &str) -> TreeRingResult<()> {
+ if value.trim().is_empty() {
+ return Err(TreeRingError::Validation(format!("{field} is required")));
+ }
+ Ok(())
+}
+
+fn evaluate_file_content(expectation: &WorkflowFileExpectation, content: &str) -> bool {
+ match expectation.check_mode("workflow file expectation") {
+ Ok(WorkflowFileCheckMode::Contains(contains)) => content.contains(contains),
+ Ok(WorkflowFileCheckMode::JsonFields(json_fields)) => {
+ serde_json::from_str::(content)
+ .map(|document| {
+ json_fields.iter().all(|(pointer, expected_value)| {
+ document
+ .pointer(pointer)
+ .is_some_and(|actual_value| actual_value == expected_value)
+ })
+ })
+ .unwrap_or(false)
+ }
+ Err(_) => false,
+ }
+}
+
+fn validate_json_pointer(field: &str, pointer: &str) -> TreeRingResult<()> {
+ if pointer.is_empty() {
+ return Ok(());
+ }
+ if !pointer.starts_with('/') {
+ return Err(TreeRingError::Validation(format!(
+ "{field} JSON pointer {pointer:?} must be empty or start with /"
+ )));
+ }
+
+ let mut characters = pointer.chars();
+ while let Some(character) = characters.next() {
+ if character == '~' {
+ match characters.next() {
+ Some('0' | '1') => {}
+ _ => {
+ return Err(TreeRingError::Validation(format!(
+ "{field} JSON pointer {pointer:?} has an invalid ~ escape"
+ )));
+ }
+ }
+ }
+ }
+
+ Ok(())
+}
+
+fn deserialize_seed_memories<'de, D>(deserializer: D) -> Result, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ Vec::::deserialize(deserializer)
+ .map(|seed_memories| seed_memories.into_iter().map(Into::into).collect())
+}
+
+fn default_workflow_seed_scope() -> String {
+ "global".to_string()
+}
+
+fn default_workflow_seed_ring() -> String {
+ "cambium".to_string()
+}
+
+fn default_workflow_seed_score() -> f64 {
+ 0.5
+}
+
+fn default_workflow_seed_sensitivity() -> String {
+ "normal".to_string()
+}
+
+fn default_workflow_seed_retention() -> String {
+ "normal".to_string()
+}
+
+fn default_workflow_seed_source_type() -> String {
+ "manual".to_string()
+}
+
+fn canonical_workflow_path(field: &str, value: &str) -> TreeRingResult {
+ if value.trim().is_empty() {
+ return Err(TreeRingError::Validation(format!(
+ "{field} must not be empty"
+ )));
+ }
+
+ if value.starts_with('/') || value.starts_with('\\') {
+ return Err(TreeRingError::Validation(format!(
+ "{field} must be relative"
+ )));
+ }
+ if has_windows_drive_prefix(value) {
+ return Err(TreeRingError::Validation(format!(
+ "{field} must not use a Windows drive prefix"
+ )));
+ }
+ if value.contains('\\') {
+ return Err(TreeRingError::Validation(format!(
+ "{field} must use forward slash separators"
+ )));
+ }
+
+ let mut segments = Vec::new();
+ for segment in value.split('/') {
+ if segment.is_empty() {
+ return Err(TreeRingError::Validation(format!(
+ "{field} must not contain empty path components"
+ )));
+ }
+ if segment == "." {
+ return Err(TreeRingError::Validation(format!(
+ "{field} must not contain current directory components"
+ )));
+ }
+ if segment == ".." {
+ return Err(TreeRingError::Validation(format!(
+ "{field} must not contain parent directory components"
+ )));
+ }
+ segments.push(segment);
+ }
+
+ Ok(segments.join("/"))
+}
+
+fn has_windows_drive_prefix(value: &str) -> bool {
+ let bytes = value.as_bytes();
+ bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
+}
+
+#[cfg(test)]
+mod tests {
+ use std::fs;
+
+ #[cfg(unix)]
+ use std::io::Read;
+
+ #[cfg(unix)]
+ use std::path::{Path, PathBuf};
+
+ use serde_json::json;
+ use tempfile::tempdir;
+
+ use super::{evaluate_workspace, WorkflowFileExpectation, WorkflowScenario};
+
+ #[cfg(unix)]
+ use super::{open_regular_workspace_file, open_workspace_directory_no_follow};
+
+ fn scenario_with_expectations(
+ expected_files: Vec,
+ ) -> WorkflowScenario {
+ WorkflowScenario {
+ name: "workspace evaluation".to_string(),
+ task: "Check the generated files.".to_string(),
+ seed_memories: Vec::new(),
+ workspace_files: Vec::new(),
+ expected_files,
+ }
+ }
+
+ #[cfg(not(unix))]
+ #[test]
+ fn evaluate_workspace_fails_closed_without_descriptor_relative_support() {
+ let workspace = tempdir().unwrap();
+ fs::write(
+ workspace.path().join("decision.md"),
+ "Choose the safe action.",
+ )
+ .unwrap();
+ let scenario = scenario_with_expectations(vec![WorkflowFileExpectation {
+ path: "decision.md".to_string(),
+ contains: Some("safe action".to_string()),
+ json_fields: None,
+ }]);
+
+ let reports = evaluate_workspace(&scenario, workspace.path());
+
+ assert_eq!(reports.len(), 1);
+ assert!(!reports[0].exists);
+ assert!(!reports[0].passed);
+ }
+
+ #[cfg(unix)]
+ fn physical_path(path: &Path) -> PathBuf {
+ fs::canonicalize(path).expect("test path must resolve to its physical location")
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn evaluate_workspace_rejects_a_final_symlinked_expected_file() {
+ use std::os::unix::fs::symlink;
+
+ let workspace = tempdir().unwrap();
+ let outside = tempdir().unwrap();
+ let external_file = outside.path().join("decision.md");
+ fs::write(&external_file, "Choose the safe action.").unwrap();
+ symlink(&external_file, workspace.path().join("decision.md")).unwrap();
+ let scenario = scenario_with_expectations(vec![WorkflowFileExpectation {
+ path: "decision.md".to_string(),
+ contains: Some("safe action".to_string()),
+ json_fields: None,
+ }]);
+
+ let reports = evaluate_workspace(&scenario, &physical_path(workspace.path()));
+
+ assert_eq!(reports.len(), 1);
+ assert!(!reports[0].exists);
+ assert!(!reports[0].passed);
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn evaluate_workspace_rejects_a_hard_linked_expected_file() {
+ let root = tempdir().unwrap();
+ let workspace_path = root.path().join("workspace");
+ let outside_path = root.path().join("outside");
+ fs::create_dir(&workspace_path).unwrap();
+ fs::create_dir(&outside_path).unwrap();
+ let external_file = outside_path.join("decision.md");
+ fs::write(&external_file, "Choose the safe action.").unwrap();
+ fs::hard_link(&external_file, workspace_path.join("decision.md")).unwrap();
+ let scenario = scenario_with_expectations(vec![WorkflowFileExpectation {
+ path: "decision.md".to_string(),
+ contains: Some("safe action".to_string()),
+ json_fields: None,
+ }]);
+
+ let reports = evaluate_workspace(&scenario, &physical_path(&workspace_path));
+
+ assert_eq!(reports.len(), 1);
+ assert!(!reports[0].exists);
+ assert!(!reports[0].passed);
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn evaluate_workspace_rejects_a_symlinked_workspace_root() {
+ use std::os::unix::fs::symlink;
+
+ let workspace = tempdir().unwrap();
+ let parent = tempdir().unwrap();
+ let workspace_path = physical_path(workspace.path());
+ let parent_path = physical_path(parent.path());
+ fs::write(
+ workspace_path.join("decision.md"),
+ "Choose the safe action.",
+ )
+ .unwrap();
+ let linked_workspace = parent_path.join("linked-workspace");
+ symlink(&workspace_path, &linked_workspace).unwrap();
+ let scenario = scenario_with_expectations(vec![WorkflowFileExpectation {
+ path: "decision.md".to_string(),
+ contains: Some("safe action".to_string()),
+ json_fields: None,
+ }]);
+
+ let reports = evaluate_workspace(&scenario, &linked_workspace);
+
+ assert_eq!(reports.len(), 1);
+ assert!(!reports[0].exists);
+ assert!(!reports[0].passed);
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn evaluate_workspace_rejects_a_symlinked_ancestor_of_workspace_root() {
+ use std::os::unix::fs::symlink;
+
+ let root = tempdir().unwrap();
+ let root_path = physical_path(root.path());
+ let trusted_parent = root_path.join("trusted-parent");
+ let outside_parent = root_path.join("outside-parent");
+ fs::create_dir(&trusted_parent).unwrap();
+ fs::create_dir(&outside_parent).unwrap();
+ let outside_workspace = outside_parent.join("workspace");
+ fs::create_dir(&outside_workspace).unwrap();
+ fs::write(
+ outside_workspace.join("decision.md"),
+ "Choose the safe action.",
+ )
+ .unwrap();
+ symlink(&outside_parent, trusted_parent.join("route")).unwrap();
+ let routed_workspace = trusted_parent.join("route/workspace");
+ let scenario = scenario_with_expectations(vec![WorkflowFileExpectation {
+ path: "decision.md".to_string(),
+ contains: Some("safe action".to_string()),
+ json_fields: None,
+ }]);
+
+ let reports = evaluate_workspace(&scenario, &routed_workspace);
+
+ assert_eq!(reports.len(), 1);
+ assert!(!reports[0].exists);
+ assert!(!reports[0].passed);
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn workspace_directory_open_rejects_relative_parent_traversal() {
+ assert!(open_workspace_directory_no_follow(Path::new("../outside")).is_none());
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn evaluate_workspace_rejects_a_symlinked_parent_of_a_structured_expected_file() {
+ use std::os::unix::fs::symlink;
+
+ let workspace = tempdir().unwrap();
+ let outside = tempdir().unwrap();
+ let workspace_path = physical_path(workspace.path());
+ fs::write(
+ outside.path().join("decision.json"),
+ r#"{"decision": {"status": "approved"}}"#,
+ )
+ .unwrap();
+ symlink(outside.path(), workspace_path.join("out")).unwrap();
+ let scenario = scenario_with_expectations(vec![WorkflowFileExpectation {
+ path: "out/decision.json".to_string(),
+ contains: None,
+ json_fields: Some([("/decision/status".to_string(), json!("approved"))].into()),
+ }]);
+
+ let reports = evaluate_workspace(&scenario, &workspace_path);
+
+ assert_eq!(reports.len(), 1);
+ assert!(!reports[0].exists);
+ assert!(!reports[0].passed);
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn approved_workspace_file_stays_bound_after_its_path_is_replaced() {
+ use std::os::unix::fs::symlink;
+
+ let workspace = tempdir().unwrap();
+ let workspace_path = physical_path(workspace.path());
+ let outside = tempdir().unwrap();
+ let decision_path = workspace_path.join("decision.md");
+ fs::write(&decision_path, "approved workspace content").unwrap();
+ let external_file = outside.path().join("decision.md");
+ fs::write(&external_file, "external replacement content").unwrap();
+
+ let mut approved_file =
+ open_regular_workspace_file(&workspace_path, "decision.md").expect("regular file");
+ fs::rename(&decision_path, workspace_path.join("decision.previous.md")).unwrap();
+ symlink(&external_file, &decision_path).unwrap();
+
+ let mut content = String::new();
+ approved_file.read_to_string(&mut content).unwrap();
+
+ assert_eq!(content, "approved workspace content");
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn evaluate_workspace_accepts_regular_nested_files_for_both_check_modes() {
+ let workspace = tempdir().unwrap();
+ let workspace_path = physical_path(workspace.path());
+ fs::create_dir_all(workspace_path.join("out")).unwrap();
+ fs::write(
+ workspace_path.join("out/decision.md"),
+ "Choose the safe action.",
+ )
+ .unwrap();
+ fs::write(
+ workspace_path.join("out/decision.json"),
+ r#"{"decision": {"status": "approved"}}"#,
+ )
+ .unwrap();
+ let scenario = scenario_with_expectations(vec![
+ WorkflowFileExpectation {
+ path: "out/decision.md".to_string(),
+ contains: Some("safe action".to_string()),
+ json_fields: None,
+ },
+ WorkflowFileExpectation {
+ path: "out/decision.json".to_string(),
+ contains: None,
+ json_fields: Some([("/decision/status".to_string(), json!("approved"))].into()),
+ },
+ ]);
+
+ let reports = evaluate_workspace(&scenario, &workspace_path);
+
+ assert_eq!(reports.len(), 2);
+ assert!(reports.iter().all(|report| report.exists));
+ assert!(reports.iter().all(|report| report.passed));
+ }
+}
diff --git a/crates/tree-ring-memory-core/tests/workflow_scenario.rs b/crates/tree-ring-memory-core/tests/workflow_scenario.rs
new file mode 100644
index 0000000..def06b1
--- /dev/null
+++ b/crates/tree-ring-memory-core/tests/workflow_scenario.rs
@@ -0,0 +1,678 @@
+use std::{
+ collections::{BTreeMap, BTreeSet},
+ fs,
+ path::Path,
+};
+
+#[cfg(unix)]
+use std::path::PathBuf;
+
+use serde_json::{json, Value};
+use tempfile::tempdir;
+use tree_ring_memory_core::{
+ evaluate_workspace, parse_workflow_scenario, WorkflowAgentRequest, WorkflowAgentResponse,
+ WorkflowArm, WorkflowFileExpectation, WorkflowMemoryContext, WorkflowScenario,
+};
+
+const VALID_SCENARIO: &str = r#"{
+ "name": "safe workflow",
+ "task": "Prepare decision.md from the workspace.",
+ "seed_memories": [],
+ "workspace_files": [{"path": "proposal.md", "content": "draft"}],
+ "expected_files": [{"path": "decision.md", "contains": "safe action"}]
+}"#;
+
+fn scenario_value() -> Value {
+ serde_json::from_str(VALID_SCENARIO).unwrap()
+}
+
+fn scenario_with_seed_memory(seed_memory: Value) -> String {
+ let mut scenario = scenario_value();
+ scenario["seed_memories"] = json!([seed_memory]);
+ serde_json::to_string(&scenario).unwrap()
+}
+
+fn json_field_scenario(json_fields: Value) -> WorkflowScenario {
+ parse_workflow_scenario(
+ &serde_json::to_string(&json!({
+ "name": "structured workspace evaluation",
+ "task": "Check the generated JSON file.",
+ "workspace_files": [],
+ "expected_files": [{
+ "path": "decision.json",
+ "json_fields": json_fields
+ }]
+ }))
+ .unwrap(),
+ )
+ .unwrap()
+}
+
+#[cfg(unix)]
+fn physical_path(path: &Path) -> PathBuf {
+ fs::canonicalize(path).expect("test path must resolve to its physical location")
+}
+
+fn valid_seed_memory() -> Value {
+ json!({
+ "id": "mem_seed",
+ "created_at": "2026-01-01T00:00:00Z",
+ "updated_at": "2026-01-01T00:00:00Z",
+ "event_type": "decision",
+ "summary": "Preserve the approved durable action.",
+ "source": {
+ "type": "manual",
+ "ref": "docs/seed.md",
+ "quote": "approved action"
+ },
+ "links": [{"type": "supports", "target": "decision.md"}],
+ "review": {"needs_review": false}
+ })
+}
+
+#[test]
+fn parses_safe_workflow_fixture_and_keeps_validator_out_of_agent_request() {
+ let scenario = parse_workflow_scenario(VALID_SCENARIO).unwrap();
+ let request = WorkflowAgentRequest::new(
+ scenario.name.clone(),
+ WorkflowArm::NoMemory,
+ scenario.task.clone(),
+ "/tmp/trial".into(),
+ vec![WorkflowMemoryContext {
+ id: "mem_1".to_string(),
+ summary: "Use the approved decision format.".to_string(),
+ details: "Keep the durable action explicit.".to_string(),
+ ring: "heartwood".to_string(),
+ event_type: "decision".to_string(),
+ source_ref: "notes/approval.md".to_string(),
+ confidence: 0.9,
+ }],
+ );
+
+ let serialized = serde_json::to_string(&request).unwrap();
+ let request_fields = serde_json::to_value(&request).unwrap();
+
+ assert_eq!(scenario.workspace_files[0].path, "proposal.md");
+ assert_eq!(
+ scenario.expected_files[0].contains.as_deref(),
+ Some("safe action")
+ );
+ assert!(!serialized.contains("safe action"));
+ assert_eq!(
+ request_fields
+ .as_object()
+ .unwrap()
+ .keys()
+ .map(String::as_str)
+ .collect::>(),
+ vec![
+ "arm",
+ "memory_context",
+ "scenario_id",
+ "schema_version",
+ "task",
+ "workspace_root",
+ ]
+ );
+}
+
+#[test]
+fn keeps_json_field_validators_out_of_agent_request() {
+ let scenario = json_field_scenario(json!({
+ "/decision/status": "must-not-leak",
+ "/metadata/requires_review": false
+ }));
+ let request = WorkflowAgentRequest::new(
+ scenario.name.clone(),
+ WorkflowArm::TreeRing,
+ scenario.task.clone(),
+ "/tmp/trial".into(),
+ Vec::new(),
+ );
+ let serialized = serde_json::to_string(&request).unwrap();
+
+ assert!(scenario.expected_files[0].json_fields.is_some());
+ assert!(!serialized.contains("/decision/status"));
+ assert!(!serialized.contains("must-not-leak"));
+ assert!(!serialized.contains("expected_files"));
+}
+
+#[test]
+fn accepts_nested_relative_paths_and_rejects_unsafe_paths() {
+ let nested = VALID_SCENARIO
+ .replace("proposal.md", "inputs/proposal.md")
+ .replace("decision.md", "out/decision.md");
+ assert!(parse_workflow_scenario(&nested).is_ok());
+
+ for unsafe_path in ["../escape.txt", "/absolute.txt", ""] {
+ let input = VALID_SCENARIO.replace("proposal.md", unsafe_path);
+ assert!(
+ parse_workflow_scenario(&input).is_err(),
+ "{unsafe_path:?} must be rejected"
+ );
+ }
+}
+
+#[test]
+fn rejects_unknown_fixture_fields() {
+ let input = VALID_SCENARIO.replace("\n}", ",\n \"unexpected\": \"must not be accepted\"\n}");
+
+ assert!(parse_workflow_scenario(&input).is_err());
+}
+
+#[test]
+fn rejects_invalid_scenario_contract_values() {
+ let missing_expected_files = VALID_SCENARIO.replace(
+ "\n \"expected_files\": [{\"path\": \"decision.md\", \"contains\": \"safe action\"}]",
+ "\n \"expected_files\": []",
+ );
+ assert!(parse_workflow_scenario(&missing_expected_files).is_err());
+
+ let duplicate_workspace_path = VALID_SCENARIO.replace(
+ "[{\"path\": \"proposal.md\", \"content\": \"draft\"}]",
+ "[{\"path\": \"proposal.md\", \"content\": \"draft\"}, {\"path\": \"proposal.md\", \"content\": \"revised\"}]",
+ );
+ assert!(parse_workflow_scenario(&duplicate_workspace_path).is_err());
+
+ let duplicate_expectation = VALID_SCENARIO.replace(
+ "[{\"path\": \"decision.md\", \"contains\": \"safe action\"}]",
+ "[{\"path\": \"decision.md\", \"contains\": \"safe action\"}, {\"path\": \"decision.md\", \"contains\": \"safe action\"}]",
+ );
+ assert!(parse_workflow_scenario(&duplicate_expectation).is_err());
+
+ let blank_expected_content = VALID_SCENARIO.replace("safe action", " ");
+ assert!(parse_workflow_scenario(&blank_expected_content).is_err());
+}
+
+#[test]
+fn permits_multiple_distinct_checks_for_the_same_expected_file() {
+ let input = VALID_SCENARIO.replace(
+ "[{\"path\": \"decision.md\", \"contains\": \"safe action\"}]",
+ "[\
+ {\"path\": \"decision.md\", \"contains\": \"safe action\"},\
+ {\"path\": \"decision.md\", \"contains\": \"durable rationale\"}\
+ ]",
+ );
+
+ assert!(parse_workflow_scenario(&input).is_ok());
+}
+
+#[test]
+fn rejects_mixed_or_missing_file_check_modes() {
+ let mut mixed = scenario_value();
+ mixed["expected_files"] = json!([{
+ "path": "decision.json",
+ "contains": "approved",
+ "json_fields": {"/status": "approved"}
+ }]);
+ let mixed_error = parse_workflow_scenario(&serde_json::to_string(&mixed).unwrap())
+ .unwrap_err()
+ .to_string();
+ assert!(mixed_error.contains("exactly one check mode"));
+
+ let mut missing = scenario_value();
+ missing["expected_files"] = json!([{"path": "decision.json"}]);
+ let missing_error = parse_workflow_scenario(&serde_json::to_string(&missing).unwrap())
+ .unwrap_err()
+ .to_string();
+ assert!(missing_error.contains("exactly one check mode"));
+}
+
+#[test]
+fn rejects_empty_json_field_check_configurations() {
+ let mut scenario = scenario_value();
+ scenario["expected_files"] = json!([{
+ "path": "decision.json",
+ "json_fields": {}
+ }]);
+
+ let error = parse_workflow_scenario(&serde_json::to_string(&scenario).unwrap())
+ .unwrap_err()
+ .to_string();
+
+ assert!(error.contains("json_fields requires at least one JSON pointer"));
+}
+
+#[test]
+fn rejects_malformed_json_pointer_configurations() {
+ for pointer in ["decision/status", "/decision/~2status", "/decision/~"] {
+ let mut json_fields = serde_json::Map::new();
+ json_fields.insert(pointer.to_string(), json!("approved"));
+ let mut scenario = scenario_value();
+ scenario["expected_files"] = json!([{
+ "path": "decision.json",
+ "json_fields": json_fields
+ }]);
+
+ assert!(
+ parse_workflow_scenario(&serde_json::to_string(&scenario).unwrap()).is_err(),
+ "{pointer:?} must be rejected"
+ );
+ }
+}
+
+#[test]
+fn rejects_invalid_seed_memories() {
+ let input = VALID_SCENARIO.replace(
+ "\"seed_memories\": []",
+ r#""seed_memories": [{
+ "id": "mem_bad",
+ "created_at": "2026-01-01T00:00:00Z",
+ "updated_at": "2026-01-01T00:00:00Z",
+ "event_type": "decision",
+ "summary": ""
+ }]"#,
+ );
+
+ assert!(parse_workflow_scenario(&input).is_err());
+}
+
+#[test]
+fn rejects_blank_and_cross_sensitivity_duplicate_seed_memory_ids() {
+ let mut blank_id = valid_seed_memory();
+ blank_id["id"] = json!(" ");
+ let blank_error = parse_workflow_scenario(&scenario_with_seed_memory(blank_id))
+ .unwrap_err()
+ .to_string();
+ assert!(blank_error.contains("seed_memories[0].id"));
+
+ let mut normal = valid_seed_memory();
+ normal["id"] = json!("mem_shared");
+ normal["sensitivity"] = json!("normal");
+ let mut private = valid_seed_memory();
+ private["id"] = json!("mem_shared");
+ private["sensitivity"] = json!("private");
+ let mut scenario = scenario_value();
+ scenario["seed_memories"] = json!([normal, private]);
+
+ let duplicate_error = parse_workflow_scenario(&serde_json::to_string(&scenario).unwrap())
+ .unwrap_err()
+ .to_string();
+ assert!(duplicate_error.contains("duplicates memory id mem_shared"));
+}
+
+#[test]
+fn rejects_unknown_seed_memory_fields_at_every_level() {
+ let mut unknown_memory_field = valid_seed_memory();
+ unknown_memory_field["unexpected"] = json!(true);
+ assert!(parse_workflow_scenario(&scenario_with_seed_memory(unknown_memory_field)).is_err());
+
+ let mut unknown_source_field = valid_seed_memory();
+ unknown_source_field["source"]["unexpected"] = json!(true);
+ assert!(parse_workflow_scenario(&scenario_with_seed_memory(unknown_source_field)).is_err());
+
+ let mut unknown_link_field = valid_seed_memory();
+ unknown_link_field["links"][0]["unexpected"] = json!(true);
+ assert!(parse_workflow_scenario(&scenario_with_seed_memory(unknown_link_field)).is_err());
+
+ let mut unknown_review_field = valid_seed_memory();
+ unknown_review_field["review"]["unexpected"] = json!(true);
+ assert!(parse_workflow_scenario(&scenario_with_seed_memory(unknown_review_field)).is_err());
+}
+
+#[test]
+fn uses_the_documented_workflow_arm_serde_names() {
+ assert_eq!(
+ serde_json::to_string(&WorkflowArm::NoMemory).unwrap(),
+ "\"no_memory\""
+ );
+ assert_eq!(
+ serde_json::to_string(&WorkflowArm::RawMemory).unwrap(),
+ "\"raw_memory\""
+ );
+ assert_eq!(
+ serde_json::to_string(&WorkflowArm::TreeRing).unwrap(),
+ "\"tree_ring\""
+ );
+}
+
+#[test]
+fn validates_agent_responses_without_validating_context_membership() {
+ let valid = WorkflowAgentResponse {
+ summary: "Created decision.md.".to_string(),
+ used_memory_ids: vec!["mem_1".to_string(), "mem_2".to_string()],
+ };
+ assert!(valid.validate().is_ok());
+
+ for response in [
+ WorkflowAgentResponse {
+ summary: " ".to_string(),
+ used_memory_ids: Vec::new(),
+ },
+ WorkflowAgentResponse {
+ summary: "Done".to_string(),
+ used_memory_ids: vec![" ".to_string()],
+ },
+ WorkflowAgentResponse {
+ summary: "Done".to_string(),
+ used_memory_ids: vec!["mem_1".to_string(), "mem_1".to_string()],
+ },
+ ] {
+ assert!(response.validate().is_err());
+ }
+
+ assert!(serde_json::from_str::(
+ r#"{"summary":"Done","unexpected":true}"#
+ )
+ .is_err());
+}
+
+#[cfg(unix)]
+#[test]
+fn evaluates_expected_files_in_fixture_order() {
+ let scenario = parse_workflow_scenario(
+ r#"{
+ "name": "workspace evaluation",
+ "task": "Check the generated files.",
+ "workspace_files": [],
+ "expected_files": [
+ {"path": "decision.md", "contains": "safe action"},
+ {"path": "missing.md", "contains": "must exist"}
+ ]
+ }"#,
+ )
+ .unwrap();
+ let workspace = tempdir().unwrap();
+ fs::write(
+ workspace.path().join("decision.md"),
+ "Choose the safe action.",
+ )
+ .unwrap();
+
+ let reports = evaluate_workspace(&scenario, &physical_path(workspace.path()));
+
+ assert_eq!(reports.len(), 2);
+ assert_eq!(reports[0].path, "decision.md");
+ assert!(reports[0].exists);
+ assert!(reports[0].passed);
+ assert_eq!(reports[1].path, "missing.md");
+ assert!(!reports[1].exists);
+ assert!(!reports[1].passed);
+}
+
+#[cfg(unix)]
+#[test]
+fn retains_legacy_contains_file_checks() {
+ let scenario = parse_workflow_scenario(VALID_SCENARIO).unwrap();
+ let workspace = tempdir().unwrap();
+ fs::write(
+ workspace.path().join("decision.md"),
+ "Choose the safe action with durable rationale.",
+ )
+ .unwrap();
+
+ let reports = evaluate_workspace(&scenario, &physical_path(workspace.path()));
+
+ assert_eq!(reports.len(), 1);
+ assert_eq!(reports[0].contains.as_deref(), Some("safe action"));
+ assert!(reports[0].json_fields.is_none());
+ assert!(reports[0].passed);
+}
+
+#[cfg(unix)]
+#[test]
+fn evaluates_json_field_expectations_with_exact_json_pointer_values() {
+ let scenario = parse_workflow_scenario(
+ r#"{
+ "name": "structured workspace evaluation",
+ "task": "Check the generated JSON file.",
+ "workspace_files": [],
+ "expected_files": [{
+ "path": "decision.json",
+ "json_fields": {
+ "/decision/status": "approved",
+ "/decision/retry_count": 0,
+ "/metadata/requires_review": false
+ }
+ }]
+ }"#,
+ )
+ .unwrap();
+ let workspace = tempdir().unwrap();
+ fs::write(
+ workspace.path().join("decision.json"),
+ r#"{
+ "decision": {"status": "approved", "retry_count": 0},
+ "metadata": {"requires_review": false}
+ }"#,
+ )
+ .unwrap();
+
+ let reports = evaluate_workspace(&scenario, &physical_path(workspace.path()));
+
+ assert_eq!(reports.len(), 1);
+ assert!(reports[0].exists);
+ assert!(reports[0].passed);
+ assert!(reports[0].contains.is_none());
+ assert_eq!(
+ reports[0].json_fields,
+ Some(
+ [
+ ("/decision/retry_count".to_string(), json!(0)),
+ ("/decision/status".to_string(), json!("approved")),
+ ("/metadata/requires_review".to_string(), json!(false)),
+ ]
+ .into_iter()
+ .collect::>(),
+ )
+ );
+ let serialized_report = serde_json::to_string(&reports[0]).unwrap();
+ assert!(
+ serialized_report.find("/decision/retry_count").unwrap()
+ < serialized_report.find("/decision/status").unwrap()
+ );
+ assert!(serialized_report.contains("\"json_fields\""));
+}
+
+#[cfg(unix)]
+#[test]
+fn marks_json_field_check_failed_when_a_pointer_is_missing() {
+ let scenario = json_field_scenario(json!({
+ "/decision/status": "approved",
+ "/decision/owner": "release-team"
+ }));
+ let workspace = tempdir().unwrap();
+ fs::write(
+ workspace.path().join("decision.json"),
+ r#"{"decision": {"status": "approved"}}"#,
+ )
+ .unwrap();
+
+ let reports = evaluate_workspace(&scenario, &physical_path(workspace.path()));
+
+ assert!(reports[0].exists);
+ assert!(!reports[0].passed);
+}
+
+#[cfg(unix)]
+#[test]
+fn marks_json_field_check_failed_when_a_value_is_wrong() {
+ let scenario = json_field_scenario(json!({"/decision/status": "approved"}));
+ let workspace = tempdir().unwrap();
+ fs::write(
+ workspace.path().join("decision.json"),
+ r#"{"decision": {"status": "rejected"}}"#,
+ )
+ .unwrap();
+
+ let reports = evaluate_workspace(&scenario, &physical_path(workspace.path()));
+
+ assert!(reports[0].exists);
+ assert!(!reports[0].passed);
+}
+
+#[cfg(unix)]
+#[test]
+fn marks_json_field_check_failed_when_file_is_not_json() {
+ let scenario = json_field_scenario(json!({"/decision/status": "approved"}));
+ let workspace = tempdir().unwrap();
+ fs::write(workspace.path().join("decision.json"), "not JSON").unwrap();
+
+ let reports = evaluate_workspace(&scenario, &physical_path(workspace.path()));
+
+ assert!(reports[0].exists);
+ assert!(!reports[0].passed);
+}
+
+#[test]
+fn workspace_evaluation_does_not_follow_unsafe_paths_from_manually_built_scenarios() {
+ let root = tempdir().unwrap();
+ let workspace = root.path().join("workspace");
+ fs::create_dir(&workspace).unwrap();
+ fs::write(root.path().join("escape.md"), "safe action").unwrap();
+ let scenario = WorkflowScenario {
+ name: "unsafe direct construction".to_string(),
+ task: "Do not escape the workspace.".to_string(),
+ seed_memories: Vec::new(),
+ workspace_files: Vec::new(),
+ expected_files: vec![WorkflowFileExpectation {
+ path: "../escape.md".to_string(),
+ contains: Some("safe action".to_string()),
+ json_fields: None,
+ }],
+ };
+
+ let reports = evaluate_workspace(&scenario, &workspace);
+
+ assert_eq!(reports.len(), 1);
+ assert!(!reports[0].exists);
+ assert!(!reports[0].passed);
+}
+
+#[test]
+fn rejects_root_prefix_and_backslash_separator_paths() {
+ for unsafe_path in [
+ "/escape.txt",
+ r"\\escape.txt",
+ "C:escape.txt",
+ r"out\decision.md",
+ ] {
+ let mut scenario = scenario_value();
+ scenario["workspace_files"][0]["path"] = json!(unsafe_path);
+ let input = serde_json::to_string(&scenario).unwrap();
+
+ assert!(
+ parse_workflow_scenario(&input).is_err(),
+ "{unsafe_path:?} must be rejected"
+ );
+ }
+}
+
+#[test]
+fn workspace_evaluation_rejects_windows_root_and_prefix_paths() {
+ let workspace = tempdir().unwrap();
+
+ for unsafe_path in [r"\\escape.txt", "C:escape.txt"] {
+ fs::write(workspace.path().join(unsafe_path), "safe action").unwrap();
+ let scenario = WorkflowScenario {
+ name: "unsafe direct construction".to_string(),
+ task: "Do not escape the workspace.".to_string(),
+ seed_memories: Vec::new(),
+ workspace_files: Vec::new(),
+ expected_files: vec![WorkflowFileExpectation {
+ path: unsafe_path.to_string(),
+ contains: Some("safe action".to_string()),
+ json_fields: None,
+ }],
+ };
+
+ let reports = evaluate_workspace(&scenario, workspace.path());
+
+ assert_eq!(reports.len(), 1);
+ assert!(!reports[0].exists, "{unsafe_path:?} must not be read");
+ assert!(!reports[0].passed, "{unsafe_path:?} must not pass");
+ }
+}
+
+#[test]
+fn workflow_proof_fixtures_parse_and_cover_the_required_scenarios() {
+ let fixture_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .join("fixtures/workflow-proof");
+ let entries = fs::read_dir(&fixture_dir)
+ .unwrap_or_else(|error| panic!("read {}: {error}", fixture_dir.display()));
+ let mut scenario_names = BTreeSet::new();
+
+ for entry in entries {
+ let path = entry.unwrap().path();
+ if path
+ .extension()
+ .is_some_and(|extension| extension == "json")
+ {
+ let input = fs::read_to_string(&path)
+ .unwrap_or_else(|error| panic!("read {}: {error}", path.display()));
+ let scenario = parse_workflow_scenario(&input)
+ .unwrap_or_else(|error| panic!("parse {}: {error}", path.display()));
+ assert!(
+ !scenario.expected_files.is_empty(),
+ "{} must define an expected file",
+ path.display()
+ );
+ scenario_names.insert(scenario.name);
+ }
+ }
+
+ for required_name in [
+ "no-background-writer",
+ "stale-cli-contract",
+ "scar-recovery",
+ ] {
+ assert!(
+ scenario_names.contains(required_name),
+ "missing workflow proof scenario {required_name}"
+ );
+ }
+}
+
+#[test]
+fn workspace_evaluation_rejects_portable_root_and_backslash_separator_paths() {
+ let workspace = tempdir().unwrap();
+ fs::create_dir_all(workspace.path().join("out")).unwrap();
+ fs::write(workspace.path().join(r"out\decision.md"), "safe action").unwrap();
+ let scenario = WorkflowScenario {
+ name: "unsafe direct construction".to_string(),
+ task: "Do not escape the workspace.".to_string(),
+ seed_memories: Vec::new(),
+ workspace_files: Vec::new(),
+ expected_files: vec![
+ WorkflowFileExpectation {
+ path: "/escape.txt".to_string(),
+ contains: Some("safe action".to_string()),
+ json_fields: None,
+ },
+ WorkflowFileExpectation {
+ path: r"out\decision.md".to_string(),
+ contains: Some("safe action".to_string()),
+ json_fields: None,
+ },
+ ],
+ };
+
+ let reports = evaluate_workspace(&scenario, workspace.path());
+
+ assert_eq!(reports.len(), 2);
+ assert!(reports.iter().all(|report| !report.exists));
+ assert!(reports.iter().all(|report| !report.passed));
+}
+
+#[test]
+fn rejects_dot_path_components_that_create_lexical_aliases() {
+ let mut scenario = scenario_value();
+ scenario["workspace_files"] = json!([
+ {"path": "out/./decision.md", "content": "draft"},
+ {"path": "out/decision.md", "content": "revised"}
+ ]);
+
+ assert!(parse_workflow_scenario(&serde_json::to_string(&scenario).unwrap()).is_err());
+}
+
+#[test]
+fn rejects_empty_path_components_that_create_lexical_aliases() {
+ let mut scenario = scenario_value();
+ scenario["workspace_files"] = json!([
+ {"path": "out//decision.md", "content": "draft"},
+ {"path": "out/decision.md", "content": "revised"}
+ ]);
+
+ assert!(parse_workflow_scenario(&serde_json::to_string(&scenario).unwrap()).is_err());
+}
diff --git a/crates/tree-ring-memory-sqlite/Cargo.toml b/crates/tree-ring-memory-sqlite/Cargo.toml
index ac95b47..0f7d3c6 100644
--- a/crates/tree-ring-memory-sqlite/Cargo.toml
+++ b/crates/tree-ring-memory-sqlite/Cargo.toml
@@ -14,7 +14,7 @@ categories = ["database-implementations", "development-tools"]
[dependencies]
rusqlite.workspace = true
serde_json.workspace = true
-tree-ring-memory-core = { path = "../tree-ring-memory-core", version = "0.11.0" }
+tree-ring-memory-core = { path = "../tree-ring-memory-core", version = "0.12.0" }
[dev-dependencies]
tempfile.workspace = true
diff --git a/docs/feed.xml b/docs/feed.xml
index d6314f4..f5953cb 100644
--- a/docs/feed.xml
+++ b/docs/feed.xml
@@ -5,12 +5,20 @@
https://terminallylazy.github.io/Tree-Ring-Memory/
- 2026-07-07T09:07:25Z
+ 2026-07-15T22:09:47Z
Tree Ring Memory
https://github.com/TerminallyLazy/Tree-Ring-Memory
+
+ Tree Ring Memory v0.12.0 workflow-proof preview is live
+ https://github.com/TerminallyLazy/Tree-Ring-Memory/releases/tag/v0.12.0
+
+ 2026-07-15T22:09:47Z
+ Tree Ring Memory v0.12.0 adds a controlled agent-workflow proof that retains paired workspaces, records the explicit model identity, and validates exact structured decisions. The proof reports only its observed fixture outcomes; it is not a universal memory benchmark.
+
+
Tree Ring Memory submitted to Awesome Command Line Tools
https://github.com/ad-si/awesome-command-line-tools/pull/2
diff --git a/docs/index.html b/docs/index.html
index 61ec65c..f9e273b 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -28,7 +28,7 @@
"programmingLanguage": "Rust",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "macOS, Linux",
- "version": "0.11.0",
+ "version": "0.12.0",
"image": "https://terminallylazy.github.io/Tree-Ring-Memory/assets/tree-ring-memory-og.png",
"isAccessibleForFree": true
}
diff --git a/docs/integrations/agent-workflow-proof.md b/docs/integrations/agent-workflow-proof.md
new file mode 100644
index 0000000..0d117f7
--- /dev/null
+++ b/docs/integrations/agent-workflow-proof.md
@@ -0,0 +1,67 @@
+# Agent Workflow Proof
+
+The workflow-proof example is an explicit, controlled comparison for three
+small synthetic, project-safe scenarios. It is not a normal `tree-ring`
+subcommand, background process, CI job, or universal benchmark.
+
+Run it deliberately from a source checkout with a locally available Codex CLI:
+
+```bash
+cargo run --locked -p tree-ring-memory-cli --example workflow_proof -- \
+ fixtures/workflow-proof target/tree-ring-certification/workflow-proof \
+ --model
+```
+
+`--model ` is required for an evidence-producing run; an omitted or
+blank value is a usage error before a Codex adapter is constructed. The adapter
+also validates its model and always passes that recorded ID to Codex. The report
+records the requested value as `agent_identity: "codex:"` in JSON
+and as an agent-identity line in the Markdown summary.
+
+The command runs the same task in three retained workspaces for every fixture:
+
+- `no_memory` receives no memory context.
+- `raw_memory` receives the visible normal, non-superseded seed memories in
+ fixture order.
+- `tree_ring` receives the normal, non-superseded memories returned by local
+ Tree Ring retrieval.
+
+The fixture pack exercises constraint recall (`no-background-writer`), current
+rules over superseded rules (`stale-cli-contract`), and a failure scar changing
+the recovery decision (`scar-recovery`). Each workspace materializes a
+`decision-format.json` action enum. The agent task asks it to inspect that
+workspace and write `decision.json` with an `action` and `rationale`; it does
+not name the expected action. Deterministic validators remain outside the agent
+request and compare only the exact `decision.json` `/action` value.
+
+## Evidence and Reproducibility
+
+The selected output directory retains all trial workspaces at
+`trials///workspace/`, plus a machine-readable
+`workflow-proof-report.json` and a readable `workflow-proof-summary.md`.
+For evidence integrity, artifacts use the resolved output directory, and
+descriptor-relative validation accepts only regular unlinked workspace files,
+rejecting symlink and hard-link outputs; non-Unix evaluation currently fails
+closed until an equivalent descriptor-safe implementation exists.
+Treat `workflow-proof-report.json` as observed paired evidence for these
+specific controlled fixtures: inspect the retained workspaces, memory context,
+agent response, and deterministic JSON field checks before drawing a
+conclusion.
+
+For every run, record alongside the output:
+
+- the Tree Ring commit (`git rev-parse HEAD`);
+- the Codex CLI version (`codex --version`) and the required `--model` value;
+- the `agent_identity` recorded in both reports (normally
+ `codex:` for this example);
+- the complete command, timestamp, and any non-default Codex binary path.
+
+No unit test, normal certification command, or CI job invokes Codex
+automatically. A real model run happens only when an operator explicitly runs
+the example above, and a failed control arm remains evidence rather than a
+runner failure.
+
+This pack does not establish a universal model score, a general claim that
+memory improves all workflows, or a replacement for external evaluations. The
+next validation step is to run external benchmark adapters and preserve their
+native reports beside this controlled evidence.
diff --git a/docs/llms.txt b/docs/llms.txt
index 1ef7cf0..aea70a8 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.11.0
+Launch release: https://github.com/TerminallyLazy/Tree-Ring-Memory/releases/tag/v0.12.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.11.0
+Current version: 0.12.0
## Summary
@@ -30,6 +30,7 @@ ideas stay as seeds.
- Explicit forgetting, redaction, supersession, audit, and maintenance.
- 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.
- DOX and Revolve sync adapters.
- Read-only agent-framework discovery.
- Terminal onboarding and a Ratatui operator console.
@@ -79,7 +80,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.11.0
+- Launch release: https://github.com/TerminallyLazy/Tree-Ring-Memory/releases/tag/v0.12.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 30e304f..638f6d9 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.11.0
+- Current version: 0.12.0
- Website:
- Repository:
-- Launch release:
+- Launch release:
- Launch discussion:
- Homebrew tap:
- Feedback:
@@ -44,6 +44,7 @@ Agent memory should age, not pile up.
- First-class forgetting, redaction, supersession, audit, and maintenance.
- 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.
- DOX and Revolve sync adapters.
- Terminal onboarding and Ratatui operator console.
diff --git a/docs/sitemap.xml b/docs/sitemap.xml
index f6f98df..c33067c 100644
--- a/docs/sitemap.xml
+++ b/docs/sitemap.xml
@@ -2,7 +2,7 @@
https://terminallylazy.github.io/Tree-Ring-Memory/
- 2026-07-07
+ 2026-07-15
weekly
1.0
@@ -20,19 +20,19 @@
https://terminallylazy.github.io/Tree-Ring-Memory/press-kit.md
- 2026-07-07
+ 2026-07-15
monthly
0.7
https://terminallylazy.github.io/Tree-Ring-Memory/llms.txt
- 2026-07-07
+ 2026-07-15
monthly
0.6
https://terminallylazy.github.io/Tree-Ring-Memory/feed.xml
- 2026-07-07
+ 2026-07-15
weekly
0.5
diff --git a/docs/superpowers/plans/2026-07-12-agent-workflow-proof-runner.md b/docs/superpowers/plans/2026-07-12-agent-workflow-proof-runner.md
new file mode 100644
index 0000000..bc2c4dd
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-12-agent-workflow-proof-runner.md
@@ -0,0 +1,364 @@
+# Agent Workflow Proof Runner Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add an explicit, reproducible Codex workflow runner that compares no-memory, raw-memory, and Tree Ring retrieval against deterministic workspace validators, then preserves the trial evidence needed to inspect any claimed lift.
+
+**Architecture:** Keep the fixture schema and validation boundary in `tree-ring-memory-core`; it owns safe workspace fixtures, expected file checks, and the agent request type that deliberately omits validator expectations. Add a small CLI library that creates isolated trial workspaces, builds fair memory contexts, invokes `codex exec` only when the explicit example is run, validates the resulting workspace, and writes JSON/Markdown evidence. The runner is certification-owned through an example binary, not a public `tree-ring eval` command and not a background process.
+
+**Tech Stack:** Rust 2021, serde/serde_json, SQLite `MemoryRetriever`, standard-library `Command`, locally installed Codex CLI, JSON fixtures.
+
+## Global Constraints
+
+- Do not add a daemon, sidecar, hosted service, telemetry pipeline, hidden recorder, or autonomous durable writer.
+- Do not scrape transcripts or turn agent event-streams into memory.
+- Keep the agent's task prompt and each arm identical except for `memory_context`.
+- Never serialize validator expectations, expected files, or fixture locations into `WorkflowAgentRequest`.
+- Treat source files and explicit task instructions as authoritative over memory; the Codex prompt must say so.
+- Raw-memory and Tree-Ring arms must both exclude secret, non-normal, and superseded memory; only retrieval ranking may differ.
+- Run every arm in a separate retained workspace below the caller-provided output directory.
+- The runner must preserve structured reports on partial agent failures; control-arm failure is evidence, not a runner error.
+- Exit nonzero only when a Tree-Ring arm errors or fails its deterministic validator; do not require controls to pass.
+- Do not add a public `tree-ring eval` subcommand. The only executable entry point in this slice is `cargo run -p tree-ring-memory-cli --example workflow_proof -- ...`.
+- Do not invoke Codex from normal unit tests or `scripts/certify-tree-ring.sh`; a real run remains a user-visible, explicit command.
+- `CodexWorkflowAgent` itself must require a validated nonblank model ID, always pass `--model`, and record that requested model identity in both JSON and Markdown reports; never silently rely on an unrecorded Codex default.
+- Preserve unrelated files; work only on `codex/agent-workflow-proof`.
+
+---
+
+## File Structure
+
+- Create `crates/tree-ring-memory-core/src/workflow.rs`: strict workflow fixture schema, arm enum, agent request/response types, safe relative-path validation, and deterministic file-check evaluator.
+- Modify `crates/tree-ring-memory-core/src/lib.rs`: export the workflow types and parser.
+- Create `crates/tree-ring-memory-core/tests/workflow_scenario.rs`: parser and request-redaction coverage.
+- Create `crates/tree-ring-memory-cli/src/lib.rs`: library target for proof-runner reuse.
+- Create `crates/tree-ring-memory-cli/src/workflow_proof.rs`: paired runner, retained workspaces, raw/Tree-Ring context construction, Codex adapter, report writer, and test-only fake agent.
+- Create `crates/tree-ring-memory-cli/examples/workflow_proof.rs`: minimal explicit argument entry point.
+- Create `crates/tree-ring-memory-cli/tests/workflow_proof.rs`: end-to-end runner tests using a fake in-process agent.
+- Create `fixtures/workflow-proof/no-background-writer.json`, `fixtures/workflow-proof/stale-cli-contract.json`, and `fixtures/workflow-proof/scar-recovery.json`: reviewable, source-linked workflow fixtures.
+- Create `docs/integrations/agent-workflow-proof.md`: command contract, evidence layout, interpretation limits, and reproducibility checklist.
+- Modify `crates/tree-ring-memory-cli/examples/workflow_proof.rs`: factor its positional parser into a testable private parser and make `--help` print the exact usage text without invoking Codex.
+- Modify `crates/tree-ring-memory-cli/src/workflow_proof.rs`: surface the agent evidence identity in `WorkflowProofReport`; Codex must include the explicitly requested model ID.
+- Modify `README.md`: link the explicit workflow-proof command and make its evidence claim precise.
+
+---
+
+### Task 1: Add the Strict Workflow Scenario Contract
+
+**Files:**
+- Create: `crates/tree-ring-memory-core/tests/workflow_scenario.rs`
+- Create: `crates/tree-ring-memory-core/src/workflow.rs`
+- Modify: `crates/tree-ring-memory-core/src/lib.rs`
+
+**Interfaces:**
+- Produces `parse_workflow_scenario(input: &str) -> TreeRingResult`.
+- Produces `WorkflowArm::{NoMemory, RawMemory, TreeRing}` with serde names `no_memory`, `raw_memory`, and `tree_ring`.
+- Produces `WorkflowAgentRequest` containing only `schema_version`, `scenario_id`, `arm`, `task`, `workspace_root`, and `memory_context`.
+- Produces `WorkflowAgentResponse { summary: String, used_memory_ids: Vec }`; validation requires a nonblank summary and unique, nonblank IDs. The runner, not the response type, checks that cited IDs were actually supplied.
+- Produces `evaluate_workspace(scenario: &WorkflowScenario, workspace_root: &Path) -> Vec`.
+
+- [ ] **Step 1: Write the failing parser and boundary tests**
+
+Create `crates/tree-ring-memory-core/tests/workflow_scenario.rs` with an inline valid JSON fixture containing one `workspace_files` entry, one `expected_files` entry, and no seed memories. Do not use the Task 3 fixture pack yet. Assert that parsing succeeds, a `../escape.txt` path is rejected, unknown top-level fields are rejected, and serializing `WorkflowAgentRequest` does not contain the actual expected-file text.
+
+```rust
+use tree_ring_memory_core::{parse_workflow_scenario, WorkflowAgentRequest, WorkflowArm};
+
+const VALID_SCENARIO: &str = r#"{
+ "name": "safe workflow",
+ "task": "Prepare decision.md from the workspace.",
+ "seed_memories": [],
+ "workspace_files": [{"path": "proposal.md", "content": "draft"}],
+ "expected_files": [{"path": "decision.md", "contains": "safe action"}]
+}"#;
+
+#[test]
+fn parses_safe_workflow_fixture_and_keeps_validator_out_of_agent_request() {
+ let scenario = parse_workflow_scenario(VALID_SCENARIO).unwrap();
+ let request = WorkflowAgentRequest::new(
+ scenario.name.clone(),
+ WorkflowArm::NoMemory,
+ scenario.task.clone(),
+ "/tmp/trial".into(),
+ Vec::new(),
+ );
+ let serialized = serde_json::to_string(&request).unwrap();
+ assert!(!serialized.contains("safe action"));
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `cargo test --locked -p tree-ring-memory-core --test workflow_scenario`
+
+Expected: compilation failure because `parse_workflow_scenario`, `WorkflowAgentRequest`, and `WorkflowArm` do not exist.
+
+- [ ] **Step 3: Implement the minimal core model**
+
+Add `workflow.rs` with `#[serde(deny_unknown_fields)]` on every fixture-owned struct. The exact safe-path helper must reject absolute paths, empty paths, and any `Component::ParentDir`; it must allow nested relative paths. Use these types:
+
+```rust
+pub struct WorkflowScenario {
+ pub name: String,
+ pub task: String,
+ #[serde(default)] pub seed_memories: Vec,
+ #[serde(default)] pub workspace_files: Vec,
+ #[serde(default)] pub expected_files: Vec,
+}
+
+pub struct WorkflowAgentRequest {
+ pub schema_version: u8,
+ pub scenario_id: String,
+ pub arm: WorkflowArm,
+ pub task: String,
+ pub workspace_root: PathBuf,
+ pub memory_context: Vec,
+}
+
+pub struct WorkflowAgentResponse {
+ pub summary: String,
+ #[serde(default)] pub used_memory_ids: Vec,
+}
+```
+
+`WorkflowScenario::validate` must require nonblank `name` and `task`, at least one expected file, unique workspace-file paths, unique expected-file `(path, contains)` pairs, valid seed memories, safe paths, and nonblank expected `contains` strings. `WorkflowMemoryContext` must contain `id`, `summary`, `details`, `ring`, `event_type`, `source_ref`, and `confidence`. `WorkflowAgentRequest::new` must not accept a `WorkflowScenario` or any validator data. `WorkflowAgentResponse::validate` must reject an empty summary, blank memory IDs, and duplicate memory IDs.
+
+- [ ] **Step 4: Export and verify the core contract**
+
+Export all public workflow types and `parse_workflow_scenario` from `lib.rs`. Run:
+
+```bash
+cargo test --locked -p tree-ring-memory-core --test workflow_scenario
+cargo test --locked -p tree-ring-memory-core
+```
+
+Expected: all new workflow-contract tests and the existing core suite pass.
+
+- [ ] **Step 5: Commit the core contract**
+
+```bash
+git add crates/tree-ring-memory-core/src/lib.rs crates/tree-ring-memory-core/src/workflow.rs crates/tree-ring-memory-core/tests/workflow_scenario.rs
+git commit -m "feat: add workflow proof scenario contract"
+```
+
+### Task 2: Build the Paired Runner and Explicit Codex Adapter
+
+**Files:**
+- Create: `crates/tree-ring-memory-cli/src/lib.rs`
+- Create: `crates/tree-ring-memory-cli/src/workflow_proof.rs`
+- Create: `crates/tree-ring-memory-cli/examples/workflow_proof.rs`
+- Create: `crates/tree-ring-memory-cli/tests/workflow_proof.rs`
+
+**Interfaces:**
+- Produces `run_workflow_proof(fixture_dir: &Path, output_dir: &Path, agent: &impl WorkflowAgent) -> Result`.
+- Produces `CodexWorkflowAgent::new(binary: PathBuf, model: String) -> Result`; it rejects blank model IDs and always passes `--model` when executing Codex.
+- Produces `workflow-proof-report.json`, `workflow-proof-summary.md`, and `trials///` under the selected output directory.
+- Consumes the Task 1 types and `MemoryRetriever` without changing SQLite or normal certification behavior.
+
+- [ ] **Step 1: Write failing runner tests with a real fake agent**
+
+Create `crates/tree-ring-memory-cli/tests/workflow_proof.rs`. Write the no-background-writer scenario into a test-owned `tempdir` as JSON; do not depend on the Task 3 fixture pack. Define a `FakeAgent` that receives `WorkflowAgentRequest`, writes `decision.md` only when its context includes `mem_quality_no_background_writer`, and returns that ID in `used_memory_ids`. Assert that:
+
+1. `NoMemory` receives an empty context and fails the expected-file validator.
+2. `RawMemory` and `TreeRing` receive only normal, non-superseded memory.
+3. Tree Ring passes, the report records one observed lift over no-memory, and all three retained `trials/.../workspace` directories exist.
+4. A fake response that cites an ID absent from `memory_context` is recorded as an error rather than counted as a pass.
+
+- [ ] **Step 2: Run the runner test to verify it fails**
+
+Run: `cargo test --locked -p tree-ring-memory-cli --test workflow_proof`
+
+Expected: compilation failure because the CLI library and `workflow_proof` interfaces do not exist.
+
+- [ ] **Step 3: Implement fair arm construction and deterministic validation**
+
+Add a CLI library target exposing `workflow_proof`. In `run_workflow_proof`:
+
+1. Read sorted `*.json` fixtures and parse them with `parse_workflow_scenario`.
+2. For every scenario and arm, create `output_dir/trials///workspace`, materialize only `workspace_files`, and keep it after the run.
+3. Build all arm prompts from the same task. `NoMemory` gets `[]`; `RawMemory` gets all visible seed memories in fixture order; `TreeRing` inserts the seed set into an in-memory SQLite store and uses `MemoryRetriever::recall(task, ..., 8, false)`.
+4. Project memory into `WorkflowMemoryContext` with ID, summary, details, ring, event type, source ref, and confidence. Never expose `source.quote`.
+5. Require every `used_memory_id` returned by the agent to be present in the request's `memory_context`; otherwise emit a trial error.
+6. Apply `evaluate_workspace` after agent execution. A control failure is a normal `Fail`; a Tree Ring failure or error makes the example exit nonzero after reports are written.
+
+Use report fields `schema_version`, `generated_at`, `agent_identity`, `scenario_count`, `trial_count`, `arm_summaries`, `scenarios`, `tree_ring_wins_over_no_memory`, `tree_ring_wins_over_raw_memory`, and `tree_ring_complete`. Name the comparison signal `observed` lift, never `proven` lift.
+
+- [ ] **Step 4: Implement the explicit Codex adapter**
+
+Define `WorkflowAgent` as:
+
+```rust
+pub trait WorkflowAgent {
+ fn execute(&self, request: &WorkflowAgentRequest) -> Result;
+}
+```
+
+`CodexWorkflowAgent` must require a validated nonblank model ID and invoke exactly this shape:
+
+```text
+codex exec --ephemeral --sandbox workspace-write --cd
+ --output-schema /.tree-ring-workflow-schema.json
+ --output-last-message /.tree-ring-workflow-response.json
+ --model
+
+```
+
+The prompt must say: work only in the workspace, use source/task files over memory when they conflict, do not seek validators or fixtures, and return only the response schema fields `summary` and `used_memory_ids`. It must contain the task and a serialized `memory_context`, but never the expected-file checks.
+
+The example parser must accept:
+
+```text
+workflow_proof --model [--codex-bin ]
+```
+
+The default binary is `codex`; no invocation happens until a user runs this example.
+
+- [ ] **Step 5: Run focused and full verification**
+
+Run:
+
+```bash
+cargo test --locked -p tree-ring-memory-cli --test workflow_proof
+cargo test --locked
+cargo fmt --check
+```
+
+Expected: fake-agent proof coverage passes without invoking `codex` and the existing suite stays green.
+
+- [ ] **Step 6: Commit the runner**
+
+```bash
+git add crates/tree-ring-memory-cli/src/lib.rs crates/tree-ring-memory-cli/src/workflow_proof.rs crates/tree-ring-memory-cli/examples/workflow_proof.rs crates/tree-ring-memory-cli/tests/workflow_proof.rs
+git commit -m "feat: add paired agent workflow proof runner"
+```
+
+### Task 3: Add Reviewable Fixtures and Operator Documentation
+
+**Files:**
+- Create: `fixtures/workflow-proof/no-background-writer.json`
+- Create: `fixtures/workflow-proof/stale-cli-contract.json`
+- Create: `fixtures/workflow-proof/scar-recovery.json`
+- Create: `docs/integrations/agent-workflow-proof.md`
+- Modify: `README.md`
+
+**Interfaces:**
+- Fixtures use the Task 1 JSON contract and contain only normal, source-linked synthetic/project-safe memories.
+- Documentation gives the exact explicit command, requires `--model `, records the requested model identity in `workflow-proof-report.json`, and names that output as observed paired evidence rather than a universal benchmark score.
+
+- [ ] **Step 1: Write failing fixture-validation coverage**
+
+Add a test to `workflow_scenario.rs` that walks `fixtures/workflow-proof`, parses every JSON file, asserts all three required scenario names are present, and asserts each fixture has an expected file. In `examples/workflow_proof.rs`, factor the current argument parsing into a private `parse_cli_args` returning `Help` or `Run`, add a `#[cfg(test)]` unit test that `--help` returns `Help`, and make `run` print the exact usage text then exit zero for that result. This remains an example-only parser; it must not add a normal CLI subcommand or require Cargo example registration.
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `cargo test --locked -p tree-ring-memory-core --test workflow_scenario`
+
+Expected: failure because `fixtures/workflow-proof` does not exist.
+
+- [ ] **Step 3: Add the three fixtures**
+
+Each fixture must seed one or two normal memories, create a small initial workspace file, and require a resulting `decision.md` content fragment. Use these decision outcomes:
+
+| Fixture | Required decision fragment | Tree Ring mechanism under test |
+|---|---|---|
+| `no-background-writer` | `no hidden durable writer` | constraint recall |
+| `stale-cli-contract` | `remember needs --event-type` | current rule beats superseded rule |
+| `scar-recovery` | `roll back the cache migration` | failure scar changes recovery workflow |
+
+Keep the task prompt neutral: it asks the agent to inspect the workspace and prepare `decision.md`; it must not state the required fragment. Every memory source must point to an existing repo file or a clearly labeled fixture evidence URI.
+
+- [ ] **Step 4: Document reproducibility and limits**
+
+Create `docs/integrations/agent-workflow-proof.md` containing:
+
+```bash
+cargo run --locked -p tree-ring-memory-cli --example workflow_proof -- \
+ fixtures/workflow-proof target/tree-ring-certification/workflow-proof \
+ --model
+```
+
+Document the three arms, retained workspace evidence, no automatic Codex invocation in CI, required `--model ` plus model/version/commit capture, no claim beyond the specific controlled fixtures, and the next step of running external benchmark adapters. The model identity must be visible in both `workflow-proof-report.json` and `workflow-proof-summary.md`; do not make it an operator-only side note.
+
+Add a concise README link under certification/evidence documentation.
+
+- [ ] **Step 5: Run fixture and documentation verification**
+
+Run:
+
+```bash
+cargo test --locked -p tree-ring-memory-core --test workflow_scenario
+cargo test --locked -p tree-ring-memory-cli --test workflow_proof
+cargo fmt --check
+git diff --check
+```
+
+Expected: the fixtures parse, the fake agent proves the runner mechanics, and no documentation or whitespace error remains.
+
+- [ ] **Step 6: Commit the fixture pack and docs**
+
+```bash
+git add fixtures/workflow-proof docs/integrations/agent-workflow-proof.md README.md crates/tree-ring-memory-core/tests/workflow_scenario.rs crates/tree-ring-memory-cli/tests/workflow_proof.rs
+git commit -m "docs: add agent workflow proof fixtures"
+```
+
+### Task 4: Execute the Explicit Real-Agent Proof and Capture Evidence
+
+> **Implementation update (2026-07-13):** The shipped fixtures now require a
+> structured `decision.json` action and validate its exact JSON field, rather
+> than matching prose in `decision.md`. Preserve every prior run as evidence:
+> use a fresh, absent output directory for each new run instead of deleting an
+> earlier artifact.
+
+**Files:**
+- Generated only: `target/tree-ring-certification/workflow-proof/`
+
+**Interfaces:**
+- Consumes the explicit example and the default local `codex` executable.
+- Produces inspectable trial directories, JSON report, and Markdown summary without changing tracked source files.
+
+- [ ] **Step 1: Build and run the runner against the local Codex CLI**
+
+Run:
+
+```bash
+proof_output=target/tree-ring-certification/workflow-proof-
+test ! -e "$proof_output"
+cargo run --locked -p tree-ring-memory-cli --example workflow_proof -- \
+ fixtures/workflow-proof "$proof_output" \
+ --model
+```
+
+Expected: all nine paired trials complete, the report exists, and the process exits zero only when every Tree Ring arm satisfies its deterministic validator.
+
+- [ ] **Step 2: Inspect evidence before interpreting it**
+
+Run:
+
+```bash
+sed -n '1,260p' "$proof_output/workflow-proof-report.json"
+find "$proof_output/trials" -maxdepth 4 -type f | sort
+```
+
+Expected: report context IDs differ only by arm; no-memory trials have empty contexts; each retained workspace exposes the validator-observable file state.
+
+- [ ] **Step 3: Run the full release-quality suite**
+
+Run:
+
+```bash
+cargo fmt --check
+cargo test --locked
+cargo clippy --locked --all-targets
+git diff --check
+```
+
+Expected: all commands exit zero. Do not run `scripts/certify-tree-ring.sh` as a substitute for the explicit agent proof; it intentionally does not invoke Codex.
+
+---
+
+## Plan Self-Review
+
+- **Spec coverage:** Task 1 prevents validator leakage and unsafe fixtures; Task 2 creates fair paired contexts, real explicit Codex execution, deterministic outcomes, retained traces, and partial-failure reports; Task 3 adds concrete Tree Ring workflow scenarios and truthful docs; Task 4 produces real observed evidence only after code is validated.
+- **Scope check:** No public eval subcommand, hidden agent execution, new storage backend, telemetry, or UI changes are introduced.
+- **Type consistency:** Core owns `WorkflowScenario`, `WorkflowArm`, `WorkflowAgentRequest`, `WorkflowAgentResponse`, and file-check reports. CLI owns `WorkflowAgent`, `CodexWorkflowAgent`, and `WorkflowProofReport`.
+- **Placeholder scan:** All file paths, interfaces, command shapes, scenario names, required decision fragments, and verification commands are explicit.
diff --git a/fixtures/workflow-proof/no-background-writer.json b/fixtures/workflow-proof/no-background-writer.json
new file mode 100644
index 0000000..437be44
--- /dev/null
+++ b/fixtures/workflow-proof/no-background-writer.json
@@ -0,0 +1,45 @@
+{
+ "name": "no-background-writer",
+ "task": "Inspect the durable workflow request about a persistent helper in the workspace, choose an action from decision-format.json, and write valid decision.json with action and rationale.",
+ "seed_memories": [
+ {
+ "id": "mem_workflow_no_background_writer",
+ "created_at": "2026-07-12T00:00:00Z",
+ "updated_at": "2026-07-12T00:00:00Z",
+ "project": "Tree_Ring_Memory",
+ "scope": "project",
+ "ring": "heartwood",
+ "event_type": "decision",
+ "summary": "Constraint: no hidden durable writer may run without an explicit request.",
+ "details": "Keep workflow proof execution explicit and human initiated.",
+ "source": {
+ "type": "file",
+ "ref": "docs/superpowers/plans/2026-07-12-agent-workflow-proof-runner.md",
+ "quote": ""
+ },
+ "tags": ["workflow-proof", "constraint"],
+ "salience": 0.95,
+ "confidence": 0.95,
+ "sensitivity": "normal",
+ "retention": "durable"
+ }
+ ],
+ "workspace_files": [
+ {
+ "path": "request.md",
+ "content": "A maintainer is considering a helper that preserves workflow changes automatically between explicit sessions. That could keep state current, but it would create durable changes without a new request. Decide whether the helper may start a persistent writer.\n"
+ },
+ {
+ "path": "decision-format.json",
+ "content": "{\n \"output_file\": \"decision.json\",\n \"required_fields\": [\"action\", \"rationale\"],\n \"action\": {\n \"type\": \"string\",\n \"enum\": [\n \"require_explicit_durable_request\",\n \"enable_persistent_writer\"\n ]\n },\n \"rationale\": {\n \"type\": \"string\",\n \"description\": \"Briefly explain the decision tradeoff.\"\n }\n}\n"
+ }
+ ],
+ "expected_files": [
+ {
+ "path": "decision.json",
+ "json_fields": {
+ "/action": "require_explicit_durable_request"
+ }
+ }
+ ]
+}
diff --git a/fixtures/workflow-proof/scar-recovery.json b/fixtures/workflow-proof/scar-recovery.json
new file mode 100644
index 0000000..efeb3ec
--- /dev/null
+++ b/fixtures/workflow-proof/scar-recovery.json
@@ -0,0 +1,45 @@
+{
+ "name": "scar-recovery",
+ "task": "Inspect the cache migration incident involving stale state in the workspace, choose an action from decision-format.json, and write valid decision.json with action and rationale.",
+ "seed_memories": [
+ {
+ "id": "mem_workflow_cache_migration_scar",
+ "created_at": "2026-07-12T00:00:00Z",
+ "updated_at": "2026-07-12T00:00:00Z",
+ "project": "Tree_Ring_Memory",
+ "scope": "project",
+ "ring": "scar",
+ "event_type": "warning",
+ "summary": "Failure scar: roll back the cache migration before retrying after stale state.",
+ "details": "Inspect stale state before another migration attempt.",
+ "source": {
+ "type": "file",
+ "ref": "fixtures/quality/scar-failure-recall.json",
+ "quote": ""
+ },
+ "tags": ["cache", "migration", "recovery"],
+ "salience": 0.95,
+ "confidence": 0.9,
+ "sensitivity": "normal",
+ "retention": "durable"
+ }
+ ],
+ "workspace_files": [
+ {
+ "path": "incident.md",
+ "content": "A failed cache migration left stale state in a rollout. Retrying might restore service faster, but it could reuse inconsistent state; rolling back first adds recovery work but restores a known baseline. Choose the next recovery action.\n"
+ },
+ {
+ "path": "decision-format.json",
+ "content": "{\n \"output_file\": \"decision.json\",\n \"required_fields\": [\"action\", \"rationale\"],\n \"action\": {\n \"type\": \"string\",\n \"enum\": [\n \"rollback_cache_migration\",\n \"retry_cache_migration\"\n ]\n },\n \"rationale\": {\n \"type\": \"string\",\n \"description\": \"Briefly explain the recovery tradeoff.\"\n }\n}\n"
+ }
+ ],
+ "expected_files": [
+ {
+ "path": "decision.json",
+ "json_fields": {
+ "/action": "rollback_cache_migration"
+ }
+ }
+ ]
+}
diff --git a/fixtures/workflow-proof/stale-cli-contract.json b/fixtures/workflow-proof/stale-cli-contract.json
new file mode 100644
index 0000000..f5de94c
--- /dev/null
+++ b/fixtures/workflow-proof/stale-cli-contract.json
@@ -0,0 +1,68 @@
+{
+ "name": "stale-cli-contract",
+ "task": "Inspect the workspace for current command-line guidance for recording a memory, choose an action from decision-format.json, and write valid decision.json with action and rationale.",
+ "seed_memories": [
+ {
+ "id": "mem_workflow_stale_cli_contract",
+ "created_at": "2026-07-10T00:00:00Z",
+ "updated_at": "2026-07-10T00:00:00Z",
+ "project": "Tree_Ring_Memory",
+ "scope": "project",
+ "ring": "heartwood",
+ "event_type": "decision",
+ "summary": "Legacy helper note: remember can be called without an event type.",
+ "details": "This legacy note has been replaced by the current CLI contract.",
+ "source": {
+ "type": "file",
+ "ref": "fixtures/quality/stale-cli-contract.json",
+ "quote": ""
+ },
+ "tags": ["cli-contract", "superseded"],
+ "salience": 0.7,
+ "confidence": 0.6,
+ "sensitivity": "normal",
+ "retention": "durable",
+ "superseded_by": "mem_workflow_current_cli_contract"
+ },
+ {
+ "id": "mem_workflow_current_cli_contract",
+ "created_at": "2026-07-12T00:00:00Z",
+ "updated_at": "2026-07-12T00:00:00Z",
+ "project": "Tree_Ring_Memory",
+ "scope": "project",
+ "ring": "heartwood",
+ "event_type": "decision",
+ "summary": "Current CLI rule: remember needs --event-type.",
+ "details": "The command parser requires an event type when recording a memory.",
+ "source": {
+ "type": "file",
+ "ref": "crates/tree-ring-memory-cli/src/main.rs",
+ "quote": ""
+ },
+ "tags": ["cli-contract", "current"],
+ "salience": 0.95,
+ "confidence": 0.95,
+ "sensitivity": "normal",
+ "retention": "durable",
+ "supersedes": ["mem_workflow_stale_cli_contract"]
+ }
+ ],
+ "workspace_files": [
+ {
+ "path": "helper-script.md",
+ "content": "A helper script records a project lesson using a legacy invocation. Preserving the invocation avoids updating call sites, while requiring classification may make the recorded context clearer. Choose how the template should be handled.\n"
+ },
+ {
+ "path": "decision-format.json",
+ "content": "{\n \"output_file\": \"decision.json\",\n \"required_fields\": [\"action\", \"rationale\"],\n \"action\": {\n \"type\": \"string\",\n \"enum\": [\n \"require_event_type\",\n \"preserve_legacy_invocation\"\n ]\n },\n \"rationale\": {\n \"type\": \"string\",\n \"description\": \"Briefly explain the command-contract tradeoff.\"\n }\n}\n"
+ }
+ ],
+ "expected_files": [
+ {
+ "path": "decision.json",
+ "json_fields": {
+ "/action": "require_event_type"
+ }
+ }
+ ]
+}
diff --git a/marketing/README.md b/marketing/README.md
index 122ac68..d3d727b 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.11.0`
+- Launch release: `https://github.com/TerminallyLazy/Tree-Ring-Memory/releases/tag/v0.12.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: