1use 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 store.clear_search_cache_strict()?;
138 let fault = store.inner.authority_fault.clone();
139 let receipt = store
140 .with_write_conn(move |conn| {
141 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 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_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 "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}