diff --git a/README.md b/README.md index caf5923..be23291 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Quipu's thesis: **start strict, use agents to bear the cost of strictness.** - **Graph projection** — materialize subgraphs into petgraph for centrality, connected components, shortest path algorithms. - **Federation** — `GraphProvider` trait for multi-source queries. Query local and remote Quipu instances in a single operation. -- **Four interfaces** — Rust crate (embed), CLI (`quipu`), REST API (`quipu-server`), and built-in web UI with embeddable web components. Plus 22 MCP tools for agent integration (23 with the `owl` feature). +- **Four interfaces** — Rust crate (embed), CLI (`quipu`), REST API (`quipu-server`), and built-in web UI with embeddable web components. Plus 25 MCP tools for agent integration (26 with the `owl` feature). - **"SQLite energy"** — single process, no server required, inspect with `sqlite3`, back up with `cp`. - **Automated releases** — release-plz bumps versions from conventional commits, generates changelogs via git-cliff, and creates GitHub releases. CI runs fmt, clippy, tests, and markdown lint on every push. @@ -242,7 +242,7 @@ The reasoner adds forward-chaining inference over the EAVT fact log: Quipu is designed as a [Bobbin](https://github.com/scbrown/bobbin) subsystem. Bobbin holds the thread (code context); Quipu ties knots of structured meaning into it. -When running as a Bobbin subsystem, agents get 22 MCP tools (23 with the +When running as a Bobbin subsystem, agents get 25 MCP tools (26 with the `owl` feature). The two most commonly used for knowledge-aware context: diff --git a/docs/book/src/reference/mcp-tools.md b/docs/book/src/reference/mcp-tools.md index e6d6183..2064750 100644 --- a/docs/book/src/reference/mcp-tools.md +++ b/docs/book/src/reference/mcp-tools.md @@ -4,8 +4,8 @@ Quipu exposes its API as MCP (Model Context Protocol) tools for agent integration. These tools are available when Quipu runs as a Bobbin subsystem or standalone MCP server. -The registry (`tool_definitions()`) exposes **24 tools** in a default build, or -**25** when built with the `owl` feature (which adds `quipu_load_ontology`). +The registry (`tool_definitions()`) exposes **25 tools** in a default build, or +**26** when built with the `owl` feature (which adds `quipu_load_ontology`). ## Tool Reference @@ -87,6 +87,24 @@ Retract facts for an entity. | `timestamp` | No | Retraction timestamp | | `actor` | No | Who is retracting | +### `quipu_retract_episode` + +Episode-scoped **logical** retraction (`POST /episode/retract`). Retracts every +currently-active fact an episode's ingest contributed (activity node, entities, +edges, reified statements) by closing `valid_to` — logical, not physical, so +time-travel history is preserved. Entities and other episodes' facts (even about +shared IRIs) are untouched. Idempotent. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `episode` | Yes | Episode name to retract (aliases: `episode_id`, `name`) | +| `timestamp` | No | Retraction timestamp | +| `actor` | No | Who is retracting | + +Retraction is a more sensitive write than assertion. The endpoint honours +read-only mode and bearer auth today; when per-principal scopes (hq-azs) and +crew identity (hq-otm) land it should require an authorized principal. + ### `quipu_episode` Ingest structured agent knowledge as an episode. diff --git a/docs/book/src/reference/rest-api.md b/docs/book/src/reference/rest-api.md index ed13a69..17ad41d 100644 --- a/docs/book/src/reference/rest-api.md +++ b/docs/book/src/reference/rest-api.md @@ -119,6 +119,39 @@ curl -s localhost:3030/retract -X POST \ Optional: `predicate` (only retract matching), `timestamp`, `actor`. +### `POST /episode/retract` + +Episode-scoped **logical** retraction. Retracts every currently-active fact an +episode's ingest contributed — its activity node, generated entities, the bare +relationship triples (edges), and any reified confidence statements — by closing +their `valid_to` via the bitemporal retract path. Facts are never physically +deleted, so time-travel queries (`/cord`, `/unravel`) still show them. + +The retraction unit is the episode's ingest transaction(s), identified by their +`source = "episode:{name}"` tag. Because identical assertions are deduplicated to +a single owning transaction, retracting an episode only removes the facts *that +episode actually wrote* — entities and facts contributed by other episodes (even +about the same shared IRIs) survive untouched. This is the safe way to undo a +specific episode's contributions without SQL surgery on shared entities. + +```bash +curl -s localhost:3030/episode/retract -X POST \ + -H "Content-Type: application/json" \ + -d '{"episode": "goldblum-deploy-verify-032"}' +``` + +Aliases for `episode`: `episode_id`, `name`. Optional: `timestamp`, `actor`. +**Idempotent** — retracting an already-retracted or unknown episode returns +`{"retracted": 0}` and changes nothing. Response includes `tx_id`, `retracted` +(count), and `statements` (the retracted facts). + +> **Auth (hq-azs / hq-otm).** Retraction is a write — and a *more* sensitive one +> than assertion, since it removes facts from current views. The endpoint is in +> `http_auth::WRITE_ENDPOINTS`, so it already honours read-only mode and the +> bearer token like every other write. When per-principal scopes (hq-azs) and +> crew identity (hq-otm) land, retraction should be gated to an authorized +> principal, not merely the same token that permits assertion. + ### `POST /shapes` Manage persistent SHACL shapes. diff --git a/docs/design/episode-retraction.md b/docs/design/episode-retraction.md new file mode 100644 index 0000000..c99a612 --- /dev/null +++ b/docs/design/episode-retraction.md @@ -0,0 +1,124 @@ +# Episode-Scoped Logical Retraction + +> Created: 2026-06-29 +> Status: IMPLEMENTED (aegis-hxb) +> Related: [vision.md](./vision.md), [../book/src/architecture/episodes.md](../book/src/architecture/episodes.md) + +## One-Line + +Retract everything a single episode's ingest contributed — and nothing else — by +closing the bitemporal `valid_to` of exactly the facts that episode's transaction +wrote, surfacing the store's existing internal retraction path over HTTP. + +## The Motivating Problem + +Before this, Quipu writes were assert-only: `/episode` and `/knot` add facts; +`/cord` / `/unravel` are read-only time-travel. The bitemporal store supported +retraction internally (`op=Retract`, `valid_to`) but nothing surfaced it. So +removing a bad or test-only episode meant hand-surgery on SQLite. + +That is dangerous because episodes reuse **real, shared entity IRIs**. A test +episode that touched `quipu-server` and `kota` cannot be undone by deleting all +triples about those entities — that would destroy legitimate graph data. + +## The Unit: the Episode's Transaction + +Every episode ingest goes through **one** `Store::transact` call stamped +`source = "episode:{name}"` (`episode::ingest_episode` → `rdf::ingest_rdf`). +That transaction source is the complete, precise provenance handle: + +- **Complete** — it covers the episode activity node, generated entities, the + bare relationship triples (edges), and reified confidence statements. By + contrast `prov:wasGeneratedBy` only links *entity nodes*, so it would miss + edges and reifications. +- **Precise** — idempotent assertion (`transact` skips a duplicate active + `(e, a, v)`) means each active fact has exactly **one** owning transaction. + +So "retract episode X" = close every currently-active asserted fact whose owning +transaction carried `source = "episode:X"`. + +### Why this is shared-IRI-safe + +Retracting episode X closes only the facts X's transaction actually wrote. A fact +about a shared entity (`quipu-server`, `kota`) that was first asserted by a real +episode keeps that real episode as its owner, so it survives. Re-ingests of the +same episode create multiple transactions that all share the source tag, so every +one of the episode's currently-active contributions is caught. + +## Mechanism: Logical, Not Physical + +`Store::retract_episode` builds `Op::Retract` datums for the in-scope facts and +commits them through the normal `transact` path. This sets `valid_to` on the +original assertions and records retract rows — it never deletes anything. So: + +- The facts drop out of **current** queries (`current_facts`, `/search`, SPARQL + at `valid_now`). +- Time-travel (`/cord`, `/unravel`, `facts_as_of`, `entity_history`) still shows + them, now closed. + +Idempotent: a second retraction finds no active facts and is a no-op +(`tx_id == NOOP_TX`, `retracted == 0`). Unknown episodes are likewise no-ops. + +## Surface + +- Store: `Store::retract_episode(name, timestamp, actor) -> (tx_id, Vec)`. +- Tool: `quipu_retract_episode` (`tool_retract_episode`). +- HTTP: `POST /episode/retract` — body `{ "episode": "" }` (aliases + `episode_id`, `name`; optional `timestamp`, `actor`). + +## Authorization (hq-azs / hq-otm) + +Retraction is a write, and a **more sensitive** one than assertion: it removes +facts from current views. `/episode/retract` is registered in +`http_auth::WRITE_ENDPOINTS`, so today it honours read-only mode and the bearer +token exactly like every other write — under the LAN-trusted default (no token) +it is open like the other writes. + +**Requirement for when auth lands:** once per-principal scopes (hq-azs) and crew +identity (hq-otm) are in place, retraction should be gated to an *authorized +principal* — a distinct, higher-trust scope — not merely the same bearer token +that permits assertion. The current single-token model cannot express that +distinction; the gate must be tightened when the identity layer exists. + +## First Use: Prune the Goldblum Deploy-Verification Episodes + +The first production use of this endpoint is to clean up the bounded, +provenance-marked test episodes left on the live ontology by the aegis-7ui +deploy verification: + +- `goldblum-deploy-verify-032` +- `goldblum-confidence-verify-032` +- `goldblum-final-verify-032` +- (plus any dearing co-verify / ian tx341 test episodes, if present) + +> **Ownership:** this cleanup runs against the live Quipu store on **kota** +> (`/var/lib/quipu/quipu.db`). It is a **separate goldblum deploy step** — it +> requires the new `quipu-server` binary to be deployed there first. The +> implementing polecat does **not** touch the live store. + +### Runbook (run by goldblum after the binary is deployed to kota) + +```bash +# 1. Confirm a test episode's facts are currently live (expect rows). +curl -s http://quipu.svc/query -X POST -H 'Content-Type: application/json' \ + -d '{"query":"SELECT ?s ?p ?o WHERE { ?s ?p ?o . ?p2 ?o2 } LIMIT 5"}' + +# 2. Retract each test episode (idempotent; safe to re-run). +for ep in goldblum-deploy-verify-032 goldblum-confidence-verify-032 goldblum-final-verify-032; do + curl -s http://quipu.svc/episode/retract -X POST -H 'Content-Type: application/json' \ + -d "{\"episode\":\"$ep\",\"actor\":\"goldblum\"}" + echo +done + +# 3. Verify the test facts are gone from CURRENT queries (expect 0 rows / ASK false), +# e.g. the deploy-test edge: +curl -s http://quipu.svc/query -X POST -H 'Content-Type: application/json' \ + -d '{"query":"ASK { ?v }"}' + +# 4. Confirm real entities survive (expect the real facts intact). +curl -s http://quipu.svc/query -X POST -H 'Content-Type: application/json' \ + -d '{"query":"SELECT ?p ?o WHERE { ?p ?o }"}' +``` + +History is preserved: the retracted test facts remain visible via `/cord` +time-travel, now closed — traceable, not erased. diff --git a/src/http_auth.rs b/src/http_auth.rs index ec615bc..f558085 100644 --- a/src/http_auth.rs +++ b/src/http_auth.rs @@ -14,6 +14,7 @@ pub const WRITE_ENDPOINTS: &[&str] = &[ "/episode", "/episodes/complete", "/retract", + "/episode/retract", "/shapes", "/impact", "/propose", diff --git a/src/lib.rs b/src/lib.rs index 37de7ba..3ead0dc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -69,8 +69,8 @@ pub use mcp::proposal::{ pub use mcp::resolution::tool_resolve_entity; pub use mcp::search::{tool_search_facts, tool_search_nodes}; pub use mcp::tools::{ - tool_cord, tool_episode, tool_hybrid_search, tool_retract, tool_search, tool_shapes, - tool_unravel, tool_validate, + tool_cord, tool_episode, tool_hybrid_search, tool_retract, tool_retract_episode, tool_search, + tool_shapes, tool_unravel, tool_validate, }; pub use mcp::{tool_definitions, tool_knot, tool_query, value_to_json}; #[cfg(feature = "lancedb")] diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 081cf6e..762fc42 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -295,6 +295,19 @@ pub fn tool_definitions() -> Vec { "required": ["entity"] } }), + serde_json::json!({ + "name": "quipu_retract_episode", + "description": "Episode-scoped logical retraction: retract all currently-active facts an episode's ingest contributed (activity node, entities, edges, reified statements), via the bitemporal valid_to close path. Logical, not physical — time-travel history is preserved. Entities and other episodes' facts are untouched. Idempotent.", + "inputSchema": { + "type": "object", + "properties": { + "episode": { "type": "string", "description": "Episode name/identifier to retract (aliases: episode_id, name)" }, + "timestamp": { "type": "string", "description": "ISO-8601 timestamp for the retraction" }, + "actor": { "type": "string", "description": "Who is performing the retraction" } + }, + "required": ["episode"] + } + }), serde_json::json!({ "name": "quipu_episode", "description": "Ingest structured knowledge from an agent episode (nodes + edges)", diff --git a/src/mcp/tests.rs b/src/mcp/tests.rs index bf32070..f298382 100644 --- a/src/mcp/tests.rs +++ b/src/mcp/tests.rs @@ -192,6 +192,135 @@ fn test_tool_episode() { assert!(result["count"].as_i64().unwrap() >= 10); } +// -- Episode-scoped logical retraction (aegis-hxb) -- + +const NS: &str = "http://aegis.gastown.local/ontology/"; +const RDFS_LABEL: &str = "http://www.w3.org/2000/01/rdf-schema#label"; + +fn ask(store: &Store, pattern: &str) -> bool { + let r = tool_query( + store, + &serde_json::json!({ "query": format!("ASK {{ {pattern} }}") }), + ) + .unwrap(); + r["result"].as_bool().unwrap() +} + +/// Ingest a "real graph" episode and a "goldblum test" episode that share a real +/// entity (quipu-server), then retract only the test episode. Its contributions +/// must vanish from current queries while the shared entity and the real +/// episode's facts survive — the core shared-IRI guarantee of aegis-hxb. +#[test] +fn test_retract_episode_scoped_isolation() { + let mut store = Store::open_in_memory().unwrap(); + + tool_episode( + &mut store, + &serde_json::json!({ + "name": "real-graph", + "timestamp": "2026-04-01T00:00:00Z", + "nodes": [ + {"name": "quipu-server", "type": "WebApplication"}, + {"name": "kota", "type": "LXCContainer"} + ], + "edges": [{"source": "quipu-server", "target": "kota", "relation": "runs_on"}] + }), + ) + .unwrap(); + + tool_episode( + &mut store, + &serde_json::json!({ + "name": "goldblum-deploy-verify-032", + "timestamp": "2026-04-02T00:00:00Z", + "nodes": [ + {"name": "quipu-server", "type": "WebApplication"}, + {"name": "v032", "type": "Version"} + ], + "edges": [{"source": "quipu-server", "target": "v032", "relation": "running_version_on"}] + }), + ) + .unwrap(); + + // Pre-condition: both episodes' facts are live. + assert!(ask( + &store, + &format!("<{NS}quipu-server> <{NS}running_version_on> <{NS}v032>") + )); + assert!(ask( + &store, + &format!("<{NS}quipu-server> <{NS}runs_on> <{NS}kota>") + )); + + let out = tool_retract_episode( + &mut store, + &serde_json::json!({ "episode": "goldblum-deploy-verify-032", "actor": "tester" }), + ) + .unwrap(); + assert_eq!(out["episode"], "goldblum-deploy-verify-032"); + assert!(out["retracted"].as_i64().unwrap() > 0); + assert!(!out["statements"].as_array().unwrap().is_empty()); + + // The test episode's edge and generated entity are gone from current views… + assert!(!ask( + &store, + &format!("<{NS}quipu-server> <{NS}running_version_on> <{NS}v032>") + )); + let v032 = tool_query( + &store, + &serde_json::json!({ "query": format!("SELECT ?p ?o WHERE {{ <{NS}v032> ?p ?o }}") }), + ) + .unwrap(); + assert_eq!(v032["count"], 0, "generated entity v032 fully retracted"); + + // …but the shared entity and the real episode's facts are intact. + assert!(ask( + &store, + &format!("<{NS}quipu-server> <{NS}runs_on> <{NS}kota>") + )); + assert!(ask( + &store, + &format!("<{NS}quipu-server> <{RDFS_LABEL}> \"quipu-server\"") + )); +} + +#[test] +fn test_retract_episode_idempotent_and_unknown() { + let mut store = Store::open_in_memory().unwrap(); + tool_episode( + &mut store, + &serde_json::json!({ + "name": "ep-x", + "timestamp": "2026-04-01T00:00:00Z", + "nodes": [{"name": "thing", "type": "Widget"}], + "edges": [] + }), + ) + .unwrap(); + + let first = + tool_retract_episode(&mut store, &serde_json::json!({ "episode": "ep-x" })).unwrap(); + assert!(first["retracted"].as_i64().unwrap() > 0); + + // Re-retracting is a no-op. + let second = + tool_retract_episode(&mut store, &serde_json::json!({ "episode": "ep-x" })).unwrap(); + assert_eq!(second["retracted"], 0); + assert_eq!(second["tx_id"], crate::episode::NOOP_TX); + + // Unknown episode is also a clean no-op. + let unknown = + tool_retract_episode(&mut store, &serde_json::json!({ "episode": "nope" })).unwrap(); + assert_eq!(unknown["retracted"], 0); + + // `episode_id` alias is accepted. + let aliased = tool_retract_episode(&mut store, &serde_json::json!({ "episode_id": "nope" })); + assert!(aliased.is_ok()); + + // Missing identifier is an error. + assert!(tool_retract_episode(&mut store, &serde_json::json!({})).is_err()); +} + #[test] fn test_tool_query_enforces_max_sparql_rows() { // hq-gkd: a LIMIT-less SELECT must not dump the whole fact log — the server @@ -370,6 +499,7 @@ fn test_tool_definitions() { assert!(names.contains(&"quipu_resolve_entity")); assert!(names.contains(&"quipu_episode")); assert!(names.contains(&"quipu_retract")); + assert!(names.contains(&"quipu_retract_episode")); assert!(names.contains(&"quipu_shapes")); assert!(names.contains(&"quipu_search_nodes")); assert!(names.contains(&"quipu_search_facts")); @@ -390,12 +520,12 @@ fn test_tool_definitions() { // its handler (hq-8wd) — otherwise the call would always fail. #[cfg(feature = "owl")] { - assert_eq!(defs.len(), 25); + assert_eq!(defs.len(), 26); assert!(names.contains(&"quipu_load_ontology")); } #[cfg(not(feature = "owl"))] { - assert_eq!(defs.len(), 24); + assert_eq!(defs.len(), 25); assert!(!names.contains(&"quipu_load_ontology")); } } diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index 5f017f0..25d7789 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -398,6 +398,67 @@ pub fn tool_retract(store: &mut Store, input: &JsonValue) -> Result { })) } +/// MCP tool: `quipu_retract_episode` -- Episode-scoped logical retraction (aegis-hxb). +/// +/// Retracts every currently-active fact contributed by an episode's ingest, +/// using the existing bitemporal `Op::Retract`/`valid_to` close path. Logical, +/// not physical: time-travel queries still show the retracted facts. Entities +/// and facts from other episodes are untouched (see [`Store::retract_episode`]). +/// +/// Input: `{ "episode": "", "timestamp"?: "...", "actor"?: "..." }`. +/// `episode_id` and `name` are accepted as aliases for `episode`. +/// Output: `{ "tx_id", "retracted": , "episode": "", +/// "statements": [{ "entity", "predicate", "value" }, ...] }`. +/// +/// Idempotent: retracting an already-retracted (or unknown) episode returns +/// `retracted: 0` and changes nothing. +/// +/// **Auth (hq-azs / hq-otm).** Retraction is a write, and a *more* sensitive one +/// than assertion — it removes facts from current views. The HTTP route +/// (`/episode/retract`) is registered in `http_auth::WRITE_ENDPOINTS`, so today +/// it honours read-only mode and the bearer token exactly like other writes. +/// When per-principal scopes (hq-azs) and crew identity (hq-otm) land, +/// retraction should require an elevated/authorized principal rather than the +/// same token that gates assertion. +pub fn tool_retract_episode(store: &mut Store, input: &JsonValue) -> Result { + let episode_name = input + .get("episode") + .or_else(|| input.get("episode_id")) + .or_else(|| input.get("name")) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + Error::InvalidValue("missing 'episode' (or 'episode_id') parameter".into()) + })?; + + let now = crate::time::now_iso(); + let timestamp = input + .get("timestamp") + .and_then(|v| v.as_str()) + .unwrap_or(&now); + + let actor = input.get("actor").and_then(|v| v.as_str()); + + let (tx_id, facts) = store.retract_episode(episode_name, timestamp, actor)?; + + let statements: Vec = facts + .iter() + .map(|f| { + serde_json::json!({ + "entity": store.resolve(f.entity).unwrap_or_default(), + "predicate": store.resolve(f.attribute).unwrap_or_default(), + "value": value_to_json(store, &f.value), + }) + }) + .collect(); + + Ok(serde_json::json!({ + "tx_id": tx_id, + "retracted": facts.len(), + "episode": episode_name, + "statements": statements + })) +} + /// MCP tool: `quipu_episode` -- Ingest structured knowledge from an agent episode. pub fn tool_episode(store: &mut Store, input: &JsonValue) -> Result { let ep: Episode = serde_json::from_value(input.clone()) diff --git a/src/server.rs b/src/server.rs index 3b08952..caedb64 100644 --- a/src/server.rs +++ b/src/server.rs @@ -162,6 +162,7 @@ async fn main() { .route("/episodes/complete", post(episodes_complete)) .route("/impact", post(impact_analysis)) .route("/retract", post(retract)) + .route("/episode/retract", post(retract_episode)) .route("/shapes", post(shapes)) .route("/propose", post(propose_schema_change)) .route("/proposals", post(list_proposals)) @@ -345,6 +346,7 @@ rw_handler!(episode, quipu::tool_episode); rw_handler!(episodes_complete, quipu::tool_episodes_complete); rw_handler!(impact_analysis, quipu::tool_impact); rw_handler!(retract, quipu::tool_retract); +rw_handler!(retract_episode, quipu::tool_retract_episode); async fn validate( State(_store): State, diff --git a/src/store/ops.rs b/src/store/ops.rs index 3e6789b..afaa4a8 100644 --- a/src/store/ops.rs +++ b/src/store/ops.rs @@ -207,6 +207,83 @@ impl Store { Ok((tx_id, count)) } + /// Episode-scoped logical retraction (aegis-hxb). + /// + /// Retract every currently-active asserted fact whose owning transaction was + /// the ingest of the named episode. This is a *logical* retraction via the + /// existing bitemporal [`Op::Retract`]/`valid_to` close path — facts are + /// never physically deleted, so time-travel queries (`/cord`, `facts_as_of`, + /// `entity_history`) still show them, now closed. + /// + /// **Provenance unit = the episode's transaction(s).** Every episode write + /// goes through one [`transact`](Store::transact) stamped + /// `source = "episode:{name}"` (see `episode::ingest_episode` → + /// `rdf::ingest_rdf`). Selecting active facts by that transaction source is + /// *complete* — it covers the episode activity node, its generated entities, + /// the bare relationship triples (edges), and any reified confidence + /// statements — whereas `prov:wasGeneratedBy` only links entity nodes. + /// + /// **Shared-IRI safety.** Idempotent assertion (see `transact`) means each + /// active `(e, a, v)` belongs to exactly one owning transaction. Retracting + /// episode X therefore closes only the facts X's transaction actually wrote; + /// facts about shared entities (e.g. `quipu-server`, `kota`) asserted by + /// other episodes or write paths stay active. Re-ingests of the same episode + /// produce multiple transactions that all share the source tag, so every one + /// of the episode's currently-active contributions is caught. + /// + /// **Idempotent.** Retracting an episode with no active facts (already + /// retracted, or never ingested) is a no-op: returns `(NOOP_TX, [])`. + /// + /// Returns `(tx_id, retracted_facts)`. `tx_id` is the retraction + /// transaction, or [`crate::episode::NOOP_TX`] when nothing was retracted. + pub fn retract_episode( + &mut self, + episode_name: &str, + timestamp: &str, + actor: Option<&str>, + ) -> Result<(i64, Vec)> { + let source_tag = format!("episode:{episode_name}"); + + // Currently-active asserted facts written by this episode's + // transaction(s). Join keeps it precise: a fact is in scope only if its + // own owning tx carried the episode source tag. + let facts = { + let mut stmt = self.conn.prepare( + "SELECT f.e, f.a, f.v, f.tx, f.valid_from, f.valid_to, f.op \ + FROM facts f JOIN transactions t ON f.tx = t.id \ + WHERE t.source = ?1 AND f.op = 1 AND f.valid_to IS NULL \ + ORDER BY f.e, f.a", + )?; + Self::collect_facts(&mut stmt, params![source_tag])? + }; + + if facts.is_empty() { + return Ok((crate::episode::NOOP_TX, Vec::new())); + } + + let datums: Vec = facts + .iter() + .map(|f| Datum { + entity: f.entity, + attribute: f.attribute, + value: f.value.clone(), + valid_from: f.valid_from.clone(), + valid_to: None, + op: Op::Retract, + }) + .collect(); + + // Stamp the retraction tx with a distinct source so it is traceable and + // never re-selected as one of the episode's own assertions. + let tx_id = self.transact( + &datums, + timestamp, + actor, + Some(&format!("retract-episode:{episode_name}")), + )?; + Ok((tx_id, facts)) + } + // -- Read path -- /// Return the current state: all asserted facts not yet retracted. diff --git a/src/store/tests.rs b/src/store/tests.rs index ffc7e5a..5b4b31c 100644 --- a/src/store/tests.rs +++ b/src/store/tests.rs @@ -607,3 +607,111 @@ fn within_transaction_dedup() { assert!(result.is_ok()); assert_eq!(store.current_facts().unwrap().len(), 1); } + +// -- Episode-scoped logical retraction (aegis-hxb) -- + +/// Write one (e, a, v) fact under a transaction tagged `source="episode:{name}"`, +/// mirroring how `episode::ingest_episode` stamps its writes. +fn assert_episode_fact(store: &mut Store, name: &str, e: i64, a: i64, v: Value) { + store + .transact( + &[Datum { + entity: e, + attribute: a, + value: v, + valid_from: "2026-01-01".into(), + valid_to: None, + op: Op::Assert, + }], + "2026-01-01T00:00:00Z", + None, + Some(&format!("episode:{name}")), + ) + .unwrap(); +} + +#[test] +fn retract_episode_removes_only_that_episodes_facts() { + let mut store = test_store(); + let alice = store.intern("http://example.org/alice").unwrap(); + let bob = store.intern("http://example.org/bob").unwrap(); + let name = store.intern("http://example.org/name").unwrap(); + + assert_episode_fact(&mut store, "ep-a", alice, name, Value::Str("Alice".into())); + assert_episode_fact(&mut store, "ep-b", bob, name, Value::Str("Bob".into())); + assert_eq!(store.current_facts().unwrap().len(), 2); + + let (tx_id, retracted) = store + .retract_episode("ep-a", "2026-02-01T00:00:00Z", Some("tester")) + .unwrap(); + assert!(tx_id > 0); + assert_eq!(retracted.len(), 1); + assert_eq!(retracted[0].entity, alice); + + // ep-a's fact left the current view; ep-b's survived untouched. + let current = store.current_facts().unwrap(); + assert_eq!(current.len(), 1); + assert_eq!(current[0].entity, bob); +} + +#[test] +fn retract_episode_preserves_history() { + let mut store = test_store(); + let alice = store.intern("http://example.org/alice").unwrap(); + let name = store.intern("http://example.org/name").unwrap(); + assert_episode_fact(&mut store, "ep-a", alice, name, Value::Str("Alice".into())); + + store + .retract_episode("ep-a", "2026-02-01T00:00:00Z", None) + .unwrap(); + + // Logical, not physical: the original assertion row still exists (now closed) + // alongside the new retract row, so time-travel history is intact. + let history = store.entity_history(alice).unwrap(); + assert_eq!(history.len(), 2, "assert + retract rows both retained"); + assert!( + history + .iter() + .any(|f| f.op == Op::Assert && f.valid_to.is_some()) + ); + assert!(history.iter().any(|f| f.op == Op::Retract)); + + // The fact as it was before the retraction is still visible via time-travel. + let before = store + .facts_as_of(&AsOf { + tx: None, + valid_at: Some("2026-01-15T00:00:00Z".into()), + }) + .unwrap(); + assert!(before.iter().any(|f| f.entity == alice)); +} + +#[test] +fn retract_episode_is_idempotent() { + let mut store = test_store(); + let alice = store.intern("http://example.org/alice").unwrap(); + let name = store.intern("http://example.org/name").unwrap(); + assert_episode_fact(&mut store, "ep-a", alice, name, Value::Str("Alice".into())); + + let (_, first) = store + .retract_episode("ep-a", "2026-02-01T00:00:00Z", None) + .unwrap(); + assert_eq!(first.len(), 1); + + // Second retraction finds nothing active: no-op. + let (tx_id, second) = store + .retract_episode("ep-a", "2026-03-01T00:00:00Z", None) + .unwrap(); + assert_eq!(tx_id, crate::episode::NOOP_TX); + assert!(second.is_empty()); +} + +#[test] +fn retract_episode_unknown_is_noop() { + let mut store = test_store(); + let (tx_id, retracted) = store + .retract_episode("never-ingested", "2026-02-01T00:00:00Z", None) + .unwrap(); + assert_eq!(tx_id, crate::episode::NOOP_TX); + assert!(retracted.is_empty()); +}