From 6d166b359c8ee9fce3f0f8485a2eb2b5d4f8eca3 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Sun, 12 Jul 2026 19:14:54 -0400 Subject: [PATCH 01/21] docs: plan agent workflow proof runner --- .../2026-07-12-agent-workflow-proof-runner.md | 345 ++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-12-agent-workflow-proof-runner.md 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..512c182 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-agent-workflow-proof-runner.md @@ -0,0 +1,345 @@ +# 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. +- 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 `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 `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 one seed memory. 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 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("no hidden durable writer")); +} +``` + +- [ ] **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, +} +``` + +`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. `WorkflowAgentRequest::new` must not accept a `WorkflowScenario` or any validator data. + +- [ ] **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: Option)`. +- 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`, `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 invoke exactly this shape (with an optional `--model` pair only when supplied): + +```text +codex exec --ephemeral --sandbox workspace-write --cd + --output-schema /.tree-ring-workflow-schema.json + --output-last-message /.tree-ring-workflow-response.json + +``` + +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 [--codex-bin ] [--model ] +``` + +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 and names `workflow-proof-report.json` as observed paired evidence, not 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. Add a CLI test that invokes `workflow_proof --help` through `CARGO_BIN_EXE` only if the example is registered; otherwise test `parse_cli_args` directly. + +- [ ] **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 +``` + +Document the three arms, retained workspace evidence, no automatic Codex invocation in CI, model/version/commit capture requirements, no claim beyond the specific controlled fixtures, and the next step of running external benchmark adapters. + +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 + +**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 +rm -rf target/tree-ring-certification/workflow-proof +cargo run --locked -p tree-ring-memory-cli --example workflow_proof -- \ + fixtures/workflow-proof target/tree-ring-certification/workflow-proof +``` + +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' target/tree-ring-certification/workflow-proof/workflow-proof-report.json +find target/tree-ring-certification/workflow-proof/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. From 3ec12611f19500d9e20c4289793737e23c757bbb Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Sun, 12 Jul 2026 19:16:48 -0400 Subject: [PATCH 02/21] docs: correct workflow proof plan test --- .../plans/2026-07-12-agent-workflow-proof-runner.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 512c182..c48efcd 100644 --- a/docs/superpowers/plans/2026-07-12-agent-workflow-proof-runner.md +++ b/docs/superpowers/plans/2026-07-12-agent-workflow-proof-runner.md @@ -55,7 +55,7 @@ - [ ] **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 one seed memory. 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 expected file text. +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}; @@ -79,7 +79,7 @@ fn parses_safe_workflow_fixture_and_keeps_validator_out_of_agent_request() { Vec::new(), ); let serialized = serde_json::to_string(&request).unwrap(); - assert!(!serialized.contains("no hidden durable writer")); + assert!(!serialized.contains("safe action")); } ``` From 5906dd2fa23a2c48503e084f3126dbc455155317 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Sun, 12 Jul 2026 19:17:52 -0400 Subject: [PATCH 03/21] docs: define workflow agent response contract --- .../plans/2026-07-12-agent-workflow-proof-runner.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 index c48efcd..83c62ea 100644 --- a/docs/superpowers/plans/2026-07-12-agent-workflow-proof-runner.md +++ b/docs/superpowers/plans/2026-07-12-agent-workflow-proof-runner.md @@ -51,6 +51,7 @@ - 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** @@ -110,9 +111,14 @@ pub struct WorkflowAgentRequest { 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. `WorkflowAgentRequest::new` must not accept a `WorkflowScenario` or any validator data. +`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** From d7337a3101752d408d9ab6c9eb20629c3edde3fc Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Sun, 12 Jul 2026 19:23:51 -0400 Subject: [PATCH 04/21] feat: add workflow proof scenario contract --- crates/tree-ring-memory-core/src/lib.rs | 6 + crates/tree-ring-memory-core/src/workflow.rs | 249 ++++++++++++++++++ .../tests/workflow_scenario.rs | 224 ++++++++++++++++ 3 files changed, 479 insertions(+) create mode 100644 crates/tree-ring-memory-core/src/workflow.rs create mode 100644 crates/tree-ring-memory-core/tests/workflow_scenario.rs 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..ac1c2dd --- /dev/null +++ b/crates/tree-ring-memory-core/src/workflow.rs @@ -0,0 +1,249 @@ +use std::{ + collections::HashSet, + fs, + path::{Component, Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; + +use crate::models::{MemoryEvent, 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)] + pub seed_memories: Vec, + #[serde(default)] + pub workspace_files: Vec, + #[serde(default)] + pub expected_files: Vec, +} + +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() { + validate_safe_relative_path( + &format!("workflow scenario workspace_files[{index}].path"), + &file.path, + )?; + if !workspace_paths.insert(file.path.as_str()) { + return Err(TreeRingError::Validation(format!( + "workflow scenario workspace_files[{index}] duplicates path {}", + file.path + ))); + } + } + + let mut expected_file_pairs = HashSet::new(); + for (index, expectation) in self.expected_files.iter().enumerate() { + validate_safe_relative_path( + &format!("workflow scenario expected_files[{index}].path"), + &expectation.path, + )?; + validate_nonblank( + &format!("workflow scenario expected_files[{index}].contains"), + &expectation.contains, + )?; + if !expected_file_pairs + .insert((expectation.path.as_str(), expectation.contains.as_str())) + { + return Err(TreeRingError::Validation(format!( + "workflow scenario expected_files[{index}] duplicates path and contains" + ))); + } + } + + for memory in &self.seed_memories { + 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, + pub contains: String, +} + +#[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, + pub contains: String, + 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 validate_safe_relative_path("workflow file expectation path", &expectation.path) + .is_ok() + { + let path = workspace_root.join(&expectation.path); + let exists = path.is_file(); + let passed = exists + && fs::read_to_string(&path) + .map(|content| content.contains(&expectation.contains)) + .unwrap_or(false); + (exists, passed) + } else { + (false, false) + }; + + WorkflowFileCheckReport { + path: expectation.path.clone(), + contains: expectation.contains.clone(), + exists, + passed, + } + }) + .collect() +} + +fn validate_nonblank(field: &str, value: &str) -> TreeRingResult<()> { + if value.trim().is_empty() { + return Err(TreeRingError::Validation(format!("{field} is required"))); + } + Ok(()) +} + +fn validate_safe_relative_path(field: &str, value: &str) -> TreeRingResult<()> { + if value.trim().is_empty() { + return Err(TreeRingError::Validation(format!( + "{field} must not be empty" + ))); + } + + let path = Path::new(value); + if path.is_absolute() { + return Err(TreeRingError::Validation(format!( + "{field} must be relative" + ))); + } + if path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err(TreeRingError::Validation(format!( + "{field} must not contain parent directory components" + ))); + } + + Ok(()) +} 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..d739013 --- /dev/null +++ b/crates/tree-ring-memory-core/tests/workflow_scenario.rs @@ -0,0 +1,224 @@ +use std::fs; + +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"}] +}"#; + +#[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, "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 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 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 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()); +} + +#[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, 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); +} + +#[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: "safe action".to_string(), + }], + }; + + let reports = evaluate_workspace(&scenario, &workspace); + + assert_eq!(reports.len(), 1); + assert!(!reports[0].exists); + assert!(!reports[0].passed); +} From 6305e487b6b98c9892dd1e75b82234a78b73afad Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Sun, 12 Jul 2026 19:35:36 -0400 Subject: [PATCH 05/21] fix: harden workflow fixture parsing --- crates/tree-ring-memory-core/src/workflow.rs | 199 +++++++++++++++++- .../tests/workflow_scenario.rs | 97 +++++++++ 2 files changed, 293 insertions(+), 3 deletions(-) diff --git a/crates/tree-ring-memory-core/src/workflow.rs b/crates/tree-ring-memory-core/src/workflow.rs index ac1c2dd..9288a75 100644 --- a/crates/tree-ring-memory-core/src/workflow.rs +++ b/crates/tree-ring-memory-core/src/workflow.rs @@ -4,9 +4,11 @@ use std::{ path::{Component, Path, PathBuf}, }; -use serde::{Deserialize, Serialize}; +use serde::{de::Deserializer, Deserialize, Serialize}; -use crate::models::{MemoryEvent, TreeRingError, TreeRingResult}; +use crate::models::{ + MemoryEvent, MemoryLink, MemoryReview, MemorySource, TreeRingError, TreeRingResult, +}; const WORKFLOW_SCHEMA_VERSION: u8 = 1; @@ -23,7 +25,7 @@ pub enum WorkflowArm { pub struct WorkflowScenario { pub name: String, pub task: String, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_seed_memories")] pub seed_memories: Vec, #[serde(default)] pub workspace_files: Vec, @@ -31,6 +33,148 @@ pub struct WorkflowScenario { 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)?; @@ -223,6 +367,38 @@ fn validate_nonblank(field: &str, value: &str) -> TreeRingResult<()> { 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 validate_safe_relative_path(field: &str, value: &str) -> TreeRingResult<()> { if value.trim().is_empty() { return Err(TreeRingError::Validation(format!( @@ -236,14 +412,31 @@ fn validate_safe_relative_path(field: &str, value: &str) -> TreeRingResult<()> { "{field} must be relative" ))); } + if has_windows_root_or_prefix(value) { + return Err(TreeRingError::Validation(format!( + "{field} must not use a Windows root or prefix" + ))); + } if path .components() .any(|component| matches!(component, Component::ParentDir)) + || value.split(['/', '\\']).any(|component| component == "..") { return Err(TreeRingError::Validation(format!( "{field} must not contain parent directory components" ))); } + if value.split(['/', '\\']).any(|component| component == ".") { + return Err(TreeRingError::Validation(format!( + "{field} must not contain current directory components" + ))); + } Ok(()) } + +fn has_windows_root_or_prefix(value: &str) -> bool { + let bytes = value.as_bytes(); + value.starts_with('\\') + || (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':') +} diff --git a/crates/tree-ring-memory-core/tests/workflow_scenario.rs b/crates/tree-ring-memory-core/tests/workflow_scenario.rs index d739013..909e0a0 100644 --- a/crates/tree-ring-memory-core/tests/workflow_scenario.rs +++ b/crates/tree-ring-memory-core/tests/workflow_scenario.rs @@ -1,5 +1,6 @@ use std::fs; +use serde_json::{json, Value}; use tempfile::tempdir; use tree_ring_memory_core::{ evaluate_workspace, parse_workflow_scenario, WorkflowAgentRequest, WorkflowAgentResponse, @@ -14,6 +15,33 @@ const VALID_SCENARIO: &str = r#"{ "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 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(); @@ -120,6 +148,25 @@ fn rejects_invalid_seed_memories() { assert!(parse_workflow_scenario(&input).is_err()); } +#[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!( @@ -222,3 +269,53 @@ fn workspace_evaluation_does_not_follow_unsafe_paths_from_manually_built_scenari assert!(!reports[0].exists); assert!(!reports[0].passed); } + +#[test] +fn rejects_windows_root_and_prefix_paths() { + for unsafe_path in [r"\\escape.txt", "C:escape.txt"] { + 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: "safe action".to_string(), + }], + }; + + 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 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()); +} From ce038d8b8b49ac9bde7b0850d81d9a51af7632a2 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Sun, 12 Jul 2026 19:44:20 -0400 Subject: [PATCH 06/21] fix: enforce portable workflow paths --- crates/tree-ring-memory-core/src/workflow.rs | 86 ++++++++++--------- .../tests/workflow_scenario.rs | 49 ++++++++++- 2 files changed, 93 insertions(+), 42 deletions(-) diff --git a/crates/tree-ring-memory-core/src/workflow.rs b/crates/tree-ring-memory-core/src/workflow.rs index 9288a75..79b9389 100644 --- a/crates/tree-ring-memory-core/src/workflow.rs +++ b/crates/tree-ring-memory-core/src/workflow.rs @@ -1,7 +1,7 @@ use std::{ collections::HashSet, fs, - path::{Component, Path, PathBuf}, + path::{Path, PathBuf}, }; use serde::{de::Deserializer, Deserialize, Serialize}; @@ -188,11 +188,11 @@ impl WorkflowScenario { let mut workspace_paths = HashSet::new(); for (index, file) in self.workspace_files.iter().enumerate() { - validate_safe_relative_path( + let path_key = canonical_workflow_path( &format!("workflow scenario workspace_files[{index}].path"), &file.path, )?; - if !workspace_paths.insert(file.path.as_str()) { + if !workspace_paths.insert(path_key) { return Err(TreeRingError::Validation(format!( "workflow scenario workspace_files[{index}] duplicates path {}", file.path @@ -202,7 +202,7 @@ impl WorkflowScenario { let mut expected_file_pairs = HashSet::new(); for (index, expectation) in self.expected_files.iter().enumerate() { - validate_safe_relative_path( + let path_key = canonical_workflow_path( &format!("workflow scenario expected_files[{index}].path"), &expectation.path, )?; @@ -210,9 +210,7 @@ impl WorkflowScenario { &format!("workflow scenario expected_files[{index}].contains"), &expectation.contains, )?; - if !expected_file_pairs - .insert((expectation.path.as_str(), expectation.contains.as_str())) - { + if !expected_file_pairs.insert((path_key, expectation.contains.clone())) { return Err(TreeRingError::Validation(format!( "workflow scenario expected_files[{index}] duplicates path and contains" ))); @@ -335,20 +333,19 @@ pub fn evaluate_workspace( .expected_files .iter() .map(|expectation| { - let (exists, passed) = - if validate_safe_relative_path("workflow file expectation path", &expectation.path) - .is_ok() - { - let path = workspace_root.join(&expectation.path); - let exists = path.is_file(); - let passed = exists - && fs::read_to_string(&path) - .map(|content| content.contains(&expectation.contains)) - .unwrap_or(false); - (exists, passed) - } else { - (false, false) - }; + let (exists, passed) = if let Ok(path_key) = + canonical_workflow_path("workflow file expectation path", &expectation.path) + { + let path = workspace_root.join(path_key); + let exists = path.is_file(); + let passed = exists + && fs::read_to_string(&path) + .map(|content| content.contains(&expectation.contains)) + .unwrap_or(false); + (exists, passed) + } else { + (false, false) + }; WorkflowFileCheckReport { path: expectation.path.clone(), @@ -399,44 +396,53 @@ fn default_workflow_seed_source_type() -> String { "manual".to_string() } -fn validate_safe_relative_path(field: &str, value: &str) -> TreeRingResult<()> { +fn canonical_workflow_path(field: &str, value: &str) -> TreeRingResult { if value.trim().is_empty() { return Err(TreeRingError::Validation(format!( "{field} must not be empty" ))); } - let path = Path::new(value); - if path.is_absolute() { + if value.starts_with('/') || value.starts_with('\\') { return Err(TreeRingError::Validation(format!( "{field} must be relative" ))); } - if has_windows_root_or_prefix(value) { + if has_windows_drive_prefix(value) { return Err(TreeRingError::Validation(format!( - "{field} must not use a Windows root or prefix" + "{field} must not use a Windows drive prefix" ))); } - if path - .components() - .any(|component| matches!(component, Component::ParentDir)) - || value.split(['/', '\\']).any(|component| component == "..") - { + if value.contains('\\') { return Err(TreeRingError::Validation(format!( - "{field} must not contain parent directory components" + "{field} must use forward slash separators" ))); } - if value.split(['/', '\\']).any(|component| component == ".") { - return Err(TreeRingError::Validation(format!( - "{field} must not contain current directory components" - ))); + + 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(()) + Ok(segments.join("/")) } -fn has_windows_root_or_prefix(value: &str) -> bool { +fn has_windows_drive_prefix(value: &str) -> bool { let bytes = value.as_bytes(); - value.starts_with('\\') - || (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':') + bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' } diff --git a/crates/tree-ring-memory-core/tests/workflow_scenario.rs b/crates/tree-ring-memory-core/tests/workflow_scenario.rs index 909e0a0..f2b5eb2 100644 --- a/crates/tree-ring-memory-core/tests/workflow_scenario.rs +++ b/crates/tree-ring-memory-core/tests/workflow_scenario.rs @@ -271,8 +271,13 @@ fn workspace_evaluation_does_not_follow_unsafe_paths_from_manually_built_scenari } #[test] -fn rejects_windows_root_and_prefix_paths() { - for unsafe_path in [r"\\escape.txt", "C:escape.txt"] { +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(); @@ -309,6 +314,35 @@ fn workspace_evaluation_rejects_windows_root_and_prefix_paths() { } } +#[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: "safe action".to_string(), + }, + WorkflowFileExpectation { + path: r"out\decision.md".to_string(), + contains: "safe action".to_string(), + }, + ], + }; + + 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(); @@ -319,3 +353,14 @@ fn rejects_dot_path_components_that_create_lexical_aliases() { 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()); +} From 1f4369ab35542d7e6a1a5c28fea8831108857187 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Sun, 12 Jul 2026 20:05:13 -0400 Subject: [PATCH 07/21] feat: add paired agent workflow proof runner --- .../examples/workflow_proof.rs | 85 +++ crates/tree-ring-memory-cli/src/lib.rs | 1 + .../src/workflow_proof.rs | 577 ++++++++++++++++++ .../tests/workflow_proof.rs | 385 ++++++++++++ 4 files changed, 1048 insertions(+) create mode 100644 crates/tree-ring-memory-cli/examples/workflow_proof.rs create mode 100644 crates/tree-ring-memory-cli/src/lib.rs create mode 100644 crates/tree-ring-memory-cli/src/workflow_proof.rs create mode 100644 crates/tree-ring-memory-cli/tests/workflow_proof.rs 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..0bff7a3 --- /dev/null +++ b/crates/tree-ring-memory-cli/examples/workflow_proof.rs @@ -0,0 +1,85 @@ +use std::path::PathBuf; + +use tree_ring_memory_cli::workflow_proof::{ + run_workflow_proof, CodexWorkflowAgent, WorkflowProofReport, +}; + +const USAGE: &str = + "usage: workflow_proof [--codex-bin ] [--model ]"; + +fn main() { + if let Err(error) = run() { + eprintln!("workflow proof failed: {error}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), String> { + let mut args = std::env::args_os().skip(1); + let fixture_dir = args + .next() + .map(PathBuf::from) + .ok_or_else(|| USAGE.to_string())?; + 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 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 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) +} 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..405c14f --- /dev/null +++ b/crates/tree-ring-memory-cli/src/workflow_proof.rs @@ -0,0 +1,577 @@ +use std::collections::BTreeSet; +use std::ffi::OsStr; +use std::fs; +use std::path::{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"; + +pub trait WorkflowAgent { + fn execute(&self, request: &WorkflowAgentRequest) -> Result; +} + +pub struct CodexWorkflowAgent { + binary: PathBuf, + model: Option, +} + +impl CodexWorkflowAgent { + pub fn new(binary: PathBuf, model: Option) -> Self { + Self { binary, model } + } +} + +impl WorkflowAgent for CodexWorkflowAgent { + 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); + if let Some(model) = &self.model { + command.arg("--model").arg(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 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 { + fs::create_dir_all(output_dir) + .map_err(|error| format!("output_directory_create_error: {error}"))?; + + 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)?); + } + + let report = summarize(scenarios); + write_reports(output_dir, &report)?; + Ok(report) +} + +fn run_scenario( + scenario: &WorkflowScenario, + scenario_id: &str, + output_dir: &Path, + agent: &impl WorkflowAgent, +) -> Result { + let raw_memory = visible_seed_memories(scenario); + let tree_ring_memory = recalled_memories(scenario)?; + let mut trials = Vec::with_capacity(3); + + for arm in [ + WorkflowArm::NoMemory, + WorkflowArm::RawMemory, + WorkflowArm::TreeRing, + ] { + let memory_context = match arm { + WorkflowArm::NoMemory => Vec::new(), + WorkflowArm::RawMemory => raw_memory.clone(), + WorkflowArm::TreeRing => tree_ring_memory.clone(), + }; + trials.push(run_trial( + scenario, + scenario_id, + arm, + memory_context, + output_dir, + agent, + )?); + } + + Ok(WorkflowProofScenarioReport { + name: scenario.name.clone(), + scenario_id: scenario_id.to_string(), + trials, + }) +} + +fn run_trial( + scenario: &WorkflowScenario, + scenario_id: &str, + arm: WorkflowArm, + memory_context: Vec, + output_dir: &Path, + agent: &impl WorkflowAgent, +) -> Result { + let workspace = output_dir + .join("trials") + .join(scenario_id) + .join(arm_directory(&arm)) + .join("workspace"); + 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 + .strip_prefix(output_dir) + .unwrap_or(&workspace) + .display() + .to_string(), + memory_context, + agent_response, + file_checks, + status, + errors, + }) +} + +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(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(), + 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!("- 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 + )) +} 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..c03e9d3 --- /dev/null +++ b/crates/tree-ring-memory-cli/tests/workflow_proof.rs @@ -0,0 +1,385 @@ +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"; + +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 has_target_memory = request + .memory_context + .iter() + .any(|memory| memory.id == TARGET_MEMORY_ID); + if has_target_memory { + fs::write( + request.workspace_root.join("decision.md"), + "No background writer should run without an explicit request.\n", + ) + .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 if has_target_memory { + vec![TARGET_MEMORY_ID.to_string()] + } else { + Vec::new() + }, + }) + } +} + +#[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); + + 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()); + } + assert!(output.path().join("workflow-proof-report.json").is_file()); + assert!(output.path().join("workflow-proof-summary.md").is_file()); +} + +#[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_optional_model_pair() { + 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 response = CodexWorkflowAgent::new(binary.clone(), Some("test-model".to_string())) + .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()); +} + +#[cfg(unix)] +#[test] +fn codex_adapter_omits_model_flag_when_no_model_is_configured() { + use std::os::unix::fs::PermissionsExt; + + let workspace = tempdir().unwrap(); + let binary = workspace.path().join("fake-codex-no-model"); + write_fake_codex(&binary, ""); + 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::NoMemory, + "Do the source task.".to_string(), + workspace.path().to_path_buf(), + Vec::new(), + ); + + CodexWorkflowAgent::new(binary.clone(), None) + .execute(&request) + .unwrap(); + + let arguments = fake_codex_arguments(&binary); + assert_eq!(arguments.len(), 11); + assert!(!arguments.iter().any(|argument| argument == "--model")); +} + +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 write_no_background_writer_fixture(fixture_dir: &Path) { + fs::write( + fixture_dir.join("no-background-writer.json"), + r#"{ + "name": "no background writer", + "task": "Create decision.md describing whether to start a background writer.", + "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": "Decide whether a background writer should run." + } + ], + "expected_files": [ + { + "path": "decision.md", + "contains": "No background writer" + } + ] +}"#, + ) + .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() +} From a4ba2c94057b40f9799dbc81355197d457c9c47d Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Sun, 12 Jul 2026 20:19:09 -0400 Subject: [PATCH 08/21] fix: preserve workflow proof failure evidence --- .../src/workflow_proof.rs | 233 ++++++++++++++++-- crates/tree-ring-memory-core/src/workflow.rs | 13 +- .../tests/workflow_scenario.rs | 24 ++ 3 files changed, 243 insertions(+), 27 deletions(-) diff --git a/crates/tree-ring-memory-cli/src/workflow_proof.rs b/crates/tree-ring-memory-cli/src/workflow_proof.rs index 405c14f..ab8b65a 100644 --- a/crates/tree-ring-memory-cli/src/workflow_proof.rs +++ b/crates/tree-ring-memory-cli/src/workflow_proof.rs @@ -17,6 +17,7 @@ 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; @@ -139,6 +140,20 @@ 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}"))?; @@ -156,7 +171,13 @@ pub fn run_workflow_proof( 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)?); + scenarios.push(run_scenario( + &scenario, + &scenario_id, + output_dir, + agent, + &tree_ring_context_builder, + )?); } let report = summarize(scenarios); @@ -169,30 +190,38 @@ fn run_scenario( 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 tree_ring_memory = recalled_memories(scenario)?; let mut trials = Vec::with_capacity(3); - - for arm in [ + trials.push(run_trial( + scenario, + scenario_id, WorkflowArm::NoMemory, + Vec::new(), + output_dir, + agent, + )?); + trials.push(run_trial( + scenario, + scenario_id, WorkflowArm::RawMemory, - WorkflowArm::TreeRing, - ] { - let memory_context = match arm { - WorkflowArm::NoMemory => Vec::new(), - WorkflowArm::RawMemory => raw_memory.clone(), - WorkflowArm::TreeRing => tree_ring_memory.clone(), - }; - trials.push(run_trial( + raw_memory, + output_dir, + agent, + )?); + let tree_ring_trial = match tree_ring_context_builder(scenario) { + Ok(tree_ring_memory) => run_trial( scenario, scenario_id, - arm, - memory_context, + 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(), @@ -201,6 +230,27 @@ fn run_scenario( }) } +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, @@ -209,11 +259,7 @@ fn run_trial( output_dir: &Path, agent: &impl WorkflowAgent, ) -> Result { - let workspace = output_dir - .join("trials") - .join(scenario_id) - .join(arm_directory(&arm)) - .join("workspace"); + let workspace = trial_workspace(output_dir, scenario_id, &arm); materialize_workspace(&workspace, scenario)?; let request = WorkflowAgentRequest::new( @@ -258,11 +304,7 @@ fn run_trial( Ok(WorkflowProofTrialReport { arm, - workspace: workspace - .strip_prefix(output_dir) - .unwrap_or(&workspace) - .display() - .to_string(), + workspace: workspace_report_path(&workspace, output_dir), memory_context, agent_response, file_checks, @@ -271,6 +313,22 @@ fn run_trial( }) } +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 @@ -575,3 +633,126 @@ memory_context:\n{}", request.task, memory_context )) } + +#[cfg(test)] +mod tests { + 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.md"), + "Use the seeded control decision.\n", + ) + .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[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()); + } + + fn write_context_failure_fixture(fixture_dir: &Path) { + fs::write( + fixture_dir.join("context-failure.json"), + r#"{ + "name": "context failure", + "task": "Create decision.md from the visible control memory.", + "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": "Create the requested decision."} + ], + "expected_files": [ + {"path": "decision.md", "contains": "seeded control decision"} + ] +}"#, + ) + .unwrap(); + } +} diff --git a/crates/tree-ring-memory-core/src/workflow.rs b/crates/tree-ring-memory-core/src/workflow.rs index 79b9389..423e1be 100644 --- a/crates/tree-ring-memory-core/src/workflow.rs +++ b/crates/tree-ring-memory-core/src/workflow.rs @@ -217,7 +217,18 @@ impl WorkflowScenario { } } - for memory in &self.seed_memories { + 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()?; } diff --git a/crates/tree-ring-memory-core/tests/workflow_scenario.rs b/crates/tree-ring-memory-core/tests/workflow_scenario.rs index f2b5eb2..b31d390 100644 --- a/crates/tree-ring-memory-core/tests/workflow_scenario.rs +++ b/crates/tree-ring-memory-core/tests/workflow_scenario.rs @@ -148,6 +148,30 @@ fn rejects_invalid_seed_memories() { 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(); From ffbb0c69155bf9d12792e78ff82a73272cc8f6f5 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Sun, 12 Jul 2026 20:34:51 -0400 Subject: [PATCH 09/21] docs: clarify workflow proof help coverage --- .../plans/2026-07-12-agent-workflow-proof-runner.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 83c62ea..ad6a834 100644 --- a/docs/superpowers/plans/2026-07-12-agent-workflow-proof-runner.md +++ b/docs/superpowers/plans/2026-07-12-agent-workflow-proof-runner.md @@ -36,6 +36,7 @@ - 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 `README.md`: link the explicit workflow-proof command and make its evidence claim precise. --- @@ -243,7 +244,7 @@ git commit -m "feat: add paired agent workflow proof runner" - [ ] **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. Add a CLI test that invokes `workflow_proof --help` through `CARGO_BIN_EXE` only if the example is registered; otherwise test `parse_cli_args` directly. +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** From 8fa8152ad31c4a7c21553ed348672fe3f8136b42 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Sun, 12 Jul 2026 20:40:43 -0400 Subject: [PATCH 10/21] docs: add agent workflow proof fixtures --- README.md | 5 ++ .../examples/workflow_proof.rs | 86 +++++++++++++++---- .../tests/workflow_scenario.rs | 42 ++++++++- docs/integrations/agent-workflow-proof.md | 52 +++++++++++ .../workflow-proof/no-background-writer.json | 39 +++++++++ fixtures/workflow-proof/scar-recovery.json | 39 +++++++++ .../workflow-proof/stale-cli-contract.json | 62 +++++++++++++ 7 files changed, 308 insertions(+), 17 deletions(-) create mode 100644 docs/integrations/agent-workflow-proof.md create mode 100644 fixtures/workflow-proof/no-background-writer.json create mode 100644 fixtures/workflow-proof/scar-recovery.json create mode 100644 fixtures/workflow-proof/stale-cli-contract.json diff --git a/README.md b/README.md index ae08e12..ff267d2 100644 --- a/README.md +++ b/README.md @@ -464,6 +464,11 @@ 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. See [agent +workflow proof](docs/integrations/agent-workflow-proof.md) for its controlled +command, retained artifacts, 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/examples/workflow_proof.rs b/crates/tree-ring-memory-cli/examples/workflow_proof.rs index 0bff7a3..ce4bdac 100644 --- a/crates/tree-ring-memory-cli/examples/workflow_proof.rs +++ b/crates/tree-ring-memory-cli/examples/workflow_proof.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::{ffi::OsString, path::PathBuf}; use tree_ring_memory_cli::workflow_proof::{ run_workflow_proof, CodexWorkflowAgent, WorkflowProofReport, @@ -7,6 +7,16 @@ use tree_ring_memory_cli::workflow_proof::{ const USAGE: &str = "usage: workflow_proof [--codex-bin ] [--model ]"; +enum WorkflowProofCliArgs { + Help, + Run { + fixture_dir: PathBuf, + output_dir: PathBuf, + codex_binary: PathBuf, + model: Option, + }, +} + fn main() { if let Err(error) = run() { eprintln!("workflow proof failed: {error}"); @@ -15,11 +25,44 @@ fn main() { } fn run() -> Result<(), String> { - let mut args = std::env::args_os().skip(1); - let fixture_dir = args - .next() - .map(PathBuf::from) - .ok_or_else(|| USAGE.to_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) @@ -53,16 +96,12 @@ fn run() -> Result<(), String> { } } - 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(()) + Ok(WorkflowProofCliArgs::Run { + fixture_dir, + output_dir, + codex_binary, + model, + }) } fn print_summary(report: &WorkflowProofReport) { @@ -83,3 +122,18 @@ fn tree_ring_non_passes(report: &WorkflowProofReport) -> usize { .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}; + + #[test] + fn parse_cli_args_recognizes_help() { + assert!(matches!( + parse_cli_args([OsString::from("--help")]), + Ok(WorkflowProofCliArgs::Help) + )); + } +} diff --git a/crates/tree-ring-memory-core/tests/workflow_scenario.rs b/crates/tree-ring-memory-core/tests/workflow_scenario.rs index b31d390..199e06a 100644 --- a/crates/tree-ring-memory-core/tests/workflow_scenario.rs +++ b/crates/tree-ring-memory-core/tests/workflow_scenario.rs @@ -1,4 +1,4 @@ -use std::fs; +use std::{collections::BTreeSet, fs, path::Path}; use serde_json::{json, Value}; use tempfile::tempdir; @@ -338,6 +338,46 @@ fn workspace_evaluation_rejects_windows_root_and_prefix_paths() { } } +#[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(); diff --git a/docs/integrations/agent-workflow-proof.md b/docs/integrations/agent-workflow-proof.md new file mode 100644 index 0000000..abd38c5 --- /dev/null +++ b/docs/integrations/agent-workflow-proof.md @@ -0,0 +1,52 @@ +# 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 +``` + +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`). The agent task only asks it to inspect +the materialized workspace and prepare `decision.md`; deterministic validators +remain outside the request. + +## 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`. +Treat `workflow-proof-report.json` as observed paired evidence for these +specific controlled fixtures: inspect the retained workspaces, memory context, +agent response, and deterministic file 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 selected model, if `--model` is + supplied; +- 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/fixtures/workflow-proof/no-background-writer.json b/fixtures/workflow-proof/no-background-writer.json new file mode 100644 index 0000000..15da667 --- /dev/null +++ b/fixtures/workflow-proof/no-background-writer.json @@ -0,0 +1,39 @@ +{ + "name": "no-background-writer", + "task": "Inspect the workspace and prepare decision.md with the appropriate workflow decision.", + "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 asks whether a new workflow helper should persist changes without an explicit request. Review the request and decide what should happen.\n" + } + ], + "expected_files": [ + { + "path": "decision.md", + "contains": "no hidden durable writer" + } + ] +} diff --git a/fixtures/workflow-proof/scar-recovery.json b/fixtures/workflow-proof/scar-recovery.json new file mode 100644 index 0000000..83a5e72 --- /dev/null +++ b/fixtures/workflow-proof/scar-recovery.json @@ -0,0 +1,39 @@ +{ + "name": "scar-recovery", + "task": "Inspect the workspace and prepare decision.md with the appropriate workflow decision.", + "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 cache migration left stale state after a failed rollout. Review the incident and choose the next recovery action.\n" + } + ], + "expected_files": [ + { + "path": "decision.md", + "contains": "roll back the 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..dcd7fc1 --- /dev/null +++ b/fixtures/workflow-proof/stale-cli-contract.json @@ -0,0 +1,62 @@ +{ + "name": "stale-cli-contract", + "task": "Inspect the workspace and prepare decision.md with the appropriate workflow decision.", + "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 is being updated to record a project lesson. Review the workspace and give the current command-line recommendation.\n" + } + ], + "expected_files": [ + { + "path": "decision.md", + "contains": "remember needs --event-type" + } + ] +} From c1fd973d3f429d455a820cafec480288da559e23 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Sun, 12 Jul 2026 20:47:36 -0400 Subject: [PATCH 11/21] docs: require workflow proof model identity --- .../plans/2026-07-12-agent-workflow-proof-runner.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index ad6a834..601a216 100644 --- a/docs/superpowers/plans/2026-07-12-agent-workflow-proof-runner.md +++ b/docs/superpowers/plans/2026-07-12-agent-workflow-proof-runner.md @@ -21,6 +21,7 @@ - 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. +- Evidence-producing example runs must require `--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`. --- @@ -37,6 +38,7 @@ - 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. --- @@ -240,7 +242,7 @@ git commit -m "feat: add paired agent workflow proof runner" **Interfaces:** - Fixtures use the Task 1 JSON contract and contain only normal, source-linked synthetic/project-safe memories. -- Documentation gives the exact explicit command and names `workflow-proof-report.json` as observed paired evidence, not a universal benchmark score. +- 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** @@ -273,7 +275,7 @@ cargo run --locked -p tree-ring-memory-cli --example workflow_proof -- \ fixtures/workflow-proof target/tree-ring-certification/workflow-proof ``` -Document the three arms, retained workspace evidence, no automatic Codex invocation in CI, model/version/commit capture requirements, no claim beyond the specific controlled fixtures, and the next step of running external benchmark adapters. +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. From df110a22b1ce4b3a0c72b16029f638527fe177a0 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Sun, 12 Jul 2026 20:54:03 -0400 Subject: [PATCH 12/21] fix: require workflow proof model identity --- README.md | 14 +++++-- .../examples/workflow_proof.rs | 40 +++++++++++++++++-- .../src/workflow_proof.rs | 22 +++++++++- .../tests/workflow_proof.rs | 13 +++--- docs/integrations/agent-workflow-proof.md | 13 ++++-- 5 files changed, 85 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index ff267d2..8b4b6dd 100644 --- a/README.md +++ b/README.md @@ -465,9 +465,17 @@ The quality report is written to 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. See [agent -workflow proof](docs/integrations/agent-workflow-proof.md) for its controlled -command, retained artifacts, and interpretation limits. +observed evidence report separate from normal certification and CI. It requires +an 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, 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 diff --git a/crates/tree-ring-memory-cli/examples/workflow_proof.rs b/crates/tree-ring-memory-cli/examples/workflow_proof.rs index ce4bdac..1120e05 100644 --- a/crates/tree-ring-memory-cli/examples/workflow_proof.rs +++ b/crates/tree-ring-memory-cli/examples/workflow_proof.rs @@ -5,7 +5,7 @@ use tree_ring_memory_cli::workflow_proof::{ }; const USAGE: &str = - "usage: workflow_proof [--codex-bin ] [--model ]"; + "usage: workflow_proof --model [--codex-bin ]"; enum WorkflowProofCliArgs { Help, @@ -13,7 +13,7 @@ enum WorkflowProofCliArgs { fixture_dir: PathBuf, output_dir: PathBuf, codex_binary: PathBuf, - model: Option, + model: String, }, } @@ -39,7 +39,7 @@ fn run() -> Result<(), String> { }; let (fixture_dir, output_dir, codex_binary, model) = args; - let agent = CodexWorkflowAgent::new(codex_binary, model); + let agent = CodexWorkflowAgent::new(codex_binary, Some(model)); let report = run_workflow_proof(&fixture_dir, &output_dir, &agent)?; print_summary(&report); if !report.tree_ring_complete { @@ -96,6 +96,11 @@ fn parse_cli_args( } } + 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, @@ -127,7 +132,7 @@ fn tree_ring_non_passes(report: &WorkflowProofReport) -> usize { mod tests { use std::ffi::OsString; - use super::{parse_cli_args, WorkflowProofCliArgs}; + use super::{parse_cli_args, WorkflowProofCliArgs, USAGE}; #[test] fn parse_cli_args_recognizes_help() { @@ -136,4 +141,31 @@ mod tests { 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/workflow_proof.rs b/crates/tree-ring-memory-cli/src/workflow_proof.rs index ab8b65a..d61781b 100644 --- a/crates/tree-ring-memory-cli/src/workflow_proof.rs +++ b/crates/tree-ring-memory-cli/src/workflow_proof.rs @@ -21,6 +21,10 @@ 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 { @@ -35,6 +39,14 @@ impl CodexWorkflowAgent { } impl WorkflowAgent for CodexWorkflowAgent { + fn evidence_identity(&self) -> String { + self.model + .as_deref() + .filter(|model| !model.trim().is_empty()) + .map(|model| format!("codex:{model}")) + .unwrap_or_else(|| "codex:unrequested-model".to_string()) + } + fn execute(&self, request: &WorkflowAgentRequest) -> Result { fs::create_dir_all(&request.workspace_root) .map_err(|error| format!("workspace_create_error: {error}"))?; @@ -127,6 +139,7 @@ pub struct WorkflowProofArmSummary { 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, @@ -180,7 +193,7 @@ fn run_workflow_proof_with_tree_ring_context_builder( )?); } - let report = summarize(scenarios); + let report = summarize(agent.evidence_identity(), scenarios); write_reports(output_dir, &report)?; Ok(report) } @@ -460,7 +473,10 @@ fn arm_directory(arm: &WorkflowArm) -> &'static str { } } -fn summarize(scenarios: Vec) -> WorkflowProofReport { +fn summarize( + agent_identity: String, + scenarios: Vec, +) -> WorkflowProofReport { let arm_summaries = [ WorkflowArm::NoMemory, WorkflowArm::RawMemory, @@ -487,6 +503,7 @@ fn summarize(scenarios: Vec) -> WorkflowProofReport WorkflowProofReport { schema_version: REPORT_SCHEMA_VERSION, generated_at: now_iso(), + agent_identity, scenario_count, trial_count, arm_summaries, @@ -553,6 +570,7 @@ 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: {}", diff --git a/crates/tree-ring-memory-cli/tests/workflow_proof.rs b/crates/tree-ring-memory-cli/tests/workflow_proof.rs index c03e9d3..d9c5c5a 100644 --- a/crates/tree-ring-memory-cli/tests/workflow_proof.rs +++ b/crates/tree-ring-memory-cli/tests/workflow_proof.rs @@ -71,6 +71,7 @@ fn paired_runner_keeps_controls_and_records_observed_lift() { 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); @@ -110,8 +111,10 @@ fn paired_runner_keeps_controls_and_records_observed_lift() { .join("workspace") .is_dir()); } - assert!(output.path().join("workflow-proof-report.json").is_file()); - assert!(output.path().join("workflow-proof-summary.md").is_file()); + 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] @@ -162,9 +165,9 @@ fn codex_adapter_uses_request_context_and_optional_model_pair() { }], ); - let response = CodexWorkflowAgent::new(binary.clone(), Some("test-model".to_string())) - .execute(&request) - .unwrap(); + let agent = CodexWorkflowAgent::new(binary.clone(), Some("test-model".to_string())); + 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); diff --git a/docs/integrations/agent-workflow-proof.md b/docs/integrations/agent-workflow-proof.md index abd38c5..fb3b3ac 100644 --- a/docs/integrations/agent-workflow-proof.md +++ b/docs/integrations/agent-workflow-proof.md @@ -8,9 +8,15 @@ 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 + 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 Codex is invoked. The report records that +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. @@ -37,8 +43,9 @@ agent response, and deterministic file 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 selected model, if `--model` is - supplied; +- 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 From 8363b88117102904aab7476401861fc8e959b5ff Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Sun, 12 Jul 2026 21:00:16 -0400 Subject: [PATCH 13/21] docs: require model identity at codex boundary --- .../superpowers/plans/2026-07-12-agent-workflow-proof-runner.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 601a216..1ff99b9 100644 --- a/docs/superpowers/plans/2026-07-12-agent-workflow-proof-runner.md +++ b/docs/superpowers/plans/2026-07-12-agent-workflow-proof-runner.md @@ -21,7 +21,7 @@ - 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. -- Evidence-producing example runs must require `--model ` and record that requested model identity in both JSON and Markdown reports; never silently rely on an unrecorded Codex default. +- `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`. --- From 331c88bddccabc95a31b5ccfd4c91a31c2e17757 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Sun, 12 Jul 2026 21:03:43 -0400 Subject: [PATCH 14/21] fix: require model at workflow adapter boundary --- README.md | 3 +- .../examples/workflow_proof.rs | 2 +- .../src/workflow_proof.rs | 26 +++++++------- .../tests/workflow_proof.rs | 34 ++++--------------- docs/integrations/agent-workflow-proof.md | 7 ++-- 5 files changed, 28 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 8b4b6dd..d92157d 100644 --- a/README.md +++ b/README.md @@ -466,7 +466,8 @@ 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 -an explicit model ID and records `codex:` in the paired reports: +a validated explicit model ID and records `codex:` in the paired +reports: ```bash cargo run --locked -p tree-ring-memory-cli --example workflow_proof -- \ diff --git a/crates/tree-ring-memory-cli/examples/workflow_proof.rs b/crates/tree-ring-memory-cli/examples/workflow_proof.rs index 1120e05..3eceb31 100644 --- a/crates/tree-ring-memory-cli/examples/workflow_proof.rs +++ b/crates/tree-ring-memory-cli/examples/workflow_proof.rs @@ -39,7 +39,7 @@ fn run() -> Result<(), String> { }; let (fixture_dir, output_dir, codex_binary, model) = args; - let agent = CodexWorkflowAgent::new(codex_binary, Some(model)); + 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 { diff --git a/crates/tree-ring-memory-cli/src/workflow_proof.rs b/crates/tree-ring-memory-cli/src/workflow_proof.rs index d61781b..1add9aa 100644 --- a/crates/tree-ring-memory-cli/src/workflow_proof.rs +++ b/crates/tree-ring-memory-cli/src/workflow_proof.rs @@ -29,22 +29,25 @@ pub trait WorkflowAgent { pub struct CodexWorkflowAgent { binary: PathBuf, - model: Option, + model: String, } impl CodexWorkflowAgent { - pub fn new(binary: PathBuf, model: Option) -> Self { - Self { binary, model } + 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()); + } + Ok(Self { + binary, + model: model.to_string(), + }) } } impl WorkflowAgent for CodexWorkflowAgent { fn evidence_identity(&self) -> String { - self.model - .as_deref() - .filter(|model| !model.trim().is_empty()) - .map(|model| format!("codex:{model}")) - .unwrap_or_else(|| "codex:unrequested-model".to_string()) + format!("codex:{}", self.model) } fn execute(&self, request: &WorkflowAgentRequest) -> Result { @@ -74,10 +77,9 @@ impl WorkflowAgent for CodexWorkflowAgent { .arg("--output-schema") .arg(&schema_path) .arg("--output-last-message") - .arg(&response_path); - if let Some(model) = &self.model { - command.arg("--model").arg(model); - } + .arg(&response_path) + .arg("--model") + .arg(&self.model); let status = command .arg(prompt) .status() diff --git a/crates/tree-ring-memory-cli/tests/workflow_proof.rs b/crates/tree-ring-memory-cli/tests/workflow_proof.rs index d9c5c5a..c8a9e68 100644 --- a/crates/tree-ring-memory-cli/tests/workflow_proof.rs +++ b/crates/tree-ring-memory-cli/tests/workflow_proof.rs @@ -139,7 +139,7 @@ fn unknown_cited_memory_is_recorded_as_a_trial_error() { #[cfg(unix)] #[test] -fn codex_adapter_uses_request_context_and_optional_model_pair() { +fn codex_adapter_uses_request_context_and_required_model() { use std::os::unix::fs::PermissionsExt; let workspace = tempdir().unwrap(); @@ -165,7 +165,7 @@ fn codex_adapter_uses_request_context_and_optional_model_pair() { }], ); - let agent = CodexWorkflowAgent::new(binary.clone(), Some("test-model".to_string())); + 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(); @@ -219,33 +219,13 @@ fn codex_adapter_uses_request_context_and_optional_model_pair() { .is_file()); } -#[cfg(unix)] #[test] -fn codex_adapter_omits_model_flag_when_no_model_is_configured() { - use std::os::unix::fs::PermissionsExt; - - let workspace = tempdir().unwrap(); - let binary = workspace.path().join("fake-codex-no-model"); - write_fake_codex(&binary, ""); - let mut permissions = fs::metadata(&binary).unwrap().permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&binary, permissions).unwrap(); +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"); - let request = WorkflowAgentRequest::new( - "adapter scenario".to_string(), - WorkflowArm::NoMemory, - "Do the source task.".to_string(), - workspace.path().to_path_buf(), - Vec::new(), - ); - - CodexWorkflowAgent::new(binary.clone(), None) - .execute(&request) - .unwrap(); - - let arguments = fake_codex_arguments(&binary); - assert_eq!(arguments.len(), 11); - assert!(!arguments.iter().any(|argument| argument == "--model")); + assert_eq!(error, "codex workflow model is required"); } fn trial_for( diff --git a/docs/integrations/agent-workflow-proof.md b/docs/integrations/agent-workflow-proof.md index fb3b3ac..b865158 100644 --- a/docs/integrations/agent-workflow-proof.md +++ b/docs/integrations/agent-workflow-proof.md @@ -13,9 +13,10 @@ cargo run --locked -p tree-ring-memory-cli --example workflow_proof -- \ ``` `--model ` is required for an evidence-producing run; an omitted or -blank value is a usage error before Codex is invoked. The report records that -requested value as `agent_identity: "codex:"` in JSON and as an -agent-identity line in the Markdown summary. +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: From a10abc56742768f5323fc56c692b686ac8291954 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Sun, 12 Jul 2026 21:09:29 -0400 Subject: [PATCH 15/21] docs: align workflow proof plan with model boundary --- .../2026-07-12-agent-workflow-proof-runner.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) 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 index 1ff99b9..a6b2331 100644 --- a/docs/superpowers/plans/2026-07-12-agent-workflow-proof-runner.md +++ b/docs/superpowers/plans/2026-07-12-agent-workflow-proof-runner.md @@ -151,7 +151,7 @@ git commit -m "feat: add workflow proof scenario contract" **Interfaces:** - Produces `run_workflow_proof(fixture_dir: &Path, output_dir: &Path, agent: &impl WorkflowAgent) -> Result`. -- Produces `CodexWorkflowAgent::new(binary: PathBuf, model: Option)`. +- 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. @@ -181,7 +181,7 @@ Add a CLI library target exposing `workflow_proof`. In `run_workflow_proof`: 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`, `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. +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** @@ -193,12 +193,13 @@ pub trait WorkflowAgent { } ``` -`CodexWorkflowAgent` must invoke exactly this shape (with an optional `--model` pair only when supplied): +`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 ``` @@ -207,7 +208,7 @@ The prompt must say: work only in the workspace, use source/task files over memo The example parser must accept: ```text -workflow_proof [--codex-bin ] [--model ] +workflow_proof --model [--codex-bin ] ``` The default binary is `codex`; no invocation happens until a user runs this example. @@ -272,7 +273,8 @@ 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 + 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. @@ -315,7 +317,8 @@ Run: ```bash rm -rf target/tree-ring-certification/workflow-proof cargo run --locked -p tree-ring-memory-cli --example workflow_proof -- \ - fixtures/workflow-proof target/tree-ring-certification/workflow-proof + fixtures/workflow-proof target/tree-ring-certification/workflow-proof \ + --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. From fc92aa82d1296e2f33ff54cc7c42a28fe26e7054 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Sun, 12 Jul 2026 21:31:11 -0400 Subject: [PATCH 16/21] fix: resolve workflow codex binary --- .../src/workflow_proof.rs | 92 ++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/crates/tree-ring-memory-cli/src/workflow_proof.rs b/crates/tree-ring-memory-cli/src/workflow_proof.rs index 1add9aa..a57c21b 100644 --- a/crates/tree-ring-memory-cli/src/workflow_proof.rs +++ b/crates/tree-ring-memory-cli/src/workflow_proof.rs @@ -1,7 +1,7 @@ use std::collections::BTreeSet; use std::ffi::OsStr; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::process::Command; use serde::{Deserialize, Serialize}; @@ -38,6 +38,7 @@ impl CodexWorkflowAgent { 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(), @@ -45,6 +46,43 @@ impl CodexWorkflowAgent { } } +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) @@ -656,6 +694,9 @@ memory_context:\n{}", #[cfg(test)] mod tests { + use std::env; + #[cfg(unix)] + use std::os::unix::fs::symlink; use std::sync::Mutex; use super::*; @@ -742,6 +783,55 @@ mod tests { assert!(output.path().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"), From fb8354a30aa6b1058a691166c315c40ccb5db4c5 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Mon, 13 Jul 2026 13:05:45 -0400 Subject: [PATCH 17/21] fix: align stale workflow retrieval fixture --- .../tests/workflow_proof.rs | 32 +++++++++++++++++++ .../workflow-proof/stale-cli-contract.json | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/tree-ring-memory-cli/tests/workflow_proof.rs b/crates/tree-ring-memory-cli/tests/workflow_proof.rs index c8a9e68..afb7dd3 100644 --- a/crates/tree-ring-memory-cli/tests/workflow_proof.rs +++ b/crates/tree-ring-memory-cli/tests/workflow_proof.rs @@ -117,6 +117,34 @@ fn paired_runner_keeps_controls_and_records_observed_lift() { 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"] + ); + + 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")); +} + #[test] fn unknown_cited_memory_is_recorded_as_a_trial_error() { let fixtures = tempdir().unwrap(); @@ -246,6 +274,10 @@ fn request_for(requests: &[WorkflowAgentRequest], arm: WorkflowArm) -> &Workflow .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 write_no_background_writer_fixture(fixture_dir: &Path) { fs::write( fixture_dir.join("no-background-writer.json"), diff --git a/fixtures/workflow-proof/stale-cli-contract.json b/fixtures/workflow-proof/stale-cli-contract.json index dcd7fc1..e07f673 100644 --- a/fixtures/workflow-proof/stale-cli-contract.json +++ b/fixtures/workflow-proof/stale-cli-contract.json @@ -1,6 +1,6 @@ { "name": "stale-cli-contract", - "task": "Inspect the workspace and prepare decision.md with the appropriate workflow decision.", + "task": "Inspect the workspace and prepare decision.md with the current command-line guidance for recording a memory.", "seed_memories": [ { "id": "mem_workflow_stale_cli_contract", From de18a56dadd33d937d8c173ab19935ea8fa50640 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Mon, 13 Jul 2026 13:27:52 -0400 Subject: [PATCH 18/21] feat: add structured workflow file checks --- crates/tree-ring-memory-core/src/workflow.rs | 109 +++++++- .../tests/workflow_scenario.rs | 246 +++++++++++++++++- 2 files changed, 338 insertions(+), 17 deletions(-) diff --git a/crates/tree-ring-memory-core/src/workflow.rs b/crates/tree-ring-memory-core/src/workflow.rs index 423e1be..8efca0a 100644 --- a/crates/tree-ring-memory-core/src/workflow.rs +++ b/crates/tree-ring-memory-core/src/workflow.rs @@ -1,10 +1,11 @@ use std::{ - collections::HashSet, + collections::{BTreeMap, HashSet}, fs, path::{Path, PathBuf}, }; use serde::{de::Deserializer, Deserialize, Serialize}; +use serde_json::Value; use crate::models::{ MemoryEvent, MemoryLink, MemoryReview, MemorySource, TreeRingError, TreeRingResult, @@ -200,19 +201,23 @@ impl WorkflowScenario { } } - let mut expected_file_pairs = HashSet::new(); + 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, )?; - validate_nonblank( - &format!("workflow scenario expected_files[{index}].contains"), - &expectation.contains, - )?; - if !expected_file_pairs.insert((path_key, expectation.contains.clone())) { + 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 contains" + "workflow scenario expected_files[{index}] duplicates path and check" ))); } } @@ -247,7 +252,40 @@ pub struct WorkflowWorkspaceFile { #[serde(deny_unknown_fields)] pub struct WorkflowFileExpectation { pub path: String, - pub contains: 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)] @@ -325,7 +363,10 @@ impl WorkflowAgentResponse { #[serde(deny_unknown_fields)] pub struct WorkflowFileCheckReport { pub path: String, - pub contains: 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, } @@ -351,7 +392,7 @@ pub fn evaluate_workspace( let exists = path.is_file(); let passed = exists && fs::read_to_string(&path) - .map(|content| content.contains(&expectation.contains)) + .map(|content| evaluate_file_content(expectation, &content)) .unwrap_or(false); (exists, passed) } else { @@ -361,6 +402,7 @@ pub fn evaluate_workspace( WorkflowFileCheckReport { path: expectation.path.clone(), contains: expectation.contains.clone(), + json_fields: expectation.json_fields.clone(), exists, passed, } @@ -375,6 +417,51 @@ fn validate_nonblank(field: &str, value: &str) -> TreeRingResult<()> { 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>, diff --git a/crates/tree-ring-memory-core/tests/workflow_scenario.rs b/crates/tree-ring-memory-core/tests/workflow_scenario.rs index 199e06a..166671c 100644 --- a/crates/tree-ring-memory-core/tests/workflow_scenario.rs +++ b/crates/tree-ring-memory-core/tests/workflow_scenario.rs @@ -1,4 +1,8 @@ -use std::{collections::BTreeSet, fs, path::Path}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::Path, +}; use serde_json::{json, Value}; use tempfile::tempdir; @@ -25,6 +29,22 @@ fn scenario_with_seed_memory(seed_memory: Value) -> String { 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() +} + fn valid_seed_memory() -> Value { json!({ "id": "mem_seed", @@ -65,7 +85,10 @@ fn parses_safe_workflow_fixture_and_keeps_validator_out_of_agent_request() { 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, "safe action"); + assert_eq!( + scenario.expected_files[0].contains.as_deref(), + Some("safe action") + ); assert!(!serialized.contains("safe action")); assert_eq!( request_fields @@ -85,6 +108,27 @@ fn parses_safe_workflow_fixture_and_keeps_validator_out_of_agent_request() { ); } +#[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 @@ -132,6 +176,73 @@ fn rejects_invalid_scenario_contract_values() { 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( @@ -270,6 +381,125 @@ fn evaluates_expected_files_in_fixture_order() { assert!(!reports[1].passed); } +#[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, 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); +} + +#[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, 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\"")); +} + +#[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, workspace.path()); + + assert!(reports[0].exists); + assert!(!reports[0].passed); +} + +#[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, workspace.path()); + + assert!(reports[0].exists); + assert!(!reports[0].passed); +} + +#[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, 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(); @@ -283,7 +513,8 @@ fn workspace_evaluation_does_not_follow_unsafe_paths_from_manually_built_scenari workspace_files: Vec::new(), expected_files: vec![WorkflowFileExpectation { path: "../escape.md".to_string(), - contains: "safe action".to_string(), + contains: Some("safe action".to_string()), + json_fields: None, }], }; @@ -326,7 +557,8 @@ fn workspace_evaluation_rejects_windows_root_and_prefix_paths() { workspace_files: Vec::new(), expected_files: vec![WorkflowFileExpectation { path: unsafe_path.to_string(), - contains: "safe action".to_string(), + contains: Some("safe action".to_string()), + json_fields: None, }], }; @@ -391,11 +623,13 @@ fn workspace_evaluation_rejects_portable_root_and_backslash_separator_paths() { expected_files: vec![ WorkflowFileExpectation { path: "/escape.txt".to_string(), - contains: "safe action".to_string(), + contains: Some("safe action".to_string()), + json_fields: None, }, WorkflowFileExpectation { path: r"out\decision.md".to_string(), - contains: "safe action".to_string(), + contains: Some("safe action".to_string()), + json_fields: None, }, ], }; From 8fce211bc38271bb9e0297a031aa524b8f71a5fb Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Mon, 13 Jul 2026 13:37:05 -0400 Subject: [PATCH 19/21] fix: reject symlinked workflow outputs --- crates/tree-ring-memory-core/src/workflow.rs | 148 ++++++++++++++++++- 1 file changed, 141 insertions(+), 7 deletions(-) diff --git a/crates/tree-ring-memory-core/src/workflow.rs b/crates/tree-ring-memory-core/src/workflow.rs index 8efca0a..a564762 100644 --- a/crates/tree-ring-memory-core/src/workflow.rs +++ b/crates/tree-ring-memory-core/src/workflow.rs @@ -388,13 +388,10 @@ pub fn evaluate_workspace( let (exists, passed) = if let Ok(path_key) = canonical_workflow_path("workflow file expectation path", &expectation.path) { - let path = workspace_root.join(path_key); - let exists = path.is_file(); - let passed = exists - && fs::read_to_string(&path) - .map(|content| evaluate_file_content(expectation, &content)) - .unwrap_or(false); - (exists, passed) + match read_regular_workspace_file(workspace_root, &path_key) { + Some(content) => (true, evaluate_file_content(expectation, &content)), + None => (false, false), + } } else { (false, false) }; @@ -410,6 +407,38 @@ pub fn evaluate_workspace( .collect() } +fn read_regular_workspace_file(workspace_root: &Path, path_key: &str) -> Option { + let workspace_metadata = fs::symlink_metadata(workspace_root).ok()?; + if workspace_metadata.file_type().is_symlink() || !workspace_metadata.is_dir() { + return None; + } + let canonical_workspace = fs::canonicalize(workspace_root).ok()?; + + let segments: Vec<_> = path_key.split('/').collect(); + let mut path = workspace_root.to_path_buf(); + for (index, segment) in segments.iter().enumerate() { + path.push(segment); + let metadata = fs::symlink_metadata(&path).ok()?; + if metadata.file_type().is_symlink() { + return None; + } + if index + 1 == segments.len() { + if !metadata.is_file() { + return None; + } + } else if !metadata.is_dir() { + return None; + } + } + + let canonical_path = fs::canonicalize(&path).ok()?; + if !canonical_path.starts_with(&canonical_workspace) { + return None; + } + + fs::read_to_string(path).ok() +} + fn validate_nonblank(field: &str, value: &str) -> TreeRingResult<()> { if value.trim().is_empty() { return Err(TreeRingError::Validation(format!("{field} is required"))); @@ -544,3 +573,108 @@ 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; + + use serde_json::json; + use tempfile::tempdir; + + use super::{evaluate_workspace, WorkflowFileExpectation, WorkflowScenario}; + + 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(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, workspace.path()); + + assert_eq!(reports.len(), 1); + assert!(!reports[0].exists); + assert!(!reports[0].passed); + } + + #[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(); + 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); + } + + #[test] + fn evaluate_workspace_accepts_regular_nested_files_for_both_check_modes() { + let workspace = tempdir().unwrap(); + 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)); + } +} From 31725dc79daa6e0660d656762bb970b45ac39816 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Mon, 13 Jul 2026 14:05:09 -0400 Subject: [PATCH 20/21] feat: harden structured workflow proof evidence --- Cargo.lock | 7 +- Cargo.toml | 3 +- README.md | 3 +- crates/tree-ring-memory-cli/Cargo.toml | 4 +- .../src/workflow_proof.rs | 64 +++- .../tests/workflow_proof.rs | 229 +++++++++++++- crates/tree-ring-memory-core/Cargo.toml | 1 + crates/tree-ring-memory-core/src/workflow.rs | 294 ++++++++++++++++-- .../tests/workflow_scenario.rs | 26 +- crates/tree-ring-memory-sqlite/Cargo.toml | 2 +- docs/integrations/agent-workflow-proof.md | 15 +- .../2026-07-12-agent-workflow-proof-runner.md | 15 +- .../workflow-proof/no-background-writer.json | 14 +- fixtures/workflow-proof/scar-recovery.json | 14 +- .../workflow-proof/stale-cli-contract.json | 14 +- 15 files changed, 624 insertions(+), 81 deletions(-) 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 d92157d..906a8e0 100644 --- a/README.md +++ b/README.md @@ -476,7 +476,8 @@ cargo run --locked -p tree-ring-memory-cli --example workflow_proof -- \ ``` See [agent workflow proof](docs/integrations/agent-workflow-proof.md) for the -controlled command, retained artifacts, and interpretation limits. +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 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/src/workflow_proof.rs b/crates/tree-ring-memory-cli/src/workflow_proof.rs index a57c21b..afa2e9a 100644 --- a/crates/tree-ring-memory-cli/src/workflow_proof.rs +++ b/crates/tree-ring-memory-cli/src/workflow_proof.rs @@ -210,6 +210,12 @@ fn run_workflow_proof_with_tree_ring_context_builder( ) -> 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() { @@ -227,14 +233,14 @@ fn run_workflow_proof_with_tree_ring_context_builder( scenarios.push(run_scenario( &scenario, &scenario_id, - output_dir, + &output_dir, agent, &tree_ring_context_builder, )?); } let report = summarize(agent.evidence_identity(), scenarios); - write_reports(output_dir, &report)?; + write_reports(&output_dir, &report)?; Ok(report) } @@ -717,8 +723,8 @@ mod tests { self.calls.lock().unwrap().push(request.arm.clone()); if request.arm == WorkflowArm::RawMemory { fs::write( - request.workspace_root.join("decision.md"), - "Use the seeded control decision.\n", + 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())?; } @@ -763,6 +769,14 @@ mod tests { 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()); @@ -783,6 +797,41 @@ mod tests { 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() { @@ -837,7 +886,7 @@ mod tests { fixture_dir.join("context-failure.json"), r#"{ "name": "context failure", - "task": "Create decision.md from the visible control memory.", + "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", @@ -856,10 +905,11 @@ mod tests { } ], "workspace_files": [ - {"path": "task.md", "content": "Create the requested decision."} + {"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.md", "contains": "seeded control decision"} + {"path": "decision.json", "json_fields": {"/action": "use_seeded_control_decision"}} ] }"#, ) diff --git a/crates/tree-ring-memory-cli/tests/workflow_proof.rs b/crates/tree-ring-memory-cli/tests/workflow_proof.rs index afb7dd3..23a732a 100644 --- a/crates/tree-ring-memory-cli/tests/workflow_proof.rs +++ b/crates/tree-ring-memory-cli/tests/workflow_proof.rs @@ -9,6 +9,9 @@ use tree_ring_memory_cli::workflow_proof::{ 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>, @@ -32,14 +35,17 @@ impl WorkflowAgent for FakeAgent { fn execute(&self, request: &WorkflowAgentRequest) -> Result { self.requests.lock().unwrap().push(request.clone()); - let has_target_memory = request - .memory_context - .iter() - .any(|memory| memory.id == TARGET_MEMORY_ID); - if has_target_memory { + 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.md"), - "No background writer should run without an explicit request.\n", + 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())?; } @@ -48,10 +54,10 @@ impl WorkflowAgent for FakeAgent { summary: "completed the requested workspace task".to_string(), used_memory_ids: if self.cite_unknown_memory { vec!["memory-not-in-context".to_string()] - } else if has_target_memory { - vec![TARGET_MEMORY_ID.to_string()] } else { - Vec::new() + decision + .map(|(memory_id, _)| vec![memory_id]) + .unwrap_or_default() }, }) } @@ -136,6 +142,12 @@ fn stale_cli_fixture_injects_current_contract_and_omits_superseded_contract() { 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!( @@ -143,6 +155,164 @@ fn stale_cli_fixture_injects_current_contract_and_omits_superseded_contract() { ["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] @@ -278,12 +448,37 @@ fn memory_ids(memories: &[tree_ring_memory_core::WorkflowMemoryContext]) -> Vec< 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": "Create decision.md describing whether to start a 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", @@ -345,13 +540,19 @@ fn write_no_background_writer_fixture(fixture_dir: &Path) { "workspace_files": [ { "path": "task.md", - "content": "Decide whether a background writer should run." + "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.md", - "contains": "No background writer" + "path": "decision.json", + "json_fields": { + "/action": "require_explicit_durable_request" + } } ] }"#, 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/workflow.rs b/crates/tree-ring-memory-core/src/workflow.rs index a564762..dd5a66e 100644 --- a/crates/tree-ring-memory-core/src/workflow.rs +++ b/crates/tree-ring-memory-core/src/workflow.rs @@ -1,9 +1,20 @@ 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; @@ -408,35 +419,111 @@ pub fn evaluate_workspace( } fn read_regular_workspace_file(workspace_root: &Path, path_key: &str) -> Option { - let workspace_metadata = fs::symlink_metadata(workspace_root).ok()?; - if workspace_metadata.file_type().is_symlink() || !workspace_metadata.is_dir() { - return None; - } - let canonical_workspace = fs::canonicalize(workspace_root).ok()?; + 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); + } - let segments: Vec<_> = path_key.split('/').collect(); - let mut path = workspace_root.to_path_buf(); - for (index, segment) in segments.iter().enumerate() { - path.push(segment); - let metadata = fs::symlink_metadata(&path).ok()?; - if metadata.file_type().is_symlink() { + if !file.metadata().ok()?.is_dir() { return None; } - if index + 1 == segments.len() { - if !metadata.is_file() { - 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; } - } else if !metadata.is_dir() { - return None; + Component::RootDir | Component::ParentDir | Component::Prefix(_) => return None, } } - let canonical_path = fs::canonicalize(&path).ok()?; - if !canonical_path.starts_with(&canonical_workspace) { + 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; } - fs::read_to_string(path).ok() + 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<()> { @@ -578,11 +665,20 @@ fn has_windows_drive_prefix(value: &str) -> bool { 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 { @@ -595,6 +691,33 @@ mod tests { } } + #[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() { @@ -611,13 +734,105 @@ mod tests { json_fields: None, }]); - let reports = evaluate_workspace(&scenario, workspace.path()); + 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() { @@ -625,36 +840,63 @@ mod tests { 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(); + 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()); + 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(); - fs::create_dir_all(workspace.path().join("out")).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"), + workspace_path.join("out/decision.md"), "Choose the safe action.", ) .unwrap(); fs::write( - workspace.path().join("out/decision.json"), + workspace_path.join("out/decision.json"), r#"{"decision": {"status": "approved"}}"#, ) .unwrap(); @@ -671,7 +913,7 @@ mod tests { }, ]); - let reports = evaluate_workspace(&scenario, workspace.path()); + let reports = evaluate_workspace(&scenario, &workspace_path); assert_eq!(reports.len(), 2); assert!(reports.iter().all(|report| report.exists)); diff --git a/crates/tree-ring-memory-core/tests/workflow_scenario.rs b/crates/tree-ring-memory-core/tests/workflow_scenario.rs index 166671c..def06b1 100644 --- a/crates/tree-ring-memory-core/tests/workflow_scenario.rs +++ b/crates/tree-ring-memory-core/tests/workflow_scenario.rs @@ -4,6 +4,9 @@ use std::{ path::Path, }; +#[cfg(unix)] +use std::path::PathBuf; + use serde_json::{json, Value}; use tempfile::tempdir; use tree_ring_memory_core::{ @@ -45,6 +48,11 @@ fn json_field_scenario(json_fields: Value) -> WorkflowScenario { .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", @@ -349,6 +357,7 @@ fn validates_agent_responses_without_validating_context_membership() { .is_err()); } +#[cfg(unix)] #[test] fn evaluates_expected_files_in_fixture_order() { let scenario = parse_workflow_scenario( @@ -370,7 +379,7 @@ fn evaluates_expected_files_in_fixture_order() { ) .unwrap(); - let reports = evaluate_workspace(&scenario, workspace.path()); + let reports = evaluate_workspace(&scenario, &physical_path(workspace.path())); assert_eq!(reports.len(), 2); assert_eq!(reports[0].path, "decision.md"); @@ -381,6 +390,7 @@ fn evaluates_expected_files_in_fixture_order() { assert!(!reports[1].passed); } +#[cfg(unix)] #[test] fn retains_legacy_contains_file_checks() { let scenario = parse_workflow_scenario(VALID_SCENARIO).unwrap(); @@ -391,7 +401,7 @@ fn retains_legacy_contains_file_checks() { ) .unwrap(); - let reports = evaluate_workspace(&scenario, workspace.path()); + let reports = evaluate_workspace(&scenario, &physical_path(workspace.path())); assert_eq!(reports.len(), 1); assert_eq!(reports[0].contains.as_deref(), Some("safe action")); @@ -399,6 +409,7 @@ fn retains_legacy_contains_file_checks() { assert!(reports[0].passed); } +#[cfg(unix)] #[test] fn evaluates_json_field_expectations_with_exact_json_pointer_values() { let scenario = parse_workflow_scenario( @@ -427,7 +438,7 @@ fn evaluates_json_field_expectations_with_exact_json_pointer_values() { ) .unwrap(); - let reports = evaluate_workspace(&scenario, workspace.path()); + let reports = evaluate_workspace(&scenario, &physical_path(workspace.path())); assert_eq!(reports.len(), 1); assert!(reports[0].exists); @@ -453,6 +464,7 @@ fn evaluates_json_field_expectations_with_exact_json_pointer_values() { 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!({ @@ -466,12 +478,13 @@ fn marks_json_field_check_failed_when_a_pointer_is_missing() { ) .unwrap(); - let reports = evaluate_workspace(&scenario, workspace.path()); + 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"})); @@ -482,19 +495,20 @@ fn marks_json_field_check_failed_when_a_value_is_wrong() { ) .unwrap(); - let reports = evaluate_workspace(&scenario, workspace.path()); + 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, workspace.path()); + let reports = evaluate_workspace(&scenario, &physical_path(workspace.path())); assert!(reports[0].exists); assert!(!reports[0].passed); 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/integrations/agent-workflow-proof.md b/docs/integrations/agent-workflow-proof.md index b865158..0d117f7 100644 --- a/docs/integrations/agent-workflow-proof.md +++ b/docs/integrations/agent-workflow-proof.md @@ -28,18 +28,25 @@ The command runs the same task in three retained workspaces for every fixture: 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`). The agent task only asks it to inspect -the materialized workspace and prepare `decision.md`; deterministic validators -remain outside the request. +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 file checks before drawing a conclusion. +agent response, and deterministic JSON field checks before drawing a +conclusion. For every run, record alongside the output: 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 index a6b2331..bc2c4dd 100644 --- a/docs/superpowers/plans/2026-07-12-agent-workflow-proof-runner.md +++ b/docs/superpowers/plans/2026-07-12-agent-workflow-proof-runner.md @@ -303,6 +303,12 @@ 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/` @@ -315,9 +321,10 @@ git commit -m "docs: add agent workflow proof fixtures" Run: ```bash -rm -rf target/tree-ring-certification/workflow-proof +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 target/tree-ring-certification/workflow-proof \ + fixtures/workflow-proof "$proof_output" \ --model ``` @@ -328,8 +335,8 @@ Expected: all nine paired trials complete, the report exists, and the process ex Run: ```bash -sed -n '1,260p' target/tree-ring-certification/workflow-proof/workflow-proof-report.json -find target/tree-ring-certification/workflow-proof/trials -maxdepth 4 -type f | sort +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. diff --git a/fixtures/workflow-proof/no-background-writer.json b/fixtures/workflow-proof/no-background-writer.json index 15da667..437be44 100644 --- a/fixtures/workflow-proof/no-background-writer.json +++ b/fixtures/workflow-proof/no-background-writer.json @@ -1,6 +1,6 @@ { "name": "no-background-writer", - "task": "Inspect the workspace and prepare decision.md with the appropriate workflow decision.", + "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", @@ -27,13 +27,19 @@ "workspace_files": [ { "path": "request.md", - "content": "A maintainer asks whether a new workflow helper should persist changes without an explicit request. Review the request and decide what should happen.\n" + "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.md", - "contains": "no hidden durable writer" + "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 index 83a5e72..efeb3ec 100644 --- a/fixtures/workflow-proof/scar-recovery.json +++ b/fixtures/workflow-proof/scar-recovery.json @@ -1,6 +1,6 @@ { "name": "scar-recovery", - "task": "Inspect the workspace and prepare decision.md with the appropriate workflow decision.", + "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", @@ -27,13 +27,19 @@ "workspace_files": [ { "path": "incident.md", - "content": "A cache migration left stale state after a failed rollout. Review the incident and choose the next recovery action.\n" + "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.md", - "contains": "roll back the cache migration" + "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 index e07f673..f5de94c 100644 --- a/fixtures/workflow-proof/stale-cli-contract.json +++ b/fixtures/workflow-proof/stale-cli-contract.json @@ -1,6 +1,6 @@ { "name": "stale-cli-contract", - "task": "Inspect the workspace and prepare decision.md with the current command-line guidance for recording a memory.", + "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", @@ -50,13 +50,19 @@ "workspace_files": [ { "path": "helper-script.md", - "content": "A helper script is being updated to record a project lesson. Review the workspace and give the current command-line recommendation.\n" + "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.md", - "contains": "remember needs --event-type" + "path": "decision.json", + "json_fields": { + "/action": "require_event_type" + } } ] } From 7a79f12b2cb35308f282c1a6d4642871b96f92c4 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Wed, 15 Jul 2026 18:12:08 -0400 Subject: [PATCH 21/21] docs: prepare v0.12.0 release --- README.md | 23 ++++++++++++----------- docs/feed.xml | 10 +++++++++- docs/index.html | 2 +- docs/llms.txt | 7 ++++--- docs/press-kit.md | 5 +++-- docs/sitemap.xml | 8 ++++---- marketing/README.md | 2 +- 7 files changed, 34 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 906a8e0..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. 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/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/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: