Skip to main content

cortex_store/
semantic_diff.rs

1//! Self-contained semantic backup/restore diff scaffolding.
2//!
3//! This module intentionally has no repository or CLI wiring. Parent tasks can
4//! export it and feed snapshots from SQLite/JSONL after the restore command owns
5//! the structural backup verification path.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use rusqlite::OptionalExtension;
10use serde::{Deserialize, Serialize};
11
12use crate::{Pool, StoreError, StoreResult};
13
14/// Meaning-level snapshot captured before or after restore.
15#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
16#[serde(default)]
17pub struct SemanticSnapshot {
18    /// Optional identifier for this snapshot.
19    pub snapshot_id: Option<String>,
20    /// Schema version at snapshot time.
21    pub schema_version: Option<u16>,
22    /// Active principles keyed by principle id.
23    pub active_principles: BTreeMap<String, PrincipleState>,
24    /// Active doctrine entries keyed by doctrine id.
25    pub active_doctrine: BTreeMap<String, DoctrineState>,
26    /// Active memories keyed by memory id.
27    pub active_memories: BTreeMap<String, MemoryState>,
28    /// Bucketed distribution of salience scores across active memories.
29    pub salience_distribution: SalienceDistribution,
30    /// Unresolved contradictions keyed by contradiction id.
31    pub unresolved_contradictions: BTreeMap<String, ContradictionState>,
32    /// Combined trust state for principals and keys.
33    pub trust_state: TrustKeyState,
34    /// Effective truth ceiling at snapshot time.
35    pub truth_ceiling: TruthCeilingState,
36}
37
38impl SemanticSnapshot {
39    /// Diffs the currently accepted semantic state against a candidate restore.
40    ///
41    /// `current` is the pre-restore state that would be replaced. `restored` is
42    /// the semantic state reconstructed from the candidate backup.
43    pub fn diff_against_restore(&self, restored: &Self) -> SemanticDiff {
44        SemanticDiff::between(self, restored)
45    }
46}
47
48/// Extract the current meaning-level snapshot from the SQLite store.
49///
50/// This is intentionally read-only. It is not a restore operation; it gives
51/// restore and backup callers a canonical snapshot shape to compare before
52/// permitting semantic state replacement.
53pub fn semantic_snapshot_from_store(pool: &Pool) -> StoreResult<SemanticSnapshot> {
54    let schema_version = current_schema_version(pool)?;
55    let mut active_memories = BTreeMap::new();
56    let mut salience_distribution = SalienceDistribution::default();
57
58    let mut memory_stmt = pool.prepare(
59        "SELECT id, memory_type, claim, salience_json, authority
60         FROM memories
61         WHERE status = 'active'
62         ORDER BY id;",
63    )?;
64    let memory_rows = memory_stmt.query_map([], |row| {
65        Ok((
66            row.get::<_, String>(0)?,
67            row.get::<_, String>(1)?,
68            row.get::<_, String>(2)?,
69            row.get::<_, String>(3)?,
70            row.get::<_, String>(4)?,
71        ))
72    })?;
73    for row in memory_rows {
74        let (id, memory_type, claim, salience_json, authority) = row?;
75        let salience = parse_salience_score(&salience_json)?;
76        salience_distribution.add(salience);
77        active_memories.insert(
78            id,
79            MemoryState {
80                claim,
81                memory_type: parse_memory_kind(&memory_type),
82                claim_key: None,
83                truth_state: truth_state_from_authority(&authority),
84                salience,
85            },
86        );
87    }
88
89    Ok(SemanticSnapshot {
90        snapshot_id: Some("store-current".into()),
91        schema_version,
92        active_principles: active_principles_from_store(pool)?,
93        active_doctrine: active_doctrine_from_store(pool)?,
94        active_memories,
95        salience_distribution,
96        unresolved_contradictions: unresolved_contradictions_from_store(pool)?,
97        trust_state: trust_state_from_store(pool)?,
98        truth_ceiling: TruthCeilingState::default(),
99    })
100}
101
102/// Semantic state of one principle in a snapshot.
103#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Ord, PartialOrd, Serialize)]
104pub struct PrincipleState {
105    /// Principle title or statement.
106    pub title: String,
107    /// Current truth classification.
108    pub truth_state: TruthState,
109    /// Memory ids that support this principle.
110    pub supporting_memory_ids: BTreeSet<String>,
111}
112
113/// Semantic state of one doctrine entry in a snapshot.
114#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Ord, PartialOrd, Serialize)]
115pub struct DoctrineState {
116    /// Doctrine rule text.
117    pub rule: String,
118    /// Id of the principle that generated this doctrine entry.
119    pub source_principle_id: String,
120    /// Enforcement strength of this doctrine entry.
121    pub force: DoctrineForce,
122    /// Current truth classification.
123    pub truth_state: TruthState,
124}
125
126/// Enforcement strength of a doctrine entry.
127#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Ord, PartialOrd, Serialize)]
128#[serde(rename_all = "snake_case")]
129pub enum DoctrineForce {
130    /// Advisory — informational only, not enforced.
131    Advisory,
132    /// Conditioning — influences behaviour without hard refusal.
133    Conditioning,
134    /// Gate — enforced; blocks the action when violated.
135    Gate,
136}
137
138/// Semantic state of one memory entry in a snapshot.
139#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Ord, PartialOrd, Serialize)]
140pub struct MemoryState {
141    /// Memory claim text.
142    pub claim: String,
143    /// Category of this memory.
144    pub memory_type: MemoryKind,
145    /// Optional deduplication key for the claim.
146    pub claim_key: Option<String>,
147    /// Current truth classification.
148    pub truth_state: TruthState,
149    /// Quantized salience score.
150    pub salience: SalienceScore,
151}
152
153/// Category of a memory entry.
154#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Ord, PartialOrd, Serialize)]
155#[serde(rename_all = "snake_case")]
156pub enum MemoryKind {
157    /// Event-based episodic memory.
158    Episodic,
159    /// Generalised semantic knowledge.
160    Semantic,
161    /// How-to procedural knowledge.
162    Procedural,
163    /// High-level strategic knowledge.
164    Strategic,
165    /// Affective or preference-related memory.
166    Affective,
167}
168
169/// Quantized salience score. Store callers can decide the input scale.
170#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Ord, PartialOrd, Serialize)]
171pub struct SalienceScore(pub u32);
172
173/// Bucketed count of memories by salience band.
174#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Ord, PartialOrd, Serialize)]
175#[serde(default)]
176pub struct SalienceDistribution {
177    /// Memories with salience score 0–24.
178    pub low: u32,
179    /// Memories with salience score 25–74.
180    pub medium: u32,
181    /// Memories with salience score 75–89.
182    pub high: u32,
183    /// Memories with salience score 90–100.
184    pub critical: u32,
185}
186
187/// Semantic state of one unresolved contradiction in a snapshot.
188#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Ord, PartialOrd, Serialize)]
189pub struct ContradictionState {
190    /// Compound key identifying the contradicting claims.
191    pub claim_key: String,
192    /// Ids of memories involved in the contradiction.
193    pub memory_ids: BTreeSet<String>,
194    /// Human-readable contradiction reason.
195    pub reason: String,
196}
197
198/// Combined trust state for principals and signing keys in a snapshot.
199#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
200#[serde(default)]
201pub struct TrustKeyState {
202    /// Trust tier and key associations per principal id.
203    pub principals: BTreeMap<String, PrincipalTrustState>,
204    /// Lifecycle state per key id.
205    pub keys: BTreeMap<String, KeyLifecycleState>,
206}
207
208/// Trust tier and associated signing keys for one principal.
209#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Ord, PartialOrd, Serialize)]
210pub struct PrincipalTrustState {
211    /// Trust tier assigned to this principal.
212    pub trust_tier: TrustTier,
213    /// Key ids associated with this principal.
214    pub key_ids: BTreeSet<String>,
215}
216
217/// Trust tier hierarchy for a principal.
218#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Ord, PartialOrd, Serialize)]
219#[serde(rename_all = "snake_case")]
220pub enum TrustTier {
221    /// No established trust.
222    Untrusted,
223    /// Observed but not verified.
224    Observed,
225    /// Cryptographically verified.
226    Verified,
227    /// Operator-granted authority.
228    Operator,
229}
230
231/// Lifecycle state of one signing key.
232#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Ord, PartialOrd, Serialize)]
233pub struct KeyLifecycleState {
234    /// Principal that owns this key.
235    pub principal_id: String,
236    /// Current lifecycle state of the key.
237    pub key_state: KeyState,
238}
239
240/// Lifecycle state of a signing key.
241#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Ord, PartialOrd, Serialize)]
242#[serde(rename_all = "snake_case")]
243pub enum KeyState {
244    /// Key has been revoked and must not be used.
245    Revoked,
246    /// Key has been retired (graceful end-of-life).
247    Retired,
248    /// Key is currently active and may be used for signing.
249    Active,
250}
251
252impl KeyState {
253    fn permits_new_signing(&self) -> bool {
254        matches!(self, Self::Active)
255    }
256}
257
258/// Active truth state of a semantic artifact.
259#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Ord, PartialOrd, Serialize)]
260#[serde(rename_all = "snake_case")]
261pub enum TruthState {
262    /// Truth state not established.
263    Unknown,
264    /// Proposed but not yet accepted.
265    Candidate,
266    /// Accepted as active.
267    Active,
268    /// Validated by explicit review.
269    Validated,
270    /// Full cryptographic chain verified.
271    FullChainVerified,
272    /// Authority-grade verified.
273    AuthorityGrade,
274}
275
276/// Effective truth ceiling for a snapshot, capturing runtime mode, proof state, and per-claim ceilings.
277#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Ord, PartialOrd, Serialize)]
278#[serde(default)]
279pub struct TruthCeilingState {
280    /// Runtime mode bounding this snapshot.
281    pub runtime_mode: RuntimeMode,
282    /// Proof state available for this snapshot.
283    pub proof_state: ProofState,
284    /// Aggregate claim ceiling for this snapshot.
285    pub claim_ceiling: TruthCeiling,
286    /// Per-claim overrides.
287    pub per_claim: BTreeMap<String, TruthCeiling>,
288}
289
290impl Default for TruthCeilingState {
291    fn default() -> Self {
292        Self {
293            runtime_mode: RuntimeMode::Unknown,
294            proof_state: ProofState::Unknown,
295            claim_ceiling: TruthCeiling::Unknown,
296            per_claim: BTreeMap::new(),
297        }
298    }
299}
300
301/// Runtime mode captured in a snapshot's truth ceiling.
302#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Ord, PartialOrd, Serialize)]
303#[serde(rename_all = "snake_case")]
304pub enum RuntimeMode {
305    /// Runtime mode not determined.
306    Unknown,
307    /// Local development mode.
308    Dev,
309    /// Local unsigned ledger mode.
310    LocalUnsigned,
311    /// Signed local ledger mode.
312    SignedLocalLedger,
313    /// Externally anchored mode.
314    ExternallyAnchored,
315    /// Authority-grade mode.
316    AuthorityGrade,
317}
318
319/// Proof state captured in a snapshot's truth ceiling.
320#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Ord, PartialOrd, Serialize)]
321#[serde(rename_all = "snake_case")]
322pub enum ProofState {
323    /// Proof chain is broken.
324    Broken,
325    /// Proof state not established.
326    Unknown,
327    /// Partial proof available.
328    Partial,
329    /// Full cryptographic chain verified.
330    FullChainVerified,
331}
332
333/// Maximum claim strength permitted by the snapshot's proof and runtime context.
334#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Ord, PartialOrd, Serialize)]
335#[serde(rename_all = "snake_case")]
336pub enum TruthCeiling {
337    /// No claims permitted.
338    None,
339    /// Ceiling not established.
340    Unknown,
341    /// Advisory claims only.
342    Advisory,
343    /// Locally observed claims.
344    ObservedLocal,
345    /// Claims backed by a trusted local ledger.
346    TrustedLocalLedger,
347    /// Claims externally anchored.
348    ExternallyAnchored,
349    /// Authority-grade claims.
350    AuthorityGrade,
351}
352
353/// Full semantic diff from current state to candidate restored state.
354#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
355pub struct SemanticDiff {
356    /// Snapshot id of the pre-restore (current) state.
357    pub current_snapshot_id: Option<String>,
358    /// Snapshot id of the candidate restored state.
359    pub restored_snapshot_id: Option<String>,
360    /// Ordered list of semantic changes detected.
361    pub changes: Vec<SemanticChange>,
362}
363
364impl SemanticDiff {
365    /// Compute the diff between `current` and `restored` snapshots.
366    pub fn between(current: &SemanticSnapshot, restored: &SemanticSnapshot) -> Self {
367        let mut diff = Self {
368            current_snapshot_id: current.snapshot_id.clone(),
369            restored_snapshot_id: restored.snapshot_id.clone(),
370            changes: Vec::new(),
371        };
372
373        compare_named_map(
374            &mut diff,
375            &current.active_principles,
376            &restored.active_principles,
377            SemanticChange::active_principle_missing,
378            SemanticChange::active_principle_added,
379            |id, before, after| {
380                if after.truth_state < before.truth_state {
381                    SemanticChange::truth_state_downgraded(
382                        ArtifactKind::ActivePrinciple,
383                        id,
384                        before.truth_state.clone(),
385                        after.truth_state.clone(),
386                    )
387                } else {
388                    SemanticChange::active_principle_changed(id)
389                }
390            },
391        );
392
393        compare_named_map(
394            &mut diff,
395            &current.active_doctrine,
396            &restored.active_doctrine,
397            SemanticChange::active_doctrine_missing,
398            SemanticChange::active_doctrine_added,
399            |id, before, after| {
400                if after.truth_state < before.truth_state {
401                    SemanticChange::truth_state_downgraded(
402                        ArtifactKind::ActiveDoctrine,
403                        id,
404                        before.truth_state.clone(),
405                        after.truth_state.clone(),
406                    )
407                } else {
408                    SemanticChange::active_doctrine_changed(id)
409                }
410            },
411        );
412
413        compare_named_map(
414            &mut diff,
415            &current.active_memories,
416            &restored.active_memories,
417            SemanticChange::active_memory_missing,
418            SemanticChange::active_memory_added,
419            |id, before, after| {
420                if after.truth_state < before.truth_state {
421                    SemanticChange::truth_state_downgraded(
422                        ArtifactKind::ActiveMemory,
423                        id,
424                        before.truth_state.clone(),
425                        after.truth_state.clone(),
426                    )
427                } else {
428                    SemanticChange::active_memory_changed(id)
429                }
430            },
431        );
432
433        if current.salience_distribution != restored.salience_distribution {
434            diff.changes.push(SemanticChange {
435                severity: SemanticSeverity::Warning,
436                kind: SemanticChangeKind::SalienceDistributionChanged {
437                    current: current.salience_distribution.clone(),
438                    restored: restored.salience_distribution.clone(),
439                },
440            });
441        }
442
443        compare_named_map(
444            &mut diff,
445            &current.unresolved_contradictions,
446            &restored.unresolved_contradictions,
447            |id| SemanticChange {
448                severity: SemanticSeverity::PreconditionUnmet,
449                kind: SemanticChangeKind::UnresolvedContradictionMissing { id },
450            },
451            |id| SemanticChange {
452                severity: SemanticSeverity::Warning,
453                kind: SemanticChangeKind::UnresolvedContradictionAdded { id },
454            },
455            |id, _, _| SemanticChange {
456                severity: SemanticSeverity::PreconditionUnmet,
457                kind: SemanticChangeKind::UnresolvedContradictionChanged { id },
458            },
459        );
460
461        compare_principals(&mut diff, &current.trust_state, &restored.trust_state);
462        compare_keys(&mut diff, &current.trust_state, &restored.trust_state);
463        compare_truth_ceiling(&mut diff, &current.truth_ceiling, &restored.truth_ceiling);
464
465        diff
466    }
467
468    /// Returns the highest severity across all changes, or `Clean` when there are none.
469    pub fn severity(&self) -> SemanticSeverity {
470        self.changes
471            .iter()
472            .map(|change| change.severity)
473            .max()
474            .unwrap_or(SemanticSeverity::Clean)
475    }
476
477    /// Returns `true` when the diff contains no changes.
478    pub fn is_clean(&self) -> bool {
479        self.changes.is_empty()
480    }
481
482    /// Derives a restore decision from this diff given the operator's acknowledgement flag.
483    pub fn restore_decision(&self, recovery_acknowledged: bool) -> RestoreDecision {
484        RestoreDecision::from_diff(self, recovery_acknowledged)
485    }
486}
487
488fn current_schema_version(pool: &Pool) -> StoreResult<Option<u16>> {
489    let version = pool
490        .query_row(
491            "SELECT schema_version FROM events
492             UNION ALL
493             SELECT schema_version FROM traces
494             ORDER BY schema_version DESC
495             LIMIT 1;",
496            [],
497            |row| row.get::<_, u16>(0),
498        )
499        .optional()?;
500    Ok(version)
501}
502
503fn active_principles_from_store(pool: &Pool) -> StoreResult<BTreeMap<String, PrincipleState>> {
504    let mut stmt = pool.prepare(
505        "SELECT id, statement, status, supporting_memories_json
506         FROM principles
507         WHERE status IN ('active', 'promoted_to_doctrine')
508         ORDER BY id;",
509    )?;
510    let rows = stmt.query_map([], |row| {
511        Ok((
512            row.get::<_, String>(0)?,
513            row.get::<_, String>(1)?,
514            row.get::<_, String>(2)?,
515            row.get::<_, String>(3)?,
516        ))
517    })?;
518
519    let mut principles = BTreeMap::new();
520    for row in rows {
521        let (id, title, status, supporting_memories_json) = row?;
522        let supporting_memory_ids = string_set_from_json(&supporting_memories_json)?;
523        principles.insert(
524            id,
525            PrincipleState {
526                title,
527                truth_state: truth_state_from_status(&status),
528                supporting_memory_ids,
529            },
530        );
531    }
532    Ok(principles)
533}
534
535fn active_doctrine_from_store(pool: &Pool) -> StoreResult<BTreeMap<String, DoctrineState>> {
536    let mut stmt = pool.prepare(
537        "SELECT id, source_principle, rule, force
538         FROM doctrine
539         ORDER BY id;",
540    )?;
541    let rows = stmt.query_map([], |row| {
542        Ok((
543            row.get::<_, String>(0)?,
544            row.get::<_, String>(1)?,
545            row.get::<_, String>(2)?,
546            row.get::<_, String>(3)?,
547        ))
548    })?;
549
550    let mut doctrine = BTreeMap::new();
551    for row in rows {
552        let (id, source_principle_id, rule, force) = row?;
553        doctrine.insert(
554            id,
555            DoctrineState {
556                rule,
557                source_principle_id,
558                force: parse_doctrine_force(&force)?,
559                truth_state: TruthState::Active,
560            },
561        );
562    }
563    Ok(doctrine)
564}
565
566fn unresolved_contradictions_from_store(
567    pool: &Pool,
568) -> StoreResult<BTreeMap<String, ContradictionState>> {
569    let mut stmt = pool.prepare(
570        "SELECT id, left_ref, right_ref, contradiction_type
571         FROM contradictions
572         WHERE status = 'unresolved'
573         ORDER BY id;",
574    )?;
575    let rows = stmt.query_map([], |row| {
576        Ok((
577            row.get::<_, String>(0)?,
578            row.get::<_, String>(1)?,
579            row.get::<_, String>(2)?,
580            row.get::<_, String>(3)?,
581        ))
582    })?;
583
584    let mut contradictions = BTreeMap::new();
585    for row in rows {
586        let (id, left_ref, right_ref, reason) = row?;
587        contradictions.insert(
588            id,
589            ContradictionState {
590                claim_key: format!("{left_ref}|{right_ref}"),
591                memory_ids: BTreeSet::from([left_ref, right_ref]),
592                reason,
593            },
594        );
595    }
596    Ok(contradictions)
597}
598
599fn trust_state_from_store(pool: &Pool) -> StoreResult<TrustKeyState> {
600    let mut trust_state = TrustKeyState::default();
601
602    let mut principal_stmt = pool.prepare(
603        "SELECT p.principal_id, p.trust_tier
604         FROM authority_principal_timeline p
605         WHERE NOT EXISTS (
606             SELECT 1 FROM authority_principal_timeline later
607             WHERE later.principal_id = p.principal_id
608               AND later.effective_at > p.effective_at
609         )
610         ORDER BY p.principal_id;",
611    )?;
612    let principal_rows = principal_stmt.query_map([], |row| {
613        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
614    })?;
615    for row in principal_rows {
616        let (principal_id, trust_tier) = row?;
617        trust_state.principals.insert(
618            principal_id,
619            PrincipalTrustState {
620                trust_tier: parse_trust_tier(&trust_tier)?,
621                key_ids: BTreeSet::new(),
622            },
623        );
624    }
625
626    let mut key_stmt = pool.prepare(
627        "SELECT k.key_id, k.principal_id, k.state
628         FROM authority_key_timeline k
629         WHERE NOT EXISTS (
630             SELECT 1 FROM authority_key_timeline later
631             WHERE later.key_id = k.key_id
632               AND later.effective_at > k.effective_at
633         )
634         ORDER BY k.key_id, k.state;",
635    )?;
636    let key_rows = key_stmt.query_map([], |row| {
637        Ok((
638            row.get::<_, String>(0)?,
639            row.get::<_, String>(1)?,
640            row.get::<_, String>(2)?,
641        ))
642    })?;
643    for row in key_rows {
644        let (key_id, principal_id, key_state) = row?;
645        trust_state
646            .principals
647            .entry(principal_id.clone())
648            .or_insert_with(|| PrincipalTrustState {
649                trust_tier: TrustTier::Untrusted,
650                key_ids: BTreeSet::new(),
651            })
652            .key_ids
653            .insert(key_id.clone());
654        trust_state.keys.insert(
655            key_id,
656            KeyLifecycleState {
657                principal_id,
658                key_state: parse_key_state(&key_state)?,
659            },
660        );
661    }
662
663    Ok(trust_state)
664}
665
666fn string_set_from_json(raw: &str) -> StoreResult<BTreeSet<String>> {
667    let value: serde_json::Value = serde_json::from_str(raw)?;
668    let Some(values) = value.as_array() else {
669        return Err(StoreError::Validation(
670            "semantic snapshot expected JSON array".into(),
671        ));
672    };
673    Ok(values
674        .iter()
675        .filter_map(|value| value.as_str().map(ToOwned::to_owned))
676        .collect())
677}
678
679fn parse_salience_score(raw: &str) -> StoreResult<SalienceScore> {
680    let value: serde_json::Value = serde_json::from_str(raw)?;
681    let score = value
682        .get("score")
683        .and_then(serde_json::Value::as_f64)
684        .unwrap_or_default()
685        .clamp(0.0, 1.0);
686    Ok(SalienceScore((score * 100.0).round() as u32))
687}
688
689impl SalienceDistribution {
690    fn add(&mut self, score: SalienceScore) {
691        match score.0 {
692            0..=24 => self.low += 1,
693            25..=74 => self.medium += 1,
694            75..=89 => self.high += 1,
695            _ => self.critical += 1,
696        }
697    }
698}
699
700fn parse_memory_kind(value: &str) -> MemoryKind {
701    match value {
702        "episodic" => MemoryKind::Episodic,
703        "procedural" => MemoryKind::Procedural,
704        "strategic" => MemoryKind::Strategic,
705        "affective" => MemoryKind::Affective,
706        _ => MemoryKind::Semantic,
707    }
708}
709
710fn parse_doctrine_force(value: &str) -> StoreResult<DoctrineForce> {
711    match value {
712        "Advisory" | "advisory" => Ok(DoctrineForce::Advisory),
713        "Conditioning" | "conditioning" => Ok(DoctrineForce::Conditioning),
714        "Gate" | "gate" => Ok(DoctrineForce::Gate),
715        other => Err(StoreError::Validation(format!(
716            "unknown doctrine force `{other}`"
717        ))),
718    }
719}
720
721fn parse_trust_tier(value: &str) -> StoreResult<TrustTier> {
722    match value {
723        "untrusted" => Ok(TrustTier::Untrusted),
724        "observed" => Ok(TrustTier::Observed),
725        "verified" => Ok(TrustTier::Verified),
726        "operator" => Ok(TrustTier::Operator),
727        other => Err(StoreError::Validation(format!(
728            "unknown trust tier `{other}`"
729        ))),
730    }
731}
732
733fn parse_key_state(value: &str) -> StoreResult<KeyState> {
734    match value {
735        "active" => Ok(KeyState::Active),
736        "retired" => Ok(KeyState::Retired),
737        "revoked" => Ok(KeyState::Revoked),
738        other => Err(StoreError::Validation(format!(
739            "unknown key state `{other}`"
740        ))),
741    }
742}
743
744fn truth_state_from_status(status: &str) -> TruthState {
745    match status {
746        "candidate" => TruthState::Candidate,
747        "promoted_to_doctrine" | "active" => TruthState::Active,
748        _ => TruthState::Unknown,
749    }
750}
751
752fn truth_state_from_authority(authority: &str) -> TruthState {
753    match authority {
754        "verified" | "operator" => TruthState::Validated,
755        "candidate" | "runtime" | "user" | "derived" => TruthState::Active,
756        _ => TruthState::Unknown,
757    }
758}
759
760fn compare_named_map<T, Missing, Added, Changed>(
761    diff: &mut SemanticDiff,
762    current: &BTreeMap<String, T>,
763    restored: &BTreeMap<String, T>,
764    missing: Missing,
765    added: Added,
766    changed: Changed,
767) where
768    T: Eq,
769    Missing: Fn(String) -> SemanticChange,
770    Added: Fn(String) -> SemanticChange,
771    Changed: Fn(String, &T, &T) -> SemanticChange,
772{
773    for (id, current_value) in current {
774        match restored.get(id) {
775            Some(restored_value) if restored_value == current_value => {}
776            Some(restored_value) => {
777                diff.changes
778                    .push(changed(id.clone(), current_value, restored_value));
779            }
780            None => diff.changes.push(missing(id.clone())),
781        }
782    }
783
784    for id in restored.keys() {
785        if !current.contains_key(id) {
786            diff.changes.push(added(id.clone()));
787        }
788    }
789}
790
791fn compare_principals(diff: &mut SemanticDiff, current: &TrustKeyState, restored: &TrustKeyState) {
792    compare_named_map(
793        diff,
794        &current.principals,
795        &restored.principals,
796        |id| SemanticChange {
797            severity: SemanticSeverity::PreconditionUnmet,
798            kind: SemanticChangeKind::PrincipalMissing { id },
799        },
800        |id| SemanticChange {
801            severity: SemanticSeverity::Warning,
802            kind: SemanticChangeKind::PrincipalAdded { id },
803        },
804        |id, before, after| {
805            if after.trust_tier < before.trust_tier {
806                SemanticChange {
807                    severity: SemanticSeverity::PreconditionUnmet,
808                    kind: SemanticChangeKind::TrustTierDowngraded {
809                        id,
810                        current: before.trust_tier.clone(),
811                        restored: after.trust_tier.clone(),
812                    },
813                }
814            } else if after.trust_tier != before.trust_tier {
815                SemanticChange {
816                    severity: SemanticSeverity::Warning,
817                    kind: SemanticChangeKind::TrustTierChanged {
818                        id,
819                        current: before.trust_tier.clone(),
820                        restored: after.trust_tier.clone(),
821                    },
822                }
823            } else {
824                SemanticChange {
825                    severity: SemanticSeverity::Warning,
826                    kind: SemanticChangeKind::PrincipalChanged { id },
827                }
828            }
829        },
830    );
831}
832
833fn compare_keys(diff: &mut SemanticDiff, current: &TrustKeyState, restored: &TrustKeyState) {
834    compare_named_map(
835        diff,
836        &current.keys,
837        &restored.keys,
838        |id| SemanticChange {
839            severity: SemanticSeverity::PreconditionUnmet,
840            kind: SemanticChangeKind::KeyMissing { id },
841        },
842        |id| SemanticChange {
843            severity: SemanticSeverity::Warning,
844            kind: SemanticChangeKind::KeyAdded { id },
845        },
846        |id, before, after| {
847            if after.principal_id != before.principal_id {
848                SemanticChange {
849                    severity: SemanticSeverity::PreconditionUnmet,
850                    kind: SemanticChangeKind::KeyPrincipalChanged {
851                        id,
852                        current: before.principal_id.clone(),
853                        restored: after.principal_id.clone(),
854                    },
855                }
856            } else if after.key_state.permits_new_signing()
857                && !before.key_state.permits_new_signing()
858            {
859                SemanticChange {
860                    severity: SemanticSeverity::PreconditionUnmet,
861                    kind: SemanticChangeKind::KeyReactivated {
862                        id,
863                        current: before.key_state.clone(),
864                        restored: after.key_state.clone(),
865                    },
866                }
867            } else if after.key_state < before.key_state {
868                SemanticChange {
869                    severity: SemanticSeverity::PreconditionUnmet,
870                    kind: SemanticChangeKind::KeyStateDowngraded {
871                        id,
872                        current: before.key_state.clone(),
873                        restored: after.key_state.clone(),
874                    },
875                }
876            } else {
877                SemanticChange {
878                    severity: SemanticSeverity::Warning,
879                    kind: SemanticChangeKind::KeyChanged { id },
880                }
881            }
882        },
883    );
884}
885
886fn compare_truth_ceiling(
887    diff: &mut SemanticDiff,
888    current: &TruthCeilingState,
889    restored: &TruthCeilingState,
890) {
891    if restored.runtime_mode < current.runtime_mode {
892        diff.changes.push(SemanticChange {
893            severity: SemanticSeverity::PreconditionUnmet,
894            kind: SemanticChangeKind::RuntimeModeDowngraded {
895                current: current.runtime_mode.clone(),
896                restored: restored.runtime_mode.clone(),
897            },
898        });
899    }
900
901    if restored.proof_state < current.proof_state {
902        diff.changes.push(SemanticChange {
903            severity: SemanticSeverity::PreconditionUnmet,
904            kind: SemanticChangeKind::ProofStateDowngraded {
905                current: current.proof_state.clone(),
906                restored: restored.proof_state.clone(),
907            },
908        });
909    }
910
911    if restored.claim_ceiling < current.claim_ceiling {
912        diff.changes.push(SemanticChange {
913            severity: SemanticSeverity::PreconditionUnmet,
914            kind: SemanticChangeKind::TruthCeilingDowngraded {
915                current: current.claim_ceiling.clone(),
916                restored: restored.claim_ceiling.clone(),
917            },
918        });
919    } else if restored.claim_ceiling != current.claim_ceiling {
920        diff.changes.push(SemanticChange {
921            severity: SemanticSeverity::Warning,
922            kind: SemanticChangeKind::TruthCeilingChanged {
923                current: current.claim_ceiling.clone(),
924                restored: restored.claim_ceiling.clone(),
925            },
926        });
927    }
928
929    for (claim, current_ceiling) in &current.per_claim {
930        match restored.per_claim.get(claim) {
931            Some(restored_ceiling) if restored_ceiling < current_ceiling => {
932                diff.changes.push(SemanticChange {
933                    severity: SemanticSeverity::PreconditionUnmet,
934                    kind: SemanticChangeKind::ClaimTruthCeilingDowngraded {
935                        claim: claim.clone(),
936                        current: current_ceiling.clone(),
937                        restored: restored_ceiling.clone(),
938                    },
939                });
940            }
941            Some(restored_ceiling) if restored_ceiling != current_ceiling => {
942                diff.changes.push(SemanticChange {
943                    severity: SemanticSeverity::Warning,
944                    kind: SemanticChangeKind::ClaimTruthCeilingChanged {
945                        claim: claim.clone(),
946                        current: current_ceiling.clone(),
947                        restored: restored_ceiling.clone(),
948                    },
949                });
950            }
951            Some(_) => {}
952            None => diff.changes.push(SemanticChange {
953                severity: SemanticSeverity::PreconditionUnmet,
954                kind: SemanticChangeKind::ClaimTruthCeilingMissing {
955                    claim: claim.clone(),
956                },
957            }),
958        }
959    }
960
961    for claim in restored.per_claim.keys() {
962        if !current.per_claim.contains_key(claim) {
963            diff.changes.push(SemanticChange {
964                severity: SemanticSeverity::Warning,
965                kind: SemanticChangeKind::ClaimTruthCeilingAdded {
966                    claim: claim.clone(),
967                },
968            });
969        }
970    }
971}
972
973/// One detected semantic change between current and restored snapshots.
974#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
975pub struct SemanticChange {
976    /// Severity of this change.
977    pub severity: SemanticSeverity,
978    /// Specific kind of change detected.
979    pub kind: SemanticChangeKind,
980}
981
982impl SemanticChange {
983    fn active_principle_missing(id: String) -> Self {
984        Self {
985            severity: SemanticSeverity::PreconditionUnmet,
986            kind: SemanticChangeKind::ActivePrincipleMissing { id },
987        }
988    }
989
990    fn active_principle_added(id: String) -> Self {
991        Self {
992            severity: SemanticSeverity::Warning,
993            kind: SemanticChangeKind::ActivePrincipleAdded { id },
994        }
995    }
996
997    fn active_principle_changed(id: String) -> Self {
998        Self {
999            severity: SemanticSeverity::Warning,
1000            kind: SemanticChangeKind::ActivePrincipleChanged { id },
1001        }
1002    }
1003
1004    fn active_doctrine_missing(id: String) -> Self {
1005        Self {
1006            severity: SemanticSeverity::PreconditionUnmet,
1007            kind: SemanticChangeKind::ActiveDoctrineMissing { id },
1008        }
1009    }
1010
1011    fn active_doctrine_added(id: String) -> Self {
1012        Self {
1013            severity: SemanticSeverity::Warning,
1014            kind: SemanticChangeKind::ActiveDoctrineAdded { id },
1015        }
1016    }
1017
1018    fn active_doctrine_changed(id: String) -> Self {
1019        Self {
1020            severity: SemanticSeverity::Warning,
1021            kind: SemanticChangeKind::ActiveDoctrineChanged { id },
1022        }
1023    }
1024
1025    fn active_memory_missing(id: String) -> Self {
1026        Self {
1027            severity: SemanticSeverity::PreconditionUnmet,
1028            kind: SemanticChangeKind::ActiveMemoryMissing { id },
1029        }
1030    }
1031
1032    fn active_memory_added(id: String) -> Self {
1033        Self {
1034            severity: SemanticSeverity::Warning,
1035            kind: SemanticChangeKind::ActiveMemoryAdded { id },
1036        }
1037    }
1038
1039    fn active_memory_changed(id: String) -> Self {
1040        Self {
1041            severity: SemanticSeverity::Warning,
1042            kind: SemanticChangeKind::ActiveMemoryChanged { id },
1043        }
1044    }
1045
1046    fn truth_state_downgraded(
1047        artifact: ArtifactKind,
1048        id: String,
1049        current: TruthState,
1050        restored: TruthState,
1051    ) -> Self {
1052        Self {
1053            severity: SemanticSeverity::PreconditionUnmet,
1054            kind: SemanticChangeKind::TruthStateDowngraded {
1055                artifact,
1056                id,
1057                current,
1058                restored,
1059            },
1060        }
1061    }
1062}
1063
1064/// Severity level of a semantic change detected during diff.
1065#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Ord, PartialOrd, Serialize)]
1066#[serde(rename_all = "snake_case")]
1067pub enum SemanticSeverity {
1068    /// No semantic change detected.
1069    Clean,
1070    /// Change detected but restore may proceed with operator acknowledgement.
1071    Warning,
1072    /// Blocking change; restore cannot proceed without explicit override.
1073    PreconditionUnmet,
1074}
1075
1076/// Decision outcome derived from a semantic diff before permitting a restore.
1077#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1078#[serde(rename_all = "snake_case")]
1079pub enum RestoreDecision {
1080    /// No changes detected; restore is safe.
1081    Clean,
1082    /// Changes detected but restore may proceed.
1083    #[allow(missing_docs)]
1084    Warning {
1085        recovery_acknowledged: bool,
1086        change_count: usize,
1087        blocked_change_count: usize,
1088    },
1089    /// Blocking changes detected; restore refused.
1090    #[allow(missing_docs)]
1091    PreconditionUnmet { blocked_change_count: usize },
1092}
1093
1094impl RestoreDecision {
1095    /// Derive a restore decision from a diff given the operator's acknowledgement flag.
1096    pub fn from_diff(diff: &SemanticDiff, recovery_acknowledged: bool) -> Self {
1097        let blocked_change_count = diff
1098            .changes
1099            .iter()
1100            .filter(|change| change.severity == SemanticSeverity::PreconditionUnmet)
1101            .count();
1102
1103        if diff.is_clean() {
1104            Self::Clean
1105        } else if blocked_change_count == 0 || recovery_acknowledged {
1106            Self::Warning {
1107                recovery_acknowledged,
1108                change_count: diff.changes.len(),
1109                blocked_change_count,
1110            }
1111        } else {
1112            Self::PreconditionUnmet {
1113                blocked_change_count,
1114            }
1115        }
1116    }
1117
1118    /// Construct a `Clean` decision.
1119    pub fn clean() -> Self {
1120        Self::Clean
1121    }
1122
1123    /// Construct a `Warning` decision for a given change count.
1124    pub fn warning(change_count: usize) -> Self {
1125        Self::Warning {
1126            recovery_acknowledged: false,
1127            change_count,
1128            blocked_change_count: 0,
1129        }
1130    }
1131
1132    /// Construct a `PreconditionUnmet` decision for a given blocked change count.
1133    pub fn precondition_unmet(blocked_change_count: usize) -> Self {
1134        Self::PreconditionUnmet {
1135            blocked_change_count,
1136        }
1137    }
1138
1139    /// Returns `true` when restore is permitted under this decision.
1140    pub fn allows_restore(&self) -> bool {
1141        matches!(self, Self::Clean | Self::Warning { .. })
1142    }
1143}
1144
1145/// Specific kind of semantic change detected between snapshots.
1146#[allow(missing_docs)]
1147#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1148#[serde(rename_all = "snake_case")]
1149pub enum SemanticChangeKind {
1150    ActivePrincipleMissing {
1151        id: String,
1152    },
1153    ActivePrincipleAdded {
1154        id: String,
1155    },
1156    ActivePrincipleChanged {
1157        id: String,
1158    },
1159    ActiveDoctrineMissing {
1160        id: String,
1161    },
1162    ActiveDoctrineAdded {
1163        id: String,
1164    },
1165    ActiveDoctrineChanged {
1166        id: String,
1167    },
1168    ActiveMemoryMissing {
1169        id: String,
1170    },
1171    ActiveMemoryAdded {
1172        id: String,
1173    },
1174    ActiveMemoryChanged {
1175        id: String,
1176    },
1177    SalienceDistributionChanged {
1178        current: SalienceDistribution,
1179        restored: SalienceDistribution,
1180    },
1181    UnresolvedContradictionMissing {
1182        id: String,
1183    },
1184    UnresolvedContradictionAdded {
1185        id: String,
1186    },
1187    UnresolvedContradictionChanged {
1188        id: String,
1189    },
1190    PrincipalMissing {
1191        id: String,
1192    },
1193    PrincipalAdded {
1194        id: String,
1195    },
1196    PrincipalChanged {
1197        id: String,
1198    },
1199    TrustTierDowngraded {
1200        id: String,
1201        current: TrustTier,
1202        restored: TrustTier,
1203    },
1204    TrustTierChanged {
1205        id: String,
1206        current: TrustTier,
1207        restored: TrustTier,
1208    },
1209    KeyMissing {
1210        id: String,
1211    },
1212    KeyAdded {
1213        id: String,
1214    },
1215    KeyChanged {
1216        id: String,
1217    },
1218    KeyPrincipalChanged {
1219        id: String,
1220        current: String,
1221        restored: String,
1222    },
1223    KeyStateDowngraded {
1224        id: String,
1225        current: KeyState,
1226        restored: KeyState,
1227    },
1228    KeyReactivated {
1229        id: String,
1230        current: KeyState,
1231        restored: KeyState,
1232    },
1233    TruthStateDowngraded {
1234        artifact: ArtifactKind,
1235        id: String,
1236        current: TruthState,
1237        restored: TruthState,
1238    },
1239    RuntimeModeDowngraded {
1240        current: RuntimeMode,
1241        restored: RuntimeMode,
1242    },
1243    ProofStateDowngraded {
1244        current: ProofState,
1245        restored: ProofState,
1246    },
1247    TruthCeilingDowngraded {
1248        current: TruthCeiling,
1249        restored: TruthCeiling,
1250    },
1251    TruthCeilingChanged {
1252        current: TruthCeiling,
1253        restored: TruthCeiling,
1254    },
1255    ClaimTruthCeilingMissing {
1256        claim: String,
1257    },
1258    ClaimTruthCeilingAdded {
1259        claim: String,
1260    },
1261    ClaimTruthCeilingDowngraded {
1262        claim: String,
1263        current: TruthCeiling,
1264        restored: TruthCeiling,
1265    },
1266    ClaimTruthCeilingChanged {
1267        claim: String,
1268        current: TruthCeiling,
1269        restored: TruthCeiling,
1270    },
1271}
1272
1273/// Category of semantic artifact referenced in a change record.
1274#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1275#[serde(rename_all = "snake_case")]
1276pub enum ArtifactKind {
1277    /// An active principle entry.
1278    ActivePrinciple,
1279    /// An active doctrine entry.
1280    ActiveDoctrine,
1281    /// An active memory entry.
1282    ActiveMemory,
1283}
1284
1285#[cfg(test)]
1286mod tests {
1287    use super::*;
1288
1289    #[test]
1290    fn restore_semantic_diff_detects_older_principle_set() {
1291        let mut current = base_snapshot();
1292        current.active_principles.insert(
1293            "prn_current".to_string(),
1294            PrincipleState {
1295                title: "Current principle".to_string(),
1296                truth_state: TruthState::FullChainVerified,
1297                supporting_memory_ids: BTreeSet::from(["mem_current".to_string()]),
1298            },
1299        );
1300        current.active_doctrine.insert(
1301            "doc_current".to_string(),
1302            DoctrineState {
1303                rule: "Current doctrine".to_string(),
1304                source_principle_id: "prn_current".to_string(),
1305                force: DoctrineForce::Gate,
1306                truth_state: TruthState::FullChainVerified,
1307            },
1308        );
1309
1310        let restored = base_snapshot();
1311        let diff = SemanticDiff::between(&current, &restored);
1312
1313        assert_eq!(diff.severity(), SemanticSeverity::PreconditionUnmet);
1314        assert!(diff.changes.iter().any(|change| matches!(
1315            &change.kind,
1316            SemanticChangeKind::ActivePrincipleMissing { id } if id == "prn_current"
1317        )));
1318        assert!(diff.changes.iter().any(|change| matches!(
1319            &change.kind,
1320            SemanticChangeKind::ActiveDoctrineMissing { id } if id == "doc_current"
1321        )));
1322        assert_eq!(
1323            diff.restore_decision(false),
1324            RestoreDecision::PreconditionUnmet {
1325                blocked_change_count: 2
1326            }
1327        );
1328    }
1329
1330    #[test]
1331    fn restore_semantic_diff_blocks_active_truth_state_downgrade_unless_acknowledged() {
1332        let mut current = base_snapshot();
1333        current.active_memories.insert(
1334            "mem_truth".to_string(),
1335            MemoryState {
1336                claim: "Restore candidate must not lower current truth".to_string(),
1337                memory_type: MemoryKind::Semantic,
1338                claim_key: Some("truth-slot".to_string()),
1339                truth_state: TruthState::FullChainVerified,
1340                salience: SalienceScore(90),
1341            },
1342        );
1343        current.truth_ceiling.claim_ceiling = TruthCeiling::TrustedLocalLedger;
1344
1345        let mut restored = current.clone();
1346        restored.active_memories.insert(
1347            "mem_truth".to_string(),
1348            MemoryState {
1349                claim: "Restore candidate must not lower current truth".to_string(),
1350                memory_type: MemoryKind::Semantic,
1351                claim_key: Some("truth-slot".to_string()),
1352                truth_state: TruthState::Active,
1353                salience: SalienceScore(90),
1354            },
1355        );
1356        restored.truth_ceiling.claim_ceiling = TruthCeiling::Advisory;
1357
1358        let diff = current.diff_against_restore(&restored);
1359
1360        assert_eq!(diff.severity(), SemanticSeverity::PreconditionUnmet);
1361        assert!(diff.changes.iter().any(|change| matches!(
1362            &change.kind,
1363            SemanticChangeKind::TruthStateDowngraded {
1364                artifact: ArtifactKind::ActiveMemory,
1365                id,
1366                current: TruthState::FullChainVerified,
1367                restored: TruthState::Active,
1368            } if id == "mem_truth"
1369        )));
1370        assert!(diff.changes.iter().any(|change| matches!(
1371            &change.kind,
1372            SemanticChangeKind::TruthCeilingDowngraded {
1373                current: TruthCeiling::TrustedLocalLedger,
1374                restored: TruthCeiling::Advisory,
1375            }
1376        )));
1377        assert!(!diff.restore_decision(false).allows_restore());
1378        assert!(diff.restore_decision(true).allows_restore());
1379        assert_eq!(
1380            diff.restore_decision(true),
1381            RestoreDecision::Warning {
1382                recovery_acknowledged: true,
1383                change_count: 2,
1384                blocked_change_count: 2
1385            }
1386        );
1387    }
1388
1389    #[test]
1390    fn restore_semantic_diff_blocks_key_principal_rebinding() {
1391        let mut current = base_snapshot();
1392        current.trust_state.principals.insert(
1393            "principal_current".to_string(),
1394            PrincipalTrustState {
1395                trust_tier: TrustTier::Operator,
1396                key_ids: BTreeSet::from(["key_primary".to_string()]),
1397            },
1398        );
1399        current.trust_state.keys.insert(
1400            "key_primary".to_string(),
1401            KeyLifecycleState {
1402                principal_id: "principal_current".to_string(),
1403                key_state: KeyState::Active,
1404            },
1405        );
1406
1407        let mut restored = current.clone();
1408        restored.trust_state.principals.insert(
1409            "principal_restored".to_string(),
1410            PrincipalTrustState {
1411                trust_tier: TrustTier::Operator,
1412                key_ids: BTreeSet::from(["key_primary".to_string()]),
1413            },
1414        );
1415        restored.trust_state.keys.insert(
1416            "key_primary".to_string(),
1417            KeyLifecycleState {
1418                principal_id: "principal_restored".to_string(),
1419                key_state: KeyState::Active,
1420            },
1421        );
1422
1423        let diff = current.diff_against_restore(&restored);
1424
1425        assert_eq!(diff.severity(), SemanticSeverity::PreconditionUnmet);
1426        assert!(diff.changes.iter().any(|change| matches!(
1427            &change.kind,
1428            SemanticChangeKind::KeyPrincipalChanged {
1429                id,
1430                current,
1431                restored,
1432            } if id == "key_primary"
1433                && current == "principal_current"
1434                && restored == "principal_restored"
1435        )));
1436        assert_eq!(
1437            diff.restore_decision(false),
1438            RestoreDecision::PreconditionUnmet {
1439                blocked_change_count: 1
1440            }
1441        );
1442    }
1443
1444    #[test]
1445    fn restore_semantic_diff_warns_on_salience_distribution_drift() {
1446        let current = base_snapshot();
1447        let mut restored = current.clone();
1448        restored.salience_distribution = SalienceDistribution {
1449            low: 0,
1450            medium: 1,
1451            high: 1,
1452            critical: 1,
1453        };
1454
1455        let diff = current.diff_against_restore(&restored);
1456
1457        assert_eq!(diff.severity(), SemanticSeverity::Warning);
1458        assert_eq!(diff.changes.len(), 1);
1459        assert!(matches!(
1460            &diff.changes[0],
1461            SemanticChange {
1462                severity: SemanticSeverity::Warning,
1463                kind: SemanticChangeKind::SalienceDistributionChanged {
1464                    current: SalienceDistribution {
1465                        low: 1,
1466                        medium: 1,
1467                        high: 0,
1468                        critical: 0,
1469                    },
1470                    restored: SalienceDistribution {
1471                        low: 0,
1472                        medium: 1,
1473                        high: 1,
1474                        critical: 1,
1475                    },
1476                },
1477            }
1478        ));
1479        assert_eq!(diff.restore_decision(false), RestoreDecision::warning(1));
1480    }
1481
1482    #[test]
1483    fn restore_semantic_diff_blocks_unresolved_contradiction_change() {
1484        let mut current = base_snapshot();
1485        current.unresolved_contradictions.insert(
1486            "ctr_current".to_string(),
1487            ContradictionState {
1488                claim_key: "claim://operator-intent".to_string(),
1489                memory_ids: BTreeSet::from(["mem_a".to_string(), "mem_b".to_string()]),
1490                reason: "current unresolved tension".to_string(),
1491            },
1492        );
1493
1494        let mut restored = current.clone();
1495        restored.unresolved_contradictions.insert(
1496            "ctr_current".to_string(),
1497            ContradictionState {
1498                claim_key: "claim://operator-intent".to_string(),
1499                memory_ids: BTreeSet::from(["mem_a".to_string(), "mem_c".to_string()]),
1500                reason: "restored conflict points at different evidence".to_string(),
1501            },
1502        );
1503
1504        let diff = current.diff_against_restore(&restored);
1505
1506        assert_eq!(diff.severity(), SemanticSeverity::PreconditionUnmet);
1507        assert!(diff.changes.iter().any(|change| matches!(
1508            &change.kind,
1509            SemanticChangeKind::UnresolvedContradictionChanged { id } if id == "ctr_current"
1510        )));
1511        assert_eq!(
1512            diff.restore_decision(false),
1513            RestoreDecision::PreconditionUnmet {
1514                blocked_change_count: 1
1515            }
1516        );
1517    }
1518
1519    #[test]
1520    fn restore_semantic_diff_reports_trust_tier_drift() {
1521        let mut current = base_snapshot();
1522        current.trust_state.principals.insert(
1523            "principal_downgraded".to_string(),
1524            PrincipalTrustState {
1525                trust_tier: TrustTier::Operator,
1526                key_ids: BTreeSet::from(["key_operator".to_string()]),
1527            },
1528        );
1529        current.trust_state.principals.insert(
1530            "principal_upgraded".to_string(),
1531            PrincipalTrustState {
1532                trust_tier: TrustTier::Observed,
1533                key_ids: BTreeSet::from(["key_observed".to_string()]),
1534            },
1535        );
1536
1537        let mut restored = current.clone();
1538        restored.trust_state.principals.insert(
1539            "principal_downgraded".to_string(),
1540            PrincipalTrustState {
1541                trust_tier: TrustTier::Verified,
1542                key_ids: BTreeSet::from(["key_operator".to_string()]),
1543            },
1544        );
1545        restored.trust_state.principals.insert(
1546            "principal_upgraded".to_string(),
1547            PrincipalTrustState {
1548                trust_tier: TrustTier::Verified,
1549                key_ids: BTreeSet::from(["key_observed".to_string()]),
1550            },
1551        );
1552
1553        let diff = current.diff_against_restore(&restored);
1554
1555        assert_eq!(diff.severity(), SemanticSeverity::PreconditionUnmet);
1556        assert_eq!(diff.changes.len(), 2);
1557        assert!(diff.changes.iter().any(|change| matches!(
1558            &change.kind,
1559            SemanticChangeKind::TrustTierDowngraded {
1560                id,
1561                current: TrustTier::Operator,
1562                restored: TrustTier::Verified,
1563            } if id == "principal_downgraded"
1564        )));
1565        assert!(diff.changes.iter().any(|change| matches!(
1566            &change.kind,
1567            SemanticChangeKind::TrustTierChanged {
1568                id,
1569                current: TrustTier::Observed,
1570                restored: TrustTier::Verified,
1571            } if id == "principal_upgraded"
1572        )));
1573        assert_eq!(
1574            diff.restore_decision(false),
1575            RestoreDecision::PreconditionUnmet {
1576                blocked_change_count: 1
1577            }
1578        );
1579    }
1580
1581    #[test]
1582    fn semantic_snapshot_extracts_active_store_state() {
1583        let pool = rusqlite::Connection::open_in_memory().expect("open sqlite");
1584        crate::migrate::apply_pending(&pool).expect("migrate");
1585        pool.execute(
1586            "INSERT INTO memories (
1587                id, memory_type, status, claim, source_episodes_json, source_events_json,
1588                domains_json, salience_json, confidence, authority, applies_when_json,
1589                does_not_apply_when_json, created_at, updated_at
1590            ) VALUES (
1591                'mem_snapshot', 'semantic', 'active', 'Snapshot extraction is read-only.',
1592                '[]', '[\"evt_snapshot\"]', '[]', '{\"score\":0.8}', 0.7, 'verified',
1593                '[]', '[]', '2026-05-04T12:00:00Z', '2026-05-04T12:00:00Z'
1594            );",
1595            [],
1596        )
1597        .expect("insert memory");
1598        pool.execute(
1599            "INSERT INTO principles (
1600                id, statement, status, supporting_memories_json, contradicting_memories_json,
1601                domains_observed_json, applies_when_json, does_not_apply_when_json,
1602                confidence, validation, brightness, created_by_json, created_at, updated_at
1603            ) VALUES (
1604                'prn_snapshot', 'Snapshot extraction must not mutate restore state.',
1605                'promoted_to_doctrine', '[\"mem_snapshot\"]', '[]', '[]', '[]', '[]',
1606                0.8, 0.8, 0.8, '{}', '2026-05-04T12:00:00Z', '2026-05-04T12:00:00Z'
1607            );",
1608            [],
1609        )
1610        .expect("insert principle");
1611        pool.execute(
1612            "INSERT INTO doctrine (
1613                id, source_principle, rule, force, promotion_reason, promoted_by_json, created_at
1614            ) VALUES (
1615                'doc_snapshot', 'prn_snapshot', 'Snapshot extraction must not mutate restore state.',
1616                'Gate', 'test', '{}', '2026-05-04T12:00:00Z'
1617            );",
1618            [],
1619        )
1620        .expect("insert doctrine");
1621        pool.execute(
1622            "INSERT INTO contradictions (
1623                id, left_ref, right_ref, contradiction_type, status, interpretation, created_at, updated_at
1624            ) VALUES (
1625                'ctr_snapshot', 'mem:a', 'mem:b', 'conflicting_causal_claim',
1626                'unresolved', NULL, '2026-05-04T12:00:00Z', '2026-05-04T12:00:00Z'
1627            );",
1628            [],
1629        )
1630        .expect("insert contradiction");
1631        pool.execute(
1632            "INSERT INTO authority_principal_timeline (
1633                principal_id, trust_tier, effective_at, trust_review_due_at, removed_at, audit_ref
1634            ) VALUES (
1635                'operator', 'operator', '2026-05-04T12:00:00Z', NULL, NULL, NULL
1636            );",
1637            [],
1638        )
1639        .expect("insert principal");
1640        pool.execute(
1641            "INSERT INTO authority_key_timeline (
1642                key_id, principal_id, state, effective_at, reason, audit_ref
1643            ) VALUES (
1644                'key_operator', 'operator', 'active', '2026-05-04T12:00:00Z', NULL, NULL
1645            );",
1646            [],
1647        )
1648        .expect("insert key");
1649
1650        let snapshot = semantic_snapshot_from_store(&pool).expect("extract snapshot");
1651
1652        assert_eq!(snapshot.snapshot_id.as_deref(), Some("store-current"));
1653        assert_eq!(
1654            snapshot.active_memories["mem_snapshot"].truth_state,
1655            TruthState::Validated
1656        );
1657        assert_eq!(
1658            snapshot.active_memories["mem_snapshot"].salience,
1659            SalienceScore(80)
1660        );
1661        assert_eq!(snapshot.salience_distribution.high, 1);
1662        assert_eq!(
1663            snapshot.active_principles["prn_snapshot"].supporting_memory_ids,
1664            BTreeSet::from(["mem_snapshot".to_string()])
1665        );
1666        assert_eq!(
1667            snapshot.active_doctrine["doc_snapshot"].force,
1668            DoctrineForce::Gate
1669        );
1670        assert!(snapshot
1671            .unresolved_contradictions
1672            .contains_key("ctr_snapshot"));
1673        assert_eq!(
1674            snapshot.trust_state.principals["operator"].trust_tier,
1675            TrustTier::Operator
1676        );
1677        assert_eq!(
1678            snapshot.trust_state.keys["key_operator"].key_state,
1679            KeyState::Active
1680        );
1681    }
1682
1683    fn base_snapshot() -> SemanticSnapshot {
1684        SemanticSnapshot {
1685            snapshot_id: Some("snap".to_string()),
1686            schema_version: Some(2),
1687            active_principles: BTreeMap::new(),
1688            active_doctrine: BTreeMap::new(),
1689            active_memories: BTreeMap::new(),
1690            salience_distribution: SalienceDistribution {
1691                low: 1,
1692                medium: 1,
1693                high: 0,
1694                critical: 0,
1695            },
1696            unresolved_contradictions: BTreeMap::new(),
1697            trust_state: TrustKeyState::default(),
1698            truth_ceiling: TruthCeilingState {
1699                runtime_mode: RuntimeMode::SignedLocalLedger,
1700                proof_state: ProofState::FullChainVerified,
1701                claim_ceiling: TruthCeiling::ObservedLocal,
1702                per_claim: BTreeMap::new(),
1703            },
1704        }
1705    }
1706}