Skip to main content

semantic_memory/
forgetting.rs

1//! Authorized selective forgetting with dependency-closed invalidation receipts.
2
3use crate::authority_contracts::{
4    AuthorityFaultStage, AuthorityPermit, AuthoritySnapshotId, RetrievalEpoch,
5};
6use crate::db::with_transaction;
7use crate::{MemoryError, MemoryStore};
8use chrono::Utc;
9use rusqlite::{params, OptionalExtension, Transaction};
10use serde::{Deserialize, Serialize};
11use std::collections::{BTreeSet, VecDeque};
12use std::sync::{Arc, Mutex};
13
14pub const FORGETTING_CLOSURE_RECEIPT_V1: &str = "forgetting_closure_receipt_v1";
15const FORGOTTEN_CONTENT: &str = "[FORGOTTEN]";
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct ForgettingClosureRequestV1 {
19    pub schema_version: String,
20    pub root_fact_ids: Vec<String>,
21    pub namespace: String,
22    pub reason: String,
23    pub closure_budget: usize,
24}
25
26impl ForgettingClosureRequestV1 {
27    pub fn new(
28        mut root_fact_ids: Vec<String>,
29        namespace: impl Into<String>,
30        reason: impl Into<String>,
31        closure_budget: usize,
32    ) -> Self {
33        for id in &mut root_fact_ids {
34            if let Some(stripped) = id.strip_prefix("fact:") {
35                *id = stripped.to_string();
36            }
37        }
38        root_fact_ids.sort();
39        root_fact_ids.dedup();
40        Self {
41            schema_version: "forgetting_closure_request_v1".into(),
42            root_fact_ids,
43            namespace: namespace.into(),
44            reason: reason.into(),
45            closure_budget,
46        }
47    }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum ForgettingDispositionV1 {
53    Applied,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
57pub struct ForgettingSurfaceRefV1 {
58    pub surface: String,
59    pub artifact_id: String,
60}
61
62impl ForgettingSurfaceRefV1 {
63    fn new(surface: impl Into<String>, artifact_id: impl Into<String>) -> Self {
64        Self {
65            surface: surface.into(),
66            artifact_id: artifact_id.into(),
67        }
68    }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct ForgettingVerificationV1 {
73    pub surface: String,
74    pub passed: bool,
75    pub detail: String,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct ForgettingEpochsV1 {
80    pub authority: RetrievalEpoch,
81    pub projection: u64,
82    pub cache: u64,
83    pub export: u64,
84    pub replay: u64,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct ForgettingClosureReceiptV1 {
89    pub schema_version: String,
90    pub receipt_id: String,
91    pub caller_idempotency_key: String,
92    pub principal: String,
93    pub namespace: String,
94    pub root_fact_ids: Vec<String>,
95    pub affected_canonical_ids: Vec<String>,
96    pub removed_surfaces: Vec<ForgettingSurfaceRefV1>,
97    pub invalidated_surfaces: Vec<ForgettingSurfaceRefV1>,
98    pub deferred_surfaces: Vec<ForgettingSurfaceRefV1>,
99    pub not_tested_surfaces: Vec<ForgettingSurfaceRefV1>,
100    pub verification: Vec<ForgettingVerificationV1>,
101    pub disposition: ForgettingDispositionV1,
102    pub before_snapshot_id: AuthoritySnapshotId,
103    pub after_snapshot_id: AuthoritySnapshotId,
104    pub before_epoch: RetrievalEpoch,
105    pub after_epoch: RetrievalEpoch,
106    pub before_epochs: ForgettingEpochsV1,
107    pub after_epochs: ForgettingEpochsV1,
108    pub reason_digest: String,
109    pub receipt_digest: String,
110    pub committed_at: String,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
114struct ArtifactNode {
115    kind: String,
116    id: String,
117}
118
119struct ClosurePlan {
120    canonical_ids: Vec<String>,
121    artifacts: Vec<ArtifactNode>,
122    graph_edge_ids: Vec<String>,
123    derivation_edge_ids: Vec<i64>,
124    search_receipt_ids: Vec<String>,
125    vector_item_keys: Vec<String>,
126}
127
128pub(crate) async fn forget(
129    store: &MemoryStore,
130    permit: AuthorityPermit,
131    caller_idempotency_key: String,
132    request: ForgettingClosureRequestV1,
133) -> Result<ForgettingClosureReceiptV1, MemoryError> {
134    validate(&permit, &caller_idempotency_key, &request)?;
135    // Clearing early is safe on rollback and prevents a committed closure from retaining a
136    // poisoned or inaccessible cache entry.
137    store.clear_search_cache_strict()?;
138    let fault = store.inner.authority_fault.clone();
139    let receipt = store
140        .with_write_conn(move |conn| {
141            // Safety: closure discovery, physical scrubbing, invalidation, epoch bumps, and the
142            // immutable receipt either all commit or all roll back.
143            with_transaction(conn, |tx| {
144                execute_forgetting(tx, &permit, &caller_idempotency_key, &request, &fault)
145            })
146        })
147        .await?;
148    store.clear_search_cache_strict()?;
149    Ok(receipt)
150}
151
152pub(crate) async fn get_receipt(
153    store: &MemoryStore,
154    caller_idempotency_key: &str,
155) -> Result<Option<ForgettingClosureReceiptV1>, MemoryError> {
156    let key = caller_idempotency_key.to_string();
157    store
158        .with_read_conn(move |conn| {
159            let raw: Option<String> = conn
160                .query_row(
161                    "SELECT receipt_json FROM forgetting_closure_receipts
162                     WHERE caller_idempotency_key = ?1",
163                    params![key],
164                    |row| row.get(0),
165                )
166                .optional()?;
167            raw.map(|json| {
168                serde_json::from_str(&json).map_err(|error| MemoryError::CorruptData {
169                    table: "forgetting_closure_receipts",
170                    row_id: key.clone(),
171                    detail: format!("invalid forgetting receipt: {error}"),
172                })
173            })
174            .transpose()
175        })
176        .await
177}
178
179fn validate(
180    permit: &AuthorityPermit,
181    key: &str,
182    request: &ForgettingClosureRequestV1,
183) -> Result<(), MemoryError> {
184    if permit.capability != AuthorityPermit::FORGET_CAPABILITY
185        || permit.principal.trim().is_empty()
186        || permit.caller_id.trim().is_empty()
187        || permit.origin_authority.is_none()
188        || key.trim().is_empty()
189    {
190        return Err(MemoryError::AuthorityUnauthorized {
191            operation: "forget".into(),
192            principal: permit.principal.clone(),
193        });
194    }
195    if request.schema_version != "forgetting_closure_request_v1"
196        || request.root_fact_ids.is_empty()
197        || request.root_fact_ids.iter().any(|id| id.trim().is_empty())
198        || request.namespace.trim().is_empty()
199        || request.reason.trim().is_empty()
200    {
201        return Err(MemoryError::ForgettingClosureIncomplete {
202            detail: "request requires version, roots, namespace, and reason".into(),
203        });
204    }
205    if request.closure_budget == 0 {
206        return Err(MemoryError::ForgettingBudgetExceeded {
207            budget: 0,
208            required: 1,
209        });
210    }
211    Ok(())
212}
213
214fn execute_forgetting(
215    tx: &Transaction<'_>,
216    permit: &AuthorityPermit,
217    key: &str,
218    request: &ForgettingClosureRequestV1,
219    fault: &Arc<Mutex<Option<AuthorityFaultStage>>>,
220) -> Result<ForgettingClosureReceiptV1, MemoryError> {
221    let reason_digest = digest(&request.reason)?;
222    let payload_digest = digest(&(
223        &permit.principal,
224        &permit.caller_id,
225        &request.schema_version,
226        &request.root_fact_ids,
227        &request.namespace,
228        &reason_digest,
229        request.closure_budget,
230    ))?;
231    if let Some((stored_payload, receipt_json)) = tx
232        .query_row(
233            "SELECT payload_digest, receipt_json FROM forgetting_closure_receipts
234             WHERE caller_idempotency_key = ?1",
235            params![key],
236            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
237        )
238        .optional()?
239    {
240        if stored_payload != payload_digest {
241            return Err(MemoryError::AuthorityIdempotencyConflict { key: key.into() });
242        }
243        return serde_json::from_str(&receipt_json).map_err(|error| MemoryError::CorruptData {
244            table: "forgetting_closure_receipts",
245            row_id: key.into(),
246            detail: format!("invalid forgetting receipt: {error}"),
247        });
248    }
249
250    let plan = compute_closure(tx, permit, request)?;
251    let before_epochs = load_epochs(tx)?;
252    let before_snapshot_id = snapshot_id(tx, before_epochs.authority.0)?;
253    let receipt_id = uuid::Uuid::new_v4().to_string();
254    let committed_at = Utc::now().format("%Y-%m-%d %H:%M:%S%.6f").to_string();
255
256    fault_gate(fault, AuthorityFaultStage::BeforeForgettingMutation)?;
257    let (mut removed, mut invalidated) = apply_plan(
258        tx,
259        &plan,
260        permit,
261        key,
262        &receipt_id,
263        &committed_at,
264        &reason_digest,
265    )?;
266    fault_gate(fault, AuthorityFaultStage::AfterForgettingMutation)?;
267    let after_epochs = bump_epochs(tx, &before_epochs)?;
268    let after_snapshot_id = snapshot_id(tx, after_epochs.authority.0)?;
269    removed.sort();
270    removed.dedup();
271    invalidated.sort();
272    invalidated.dedup();
273    let verification = verify_plan(tx, &plan)?;
274    if verification.iter().any(|check| !check.passed) {
275        return Err(MemoryError::ForgettingClosureIncomplete {
276            detail: verification
277                .iter()
278                .filter(|check| !check.passed)
279                .map(|check| format!("{}: {}", check.surface, check.detail))
280                .collect::<Vec<_>>()
281                .join("; "),
282        });
283    }
284
285    let mut receipt = ForgettingClosureReceiptV1 {
286        schema_version: FORGETTING_CLOSURE_RECEIPT_V1.into(),
287        receipt_id: receipt_id.clone(),
288        caller_idempotency_key: key.into(),
289        principal: permit.principal.clone(),
290        namespace: request.namespace.clone(),
291        root_fact_ids: request.root_fact_ids.clone(),
292        affected_canonical_ids: plan.canonical_ids.clone(),
293        removed_surfaces: removed,
294        invalidated_surfaces: invalidated,
295        deferred_surfaces: Vec::new(),
296        not_tested_surfaces: Vec::new(),
297        verification,
298        disposition: ForgettingDispositionV1::Applied,
299        before_snapshot_id,
300        after_snapshot_id,
301        before_epoch: before_epochs.authority,
302        after_epoch: after_epochs.authority,
303        before_epochs,
304        after_epochs,
305        reason_digest,
306        receipt_digest: String::new(),
307        committed_at,
308    };
309    receipt.receipt_digest = digest(&receipt)?;
310    let receipt_json = serde_json::to_string(&receipt)
311        .map_err(|error| MemoryError::Other(format!("serialize forgetting receipt: {error}")))?;
312    fault_gate(fault, AuthorityFaultStage::BeforeForgettingReceipt)?;
313    tx.execute(
314        "INSERT INTO forgetting_closure_receipts
315         (receipt_id, caller_idempotency_key, payload_digest, receipt_json, receipt_digest, created_at)
316         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
317        params![receipt.receipt_id, key, payload_digest, receipt_json,
318                receipt.receipt_digest, receipt.committed_at],
319    )?;
320    fault_gate(fault, AuthorityFaultStage::AfterForgettingReceipt)?;
321    Ok(receipt)
322}
323
324fn compute_closure(
325    tx: &Transaction<'_>,
326    permit: &AuthorityPermit,
327    request: &ForgettingClosureRequestV1,
328) -> Result<ClosurePlan, MemoryError> {
329    let mut artifacts = BTreeSet::new();
330    let mut queue = VecDeque::new();
331    for id in &request.root_fact_ids {
332        let node = ArtifactNode {
333            kind: "fact".into(),
334            id: id.clone(),
335        };
336        artifacts.insert(node.clone());
337        queue.push_back(node);
338    }
339    let mut dependency_graph_edges = BTreeSet::new();
340    let mut derivation_edges = BTreeSet::new();
341
342    while let Some(node) = queue.pop_front() {
343        if artifacts.len() > request.closure_budget {
344            return Err(MemoryError::ForgettingBudgetExceeded {
345                budget: request.closure_budget,
346                required: artifacts.len(),
347            });
348        }
349        if node.kind == "fact" {
350            validate_fact_scope(tx, &node.id, &request.namespace, &permit.principal)?;
351            let target = format!("fact:{}", node.id);
352            let mut stmt = tx.prepare(
353                "SELECT id, source, edge_type FROM graph_edges
354                 WHERE target = ?1 AND is_invalidated = 0 ORDER BY id",
355            )?;
356            let rows = stmt
357                .query_map(params![target], |row| {
358                    Ok((
359                        row.get::<_, String>(0)?,
360                        row.get::<_, String>(1)?,
361                        row.get::<_, String>(2)?,
362                    ))
363                })?
364                .collect::<Result<Vec<_>, _>>()?;
365            for (edge_id, source, edge_type) in rows {
366                let relation = graph_relation(&edge_type)?;
367                if matches!(
368                    relation.as_deref(),
369                    Some("derived_from_state" | "supersedes" | "redacts")
370                ) {
371                    dependency_graph_edges.insert(edge_id);
372                    let source_id = source.strip_prefix("fact:").ok_or_else(|| {
373                        MemoryError::ForgettingClosureIncomplete {
374                            detail: format!("dependency source '{source}' is not a fact"),
375                        }
376                    })?;
377                    let derived = ArtifactNode {
378                        kind: "fact".into(),
379                        id: source_id.into(),
380                    };
381                    if artifacts.insert(derived.clone()) {
382                        queue.push_back(derived);
383                    }
384                }
385            }
386        }
387
388        let mut stmt = tx.prepare(
389            "SELECT id, target_kind, target_id FROM derivation_edges
390             WHERE source_kind = ?1 AND source_id = ?2 AND is_invalidated = 0 ORDER BY id",
391        )?;
392        let rows = stmt
393            .query_map(params![node.kind, node.id], |row| {
394                Ok((
395                    row.get::<_, i64>(0)?,
396                    row.get::<_, String>(1)?,
397                    row.get::<_, String>(2)?,
398                ))
399            })?
400            .collect::<Result<Vec<_>, _>>()?;
401        for (edge_id, kind, id) in rows {
402            if !matches!(
403                kind.as_str(),
404                "fact"
405                    | "procedure"
406                    | "claim"
407                    | "claim_version"
408                    | "relation_version"
409                    | "entity"
410                    | "entity_alias"
411                    | "evidence_ref"
412                    | "episode"
413            ) {
414                return Err(MemoryError::ForgettingClosureIncomplete {
415                    detail: format!(
416                        "derived artifact kind '{kind}' has no governed forgetting adapter"
417                    ),
418                });
419            }
420            derivation_edges.insert(edge_id);
421            let derived = ArtifactNode { kind, id };
422            if artifacts.insert(derived.clone()) {
423                queue.push_back(derived);
424            }
425        }
426    }
427
428    let canonical_ids = artifacts
429        .iter()
430        .filter(|node| node.kind == "fact")
431        .map(|node| node.id.clone())
432        .collect::<Vec<_>>();
433    let fact_nodes = canonical_ids
434        .iter()
435        .map(|id| format!("fact:{id}"))
436        .collect::<BTreeSet<_>>();
437
438    // Every incident graph edge is invalidated, not only edges used during traversal.
439    let mut graph_edge_ids = dependency_graph_edges;
440    let mut stmt = tx.prepare(
441        "SELECT id, source, target FROM graph_edges WHERE is_invalidated = 0 ORDER BY id",
442    )?;
443    for row in stmt.query_map([], |row| {
444        Ok((
445            row.get::<_, String>(0)?,
446            row.get::<_, String>(1)?,
447            row.get::<_, String>(2)?,
448        ))
449    })? {
450        let (id, source, target) = row?;
451        if fact_nodes.contains(&source) || fact_nodes.contains(&target) {
452            graph_edge_ids.insert(id);
453        }
454    }
455
456    let mut search_receipt_ids = BTreeSet::new();
457    let mut stmt = tx.prepare("SELECT receipt_id, result_ids_json FROM search_receipts")?;
458    for row in stmt.query_map([], |row| {
459        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
460    })? {
461        let (receipt_id, raw_ids) = row?;
462        let ids: Vec<String> =
463            serde_json::from_str(&raw_ids).map_err(|error| MemoryError::CorruptData {
464                table: "search_receipts",
465                row_id: receipt_id.clone(),
466                detail: format!("invalid result IDs: {error}"),
467            })?;
468        if ids.iter().any(|id| fact_nodes.contains(id)) {
469            search_receipt_ids.insert(receipt_id);
470        }
471    }
472
473    let mut vector_item_keys = BTreeSet::new();
474    let mut stmt = tx.prepare(
475        "SELECT DISTINCT item_key FROM derived_vector_artifacts WHERE status = 'active'",
476    )?;
477    for row in stmt.query_map([], |row| row.get::<_, String>(0))? {
478        let item_key = row?;
479        if fact_nodes.contains(&item_key) {
480            vector_item_keys.insert(item_key);
481        }
482    }
483
484    Ok(ClosurePlan {
485        canonical_ids,
486        artifacts: artifacts.into_iter().collect(),
487        graph_edge_ids: graph_edge_ids.into_iter().collect(),
488        derivation_edge_ids: derivation_edges.into_iter().collect(),
489        search_receipt_ids: search_receipt_ids.into_iter().collect(),
490        vector_item_keys: vector_item_keys.into_iter().collect(),
491    })
492}
493
494fn graph_relation(edge_type: &str) -> Result<Option<String>, MemoryError> {
495    let value: serde_json::Value = serde_json::from_str(edge_type).map_err(|error| {
496        MemoryError::ForgettingClosureIncomplete {
497            detail: format!("cannot interpret dependency edge type: {error}"),
498        }
499    })?;
500    Ok(value
501        .get("relation")
502        .or_else(|| {
503            value
504                .get("entity")
505                .and_then(|nested| nested.get("relation"))
506        })
507        .and_then(|relation| relation.as_str())
508        .map(str::to_string))
509}
510
511fn validate_fact_scope(
512    tx: &Transaction<'_>,
513    fact_id: &str,
514    namespace: &str,
515    principal: &str,
516) -> Result<(), MemoryError> {
517    let row: Option<(String, Option<String>)> = tx
518        .query_row(
519            "SELECT f.namespace, o.label_json FROM facts f
520             LEFT JOIN origin_authority_labels o ON o.fact_id = f.id WHERE f.id = ?1",
521            params![fact_id],
522            |row| Ok((row.get(0)?, row.get(1)?)),
523        )
524        .optional()?;
525    let Some((actual_namespace, Some(label_json))) = row else {
526        return Err(MemoryError::ForgettingClosureIncomplete {
527            detail: format!("canonical fact '{fact_id}' is missing or lacks origin authority"),
528        });
529    };
530    let label: crate::OriginAuthorityLabelV1 =
531        serde_json::from_str(&label_json).map_err(|error| MemoryError::CorruptData {
532            table: "origin_authority_labels",
533            row_id: fact_id.into(),
534            detail: format!("invalid origin label: {error}"),
535        })?;
536    if actual_namespace != namespace || label.origin_principal != principal {
537        return Err(MemoryError::ForgettingClosureIncomplete {
538            detail: format!("fact '{fact_id}' is outside authorized namespace/principal closure"),
539        });
540    }
541    Ok(())
542}
543
544#[allow(clippy::too_many_arguments)]
545fn apply_plan(
546    tx: &Transaction<'_>,
547    plan: &ClosurePlan,
548    permit: &AuthorityPermit,
549    key: &str,
550    receipt_id: &str,
551    committed_at: &str,
552    reason_digest: &str,
553) -> Result<(Vec<ForgettingSurfaceRefV1>, Vec<ForgettingSurfaceRefV1>), MemoryError> {
554    let mut removed = Vec::new();
555    let mut invalidated = Vec::new();
556    for fact_id in &plan.canonical_ids {
557        let (rowid, content, namespace, has_embedding, has_q8): (i64, String, String, bool, bool) =
558            tx.query_row(
559                "SELECT rm.rowid, f.content, f.namespace,
560                    f.embedding IS NOT NULL, f.embedding_q8 IS NOT NULL
561             FROM facts f JOIN facts_rowid_map rm ON rm.fact_id = f.id WHERE f.id = ?1",
562                params![fact_id],
563                |row| {
564                    Ok((
565                        row.get(0)?,
566                        row.get(1)?,
567                        row.get(2)?,
568                        row.get(3)?,
569                        row.get(4)?,
570                    ))
571                },
572            )?;
573        let content_digest = digest(&content)?;
574        tx.execute(
575            "INSERT INTO facts_fts(facts_fts, rowid, content) VALUES('delete', ?1, ?2)",
576            params![rowid, content],
577        )?;
578        tx.execute(
579            "INSERT INTO facts_fts(rowid, content) VALUES(?1, ?2)",
580            params![rowid, FORGOTTEN_CONTENT],
581        )?;
582        let tombstone_metadata = serde_json::json!({
583            "forgetting_tombstone": {
584                "receipt_id": receipt_id,
585                "content_digest": content_digest,
586            }
587        })
588        .to_string();
589        tx.execute(
590            "UPDATE facts SET content = ?1, source = NULL, embedding = NULL, embedding_q8 = NULL,
591             metadata = ?2, updated_at = ?3 WHERE id = ?4",
592            params![FORGOTTEN_CONTENT, tombstone_metadata, committed_at, fact_id],
593        )?;
594        tx.execute(
595            "INSERT INTO forgotten_facts
596             (fact_id, receipt_id, namespace, content_digest, forgotten_at)
597             VALUES (?1, ?2, ?3, ?4, ?5)",
598            params![fact_id, receipt_id, namespace, content_digest, committed_at],
599        )?;
600        tx.execute(
601            "UPDATE authority_versions SET is_redacted = 1 WHERE fact_id = ?1",
602            params![fact_id],
603        )?;
604        tx.execute(
605            "INSERT INTO origin_authority_revocations
606             (revocation_id, fact_id, caller_idempotency_key, principal,
607              revocation_reference, revoked_at)
608             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
609            params![
610                uuid::Uuid::new_v4().to_string(),
611                fact_id,
612                format!("{key}:{fact_id}"),
613                permit.principal,
614                format!("forgetting:{reason_digest}"),
615                committed_at,
616            ],
617        )?;
618        tx.execute(
619            "INSERT INTO pending_index_ops
620             (item_key, entity_type, op_kind, attempt_count, last_error, updated_at)
621             VALUES (?1, 'fact', 'delete', 0, NULL, ?2)
622             ON CONFLICT(item_key) DO UPDATE SET op_kind = 'delete', attempt_count = 0,
623                 last_error = NULL, updated_at = excluded.updated_at",
624            params![format!("fact:{fact_id}"), committed_at],
625        )?;
626        removed.push(ForgettingSurfaceRefV1::new("canonical_content", fact_id));
627        removed.push(ForgettingSurfaceRefV1::new("fts", fact_id));
628        if has_embedding {
629            removed.push(ForgettingSurfaceRefV1::new("vector", fact_id));
630        }
631        if has_q8 {
632            removed.push(ForgettingSurfaceRefV1::new("quantized_vector", fact_id));
633        }
634        invalidated.push(ForgettingSurfaceRefV1::new("origin_authority", fact_id));
635        invalidated.push(ForgettingSurfaceRefV1::new(
636            "ann_index",
637            format!("fact:{fact_id}"),
638        ));
639    }
640
641    for edge_id in &plan.graph_edge_ids {
642        tx.execute(
643            "UPDATE graph_edges SET is_invalidated = 1, invalidated_at = ?1,
644             invalidation_reason = ?2 WHERE id = ?3 AND is_invalidated = 0",
645            params![committed_at, format!("forgetting:{reason_digest}"), edge_id],
646        )?;
647        invalidated.push(ForgettingSurfaceRefV1::new("graph", edge_id));
648    }
649    for edge_id in &plan.derivation_edge_ids {
650        tx.execute(
651            "UPDATE derivation_edges SET is_invalidated = 1, invalidated_at = ?1,
652             invalidation_reason = ?2 WHERE id = ?3 AND is_invalidated = 0",
653            params![committed_at, format!("forgetting:{reason_digest}"), edge_id],
654        )?;
655        invalidated.push(ForgettingSurfaceRefV1::new(
656            "projection_derivation",
657            edge_id.to_string(),
658        ));
659    }
660    for item_key in &plan.vector_item_keys {
661        tx.execute(
662            "UPDATE derived_vector_artifacts SET status = 'invalidated'
663             WHERE item_key = ?1 AND status = 'active'",
664            params![item_key],
665        )?;
666        invalidated.push(ForgettingSurfaceRefV1::new("derived_vector", item_key));
667    }
668    for artifact in &plan.artifacts {
669        if artifact.kind != "fact" && scrub_projection_artifact(tx, artifact)? {
670            removed.push(ForgettingSurfaceRefV1::new(
671                format!("projection_payload:{}", artifact.kind),
672                &artifact.id,
673            ));
674        }
675        tx.execute(
676            "INSERT OR IGNORE INTO forgetting_artifact_invalidations
677             (surface_kind, artifact_id, receipt_id, invalidated_at) VALUES (?1, ?2, ?3, ?4)",
678            params![artifact.kind, artifact.id, receipt_id, committed_at],
679        )?;
680        if artifact.kind != "fact" {
681            invalidated.push(ForgettingSurfaceRefV1::new(
682                format!("projection:{}", artifact.kind),
683                &artifact.id,
684            ));
685        }
686    }
687    for search_receipt_id in &plan.search_receipt_ids {
688        tx.execute(
689            "INSERT OR IGNORE INTO forgetting_artifact_invalidations
690             (surface_kind, artifact_id, receipt_id, invalidated_at)
691             VALUES ('search_receipt', ?1, ?2, ?3)",
692            params![search_receipt_id, receipt_id, committed_at],
693        )?;
694        invalidated.push(ForgettingSurfaceRefV1::new("replay", search_receipt_id));
695        invalidated.push(ForgettingSurfaceRefV1::new("export", search_receipt_id));
696    }
697    invalidated.push(ForgettingSurfaceRefV1::new("search_cache", "all"));
698    Ok((removed, invalidated))
699}
700
701fn scrub_projection_artifact(
702    tx: &Transaction<'_>,
703    artifact: &ArtifactNode,
704) -> Result<bool, MemoryError> {
705    let changed = match artifact.kind.as_str() {
706        "claim_version" => tx.execute(
707            "UPDATE claim_versions SET content = ?1, subject_entity_id = '[FORGOTTEN]',
708             predicate = '[FORGOTTEN]', object_anchor = 'null', metadata = NULL,
709             preferred_open = 0, claim_state = 'retracted', freshness = 'superseded'
710             WHERE claim_version_id = ?2",
711            params![FORGOTTEN_CONTENT, artifact.id],
712        )?,
713        "relation_version" => tx.execute(
714            "UPDATE relation_versions SET subject_entity_id = '[FORGOTTEN]',
715             predicate = '[FORGOTTEN]', object_anchor = 'null', metadata = NULL,
716             preferred_open = 0, freshness = 'superseded'
717             WHERE relation_version_id = ?1",
718            params![artifact.id],
719        )?,
720        "entity" | "entity_alias" => tx.execute(
721            "UPDATE entity_aliases SET alias_text = ?1, match_evidence = NULL,
722             alias_source = '[FORGOTTEN]' WHERE canonical_entity_id = ?2",
723            params![FORGOTTEN_CONTENT, artifact.id],
724        )?,
725        // Evidence handles are capabilities, so physical removal is safer than a reusable marker.
726        "evidence_ref" => tx.execute(
727            "DELETE FROM evidence_refs WHERE fetch_handle = ?1",
728            params![artifact.id],
729        )?,
730        "episode" => tx.execute(
731            "UPDATE episode_links SET document_id = '[FORGOTTEN]', cause_ids = '[]',
732             effect_type = '[FORGOTTEN]', outcome = '[FORGOTTEN]', metadata = NULL
733             WHERE episode_id = ?1",
734            params![artifact.id],
735        )?,
736        // Procedures are immutable. Forgetting their factual evidence source records a durable
737        // invalidation in forgetting_artifact_invalidations; governed procedural retrieval checks
738        // that table uniformly across search/direct/cache/export/replay paths.
739        "procedure" => 0,
740        _ => 0,
741    };
742    Ok(changed > 0)
743}
744
745fn verify_plan(
746    tx: &Transaction<'_>,
747    plan: &ClosurePlan,
748) -> Result<Vec<ForgettingVerificationV1>, MemoryError> {
749    let mut checks = Vec::new();
750    for fact_id in &plan.canonical_ids {
751        let (content, embedding_absent, q8_absent, forgotten): (String, bool, bool, bool) = tx
752            .query_row(
753                "SELECT f.content, f.embedding IS NULL, f.embedding_q8 IS NULL,
754                        EXISTS(SELECT 1 FROM forgotten_facts ff WHERE ff.fact_id = f.id)
755                 FROM facts f WHERE f.id = ?1",
756                params![fact_id],
757                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
758            )?;
759        checks.push(ForgettingVerificationV1 {
760            surface: format!("direct_id_raw:{fact_id}"),
761            passed: content == FORGOTTEN_CONTENT && embedding_absent && q8_absent && forgotten,
762            detail: "content tombstoned; dense and quantized vectors absent".into(),
763        });
764        let fts_rows: i64 = tx.query_row(
765            "SELECT COUNT(*) FROM facts_fts
766             JOIN facts_rowid_map rm ON rm.rowid = facts_fts.rowid
767             WHERE rm.fact_id = ?1",
768            params![fact_id],
769            |row| row.get(0),
770        )?;
771        checks.push(ForgettingVerificationV1 {
772            surface: format!("fts:{fact_id}"),
773            passed: fts_rows == 1,
774            detail: format!("FTS tombstone rows after exact delete/reinsert: {fts_rows}"),
775        });
776        let revocations: i64 = tx.query_row(
777            "SELECT COUNT(*) FROM origin_authority_revocations WHERE fact_id = ?1",
778            params![fact_id],
779            |row| row.get(0),
780        )?;
781        checks.push(ForgettingVerificationV1 {
782            surface: format!("governed_direct_export:{fact_id}"),
783            passed: forgotten && revocations > 0,
784            detail: format!("forgetting tombstone present; revocations: {revocations}"),
785        });
786        checks.push(ForgettingVerificationV1 {
787            surface: format!("ordinary_current_historical:{fact_id}"),
788            passed: forgotten,
789            detail: "all StateView policies exclude forgotten_facts".into(),
790        });
791        let pending_delete: bool = tx.query_row(
792            "SELECT EXISTS(SELECT 1 FROM pending_index_ops
793             WHERE item_key = ?1 AND op_kind = 'delete')",
794            params![format!("fact:{fact_id}")],
795            |row| row.get(0),
796        )?;
797        let active_vectors: i64 = tx.query_row(
798            "SELECT COUNT(*) FROM derived_vector_artifacts
799             WHERE item_key = ?1 AND status = 'active'",
800            params![format!("fact:{fact_id}")],
801            |row| row.get(0),
802        )?;
803        checks.push(ForgettingVerificationV1 {
804            surface: format!("vector_ann:{fact_id}"),
805            passed: embedding_absent && q8_absent && active_vectors == 0 && pending_delete,
806            detail: format!(
807                "source vectors absent; active derived vectors: {active_vectors}; ANN delete queued"
808            ),
809        });
810        let active_edges: i64 = tx.query_row(
811            "SELECT COUNT(*) FROM graph_edges WHERE is_invalidated = 0
812             AND (source = ?1 OR target = ?1)",
813            params![format!("fact:{fact_id}")],
814            |row| row.get(0),
815        )?;
816        checks.push(ForgettingVerificationV1 {
817            surface: format!("graph:{fact_id}"),
818            passed: active_edges == 0,
819            detail: format!("active incident edges: {active_edges}"),
820        });
821    }
822    let mut active_derivations = 0_i64;
823    for id in &plan.derivation_edge_ids {
824        active_derivations += tx.query_row(
825            "SELECT COUNT(*) FROM derivation_edges WHERE id = ?1 AND is_invalidated = 0",
826            params![id],
827            |row| row.get::<_, i64>(0),
828        )?;
829    }
830    checks.push(ForgettingVerificationV1 {
831        surface: "projection_derivation".into(),
832        passed: active_derivations == 0,
833        detail: format!("active affected derivations: {active_derivations}"),
834    });
835    let invalidated_artifacts: i64 = tx.query_row(
836        "SELECT COUNT(*) FROM forgetting_artifact_invalidations WHERE receipt_id = (
837             SELECT receipt_id FROM forgotten_facts WHERE fact_id = ?1
838         )",
839        params![plan.canonical_ids.first().ok_or_else(|| {
840            MemoryError::ForgettingClosureIncomplete {
841                detail: "closure unexpectedly contains no canonical facts".into(),
842            }
843        })?],
844        |row| row.get(0),
845    )?;
846    checks.push(ForgettingVerificationV1 {
847        surface: "projection_export_replay".into(),
848        passed: invalidated_artifacts >= plan.artifacts.len() as i64,
849        detail: format!("durable artifact invalidations: {invalidated_artifacts}"),
850    });
851    checks.push(ForgettingVerificationV1 {
852        surface: "cache".into(),
853        passed: true,
854        detail: "strict in-process cache clear completed before transactional mutation".into(),
855    });
856    Ok(checks)
857}
858
859fn load_epochs(tx: &Transaction<'_>) -> Result<ForgettingEpochsV1, MemoryError> {
860    let values: (i64, i64, i64, i64, i64) = tx.query_row(
861        "SELECT retrieval_epoch, projection_epoch, cache_epoch, export_epoch, replay_epoch
862         FROM authority_state WHERE id = 1",
863        [],
864        |row| {
865            Ok((
866                row.get(0)?,
867                row.get(1)?,
868                row.get(2)?,
869                row.get(3)?,
870                row.get(4)?,
871            ))
872        },
873    )?;
874    let convert = |value: i64| {
875        u64::try_from(value).map_err(|_| MemoryError::Other("negative forgetting epoch".into()))
876    };
877    Ok(ForgettingEpochsV1 {
878        authority: RetrievalEpoch(convert(values.0)?),
879        projection: convert(values.1)?,
880        cache: convert(values.2)?,
881        export: convert(values.3)?,
882        replay: convert(values.4)?,
883    })
884}
885
886fn bump_epochs(
887    tx: &Transaction<'_>,
888    before: &ForgettingEpochsV1,
889) -> Result<ForgettingEpochsV1, MemoryError> {
890    let next = |value: u64| {
891        value
892            .checked_add(1)
893            .ok_or_else(|| MemoryError::Other("forgetting epoch overflow".into()))
894    };
895    let after = ForgettingEpochsV1 {
896        authority: RetrievalEpoch(next(before.authority.0)?),
897        projection: next(before.projection)?,
898        cache: next(before.cache)?,
899        export: next(before.export)?,
900        replay: next(before.replay)?,
901    };
902    let changed = tx.execute(
903        "UPDATE authority_state SET retrieval_epoch = ?1, projection_epoch = ?2,
904         cache_epoch = ?3, export_epoch = ?4, replay_epoch = ?5
905         WHERE id = 1 AND retrieval_epoch = ?6 AND projection_epoch = ?7
906           AND cache_epoch = ?8 AND export_epoch = ?9 AND replay_epoch = ?10",
907        params![
908            after.authority.0 as i64,
909            after.projection as i64,
910            after.cache as i64,
911            after.export as i64,
912            after.replay as i64,
913            before.authority.0 as i64,
914            before.projection as i64,
915            before.cache as i64,
916            before.export as i64,
917            before.replay as i64,
918        ],
919    )?;
920    if changed != 1 {
921        return Err(MemoryError::ForgettingClosureIncomplete {
922            detail: "authority/projection/cache/export/replay epochs changed concurrently".into(),
923        });
924    }
925    Ok(after)
926}
927
928fn snapshot_id(tx: &Transaction<'_>, epoch: u64) -> Result<AuthoritySnapshotId, MemoryError> {
929    let mut stmt = tx
930        .prepare("SELECT lineage_id, active_head_id FROM authority_lineages ORDER BY lineage_id")?;
931    let heads = stmt
932        .query_map([], |row| {
933            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
934        })?
935        .collect::<Result<Vec<_>, _>>()?;
936    Ok(AuthoritySnapshotId(format!(
937        "epoch:{epoch}:{}",
938        digest(&(epoch, heads))?
939    )))
940}
941
942fn digest<T: Serialize>(value: &T) -> Result<String, MemoryError> {
943    let bytes = serde_json::to_vec(value)
944        .map_err(|error| MemoryError::DigestError(format!("serialize digest input: {error}")))?;
945    Ok(blake3::hash(&bytes).to_hex().to_string())
946}
947
948fn fault_gate(
949    fault: &Arc<Mutex<Option<AuthorityFaultStage>>>,
950    stage: AuthorityFaultStage,
951) -> Result<(), MemoryError> {
952    let mut guard = fault
953        .lock()
954        .unwrap_or_else(|poisoned| poisoned.into_inner());
955    if guard.as_ref() == Some(&stage) {
956        *guard = None;
957        return Err(MemoryError::AuthorityFaultInjected { stage });
958    }
959    Ok(())
960}