Skip to main content

remem/truth/
visibility.rs

1//! Deterministic read-only trust/visibility classification for memories.
2use anyhow::{Context, Result};
3use rusqlite::{params_from_iter, Connection, OptionalExtension, Row as SqlRow};
4use serde::Serialize;
5use std::collections::{BTreeSet, HashMap};
6pub const CURRENT_CONFIDENCE_FLOOR: f64 = 0.80;
7/// SQLite caps bound parameters per statement; stay well under it so a large
8/// context load still classifies in a handful of statements.
9const VISIBILITY_BATCH_CHUNK_SIZE: usize = 900;
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
11#[serde(rename_all = "snake_case")]
12pub enum MemoryVisibilityClass {
13    Current,
14    LegacyUnverified,
15    Quarantined,
16    Expired,
17    Superseded,
18    NotYetValid,
19    Inactive,
20}
21impl MemoryVisibilityClass {
22    pub const fn as_str(self) -> &'static str {
23        match self {
24            Self::Current => "current",
25            Self::LegacyUnverified => "legacy_unverified",
26            Self::Quarantined => "quarantined",
27            Self::Expired => "expired",
28            Self::Superseded => "superseded",
29            Self::NotYetValid => "not_yet_valid",
30            Self::Inactive => "inactive",
31        }
32    }
33}
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
35#[serde(rename_all = "snake_case")]
36pub enum MemoryVisibilityReason {
37    CurrentEligible,
38    StatusQuarantined,
39    ValidityExpired,
40    StatusSuperseded,
41    ValidityNotYetStarted,
42    StatusInactive,
43    ProvenanceMissing,
44    ProvenanceMalformed,
45    ConfidenceMissing,
46    ConfidenceBelowFloor,
47    ValidityStartMissing,
48    MutableStateIdentityMissing,
49    RowMissing,
50}
51impl MemoryVisibilityReason {
52    pub const fn as_str(self) -> &'static str {
53        match self {
54            Self::CurrentEligible => "current_eligible",
55            Self::StatusQuarantined => "status_quarantined",
56            Self::ValidityExpired => "validity_expired",
57            Self::StatusSuperseded => "status_superseded",
58            Self::ValidityNotYetStarted => "validity_not_yet_started",
59            Self::StatusInactive => "status_inactive",
60            Self::ProvenanceMissing => "legacy_unverified_provenance_missing",
61            Self::ProvenanceMalformed => "legacy_unverified_provenance_malformed",
62            Self::ConfidenceMissing => "legacy_unverified_confidence_missing",
63            Self::ConfidenceBelowFloor => "legacy_unverified_confidence_below_floor",
64            Self::ValidityStartMissing => "legacy_unverified_validity_start_missing",
65            Self::MutableStateIdentityMissing => "legacy_unverified_mutable_state_identity_missing",
66            Self::RowMissing => "legacy_unverified_row_missing",
67        }
68    }
69}
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
71pub struct MemoryVisibility {
72    pub classification: MemoryVisibilityClass,
73    pub reason: MemoryVisibilityReason,
74    pub current_context_eligible: bool,
75}
76impl MemoryVisibility {
77    fn excluded(classification: MemoryVisibilityClass, reason: MemoryVisibilityReason) -> Self {
78        Self {
79            classification,
80            reason,
81            current_context_eligible: false,
82        }
83    }
84
85    /// True when a row the classifier excluded was admitted anyway because the
86    /// gate is running in shadow mode. Consumers use this to report what
87    /// enforcement *would* have dropped.
88    pub fn admitted_by_shadow_mode(&self) -> bool {
89        self.current_context_eligible && self.classification != MemoryVisibilityClass::Current
90    }
91}
92
93/// Rollout control for the G2 current-context gate.
94///
95/// Enforcement changes which memories reach live context, and on databases with
96/// a large pre-provenance history it can exclude nearly the whole active set. An
97/// operator needs a way to measure that before and after flipping it, and a way
98/// back if injection collapses in production.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum CurrentContextGateMode {
101    /// Classify and exclude non-current rows. Default.
102    Enforce,
103    /// Classify and report, but still admit rows that are only excluded for
104    /// `legacy_unverified` reasons.
105    ///
106    /// Lifecycle exclusions (quarantined, expired, superseded, not-yet-valid,
107    /// inactive) are pre-existing security and correctness boundaries, not part
108    /// of this rollout, so shadow mode never relaxes them.
109    Shadow,
110}
111
112pub const CURRENT_CONTEXT_GATE_ENV: &str = "REMEM_CURRENT_CONTEXT_GATE";
113
114/// Read the gate mode from [`CURRENT_CONTEXT_GATE_ENV`].
115///
116/// Unset or unrecognized values fail closed to [`CurrentContextGateMode::Enforce`].
117pub fn current_context_gate_mode() -> CurrentContextGateMode {
118    match std::env::var(CURRENT_CONTEXT_GATE_ENV)
119        .ok()
120        .as_deref()
121        .map(str::trim)
122    {
123        Some("shadow") => CurrentContextGateMode::Shadow,
124        _ => CurrentContextGateMode::Enforce,
125    }
126}
127
128fn apply_gate_mode(visibility: MemoryVisibility) -> MemoryVisibility {
129    if visibility.current_context_eligible
130        || visibility.classification != MemoryVisibilityClass::LegacyUnverified
131        || current_context_gate_mode() != CurrentContextGateMode::Shadow
132    {
133        return visibility;
134    }
135    MemoryVisibility {
136        current_context_eligible: true,
137        ..visibility
138    }
139}
140#[derive(Clone)]
141struct Row {
142    status: String,
143    memory_type: String,
144    topic_key: Option<String>,
145    source_candidate_id: Option<i64>,
146    evidence_event_ids: Option<String>,
147    source_trust_class: Option<String>,
148    confidence: Option<f64>,
149    valid_from_epoch: Option<i64>,
150    valid_to_epoch: Option<i64>,
151    expires_at_epoch: Option<i64>,
152    state_key_id: Option<i64>,
153    lesson_has_proof: bool,
154    candidate_has_proof: bool,
155    direct_evidence_resolves: bool,
156    state_key_resolves: bool,
157}
158const VISIBILITY_PROJECTION_SQL: &str =
159    "SELECT id, status, memory_type, topic_key, source_candidate_id, evidence_event_ids,
160                source_trust_class,
161                COALESCE(confidence, (
162                    SELECT candidate.confidence FROM memory_candidates candidate
163                    WHERE candidate.id = memories.source_candidate_id
164                      AND candidate.review_status IN ('accepted', 'approved', 'auto_promoted')
165                )),
166                COALESCE(valid_from_epoch, (
167                    SELECT candidate.created_at_epoch FROM memory_candidates candidate
168                    WHERE candidate.id = memories.source_candidate_id
169                      AND candidate.review_status IN ('accepted', 'approved', 'auto_promoted')
170                )), valid_to_epoch,
171                expires_at_epoch, state_key_id,
172                EXISTS(
173                    SELECT 1 FROM memory_lessons lesson
174                    WHERE lesson.memory_id = memories.id
175                      AND lesson.confidence >= 0.80
176                      AND trim(COALESCE(lesson.source_evidence, '')) <> ''
177                ),
178                EXISTS(
179                    SELECT 1 FROM memory_candidates candidate
180                    WHERE candidate.id = memories.source_candidate_id
181                      AND candidate.review_status IN ('accepted', 'approved', 'auto_promoted')
182                      AND CASE WHEN json_valid(candidate.evidence_event_ids) THEN
183                          json_array_length(candidate.evidence_event_ids) > 0
184                          AND NOT EXISTS (
185                              SELECT 1 FROM json_each(candidate.evidence_event_ids) evidence
186                              LEFT JOIN captured_events event ON event.id = evidence.value
187                              WHERE evidence.type <> 'integer' OR event.id IS NULL
188                          )
189                      ELSE 0 END
190                ),
191                CASE
192                    WHEN evidence_event_ids IS NULL THEN 1
193                    WHEN json_valid(evidence_event_ids) THEN
194                        json_array_length(evidence_event_ids) > 0
195                        AND NOT EXISTS (
196                            SELECT 1 FROM json_each(evidence_event_ids) evidence
197                            LEFT JOIN captured_events event ON event.id = evidence.value
198                            WHERE evidence.type <> 'integer' OR event.id IS NULL
199                        )
200                    ELSE 0
201                END,
202                EXISTS(SELECT 1 FROM memory_state_keys state_key
203                       WHERE state_key.id = memories.state_key_id
204                         AND (memories.owner_scope IS NULL
205                              OR state_key.owner_scope = memories.owner_scope)
206                         AND state_key.owner_key = COALESCE(memories.owner_key, memories.project)
207                         AND state_key.memory_type = memories.memory_type)
208         FROM memories WHERE id IN";
209
210fn read_visibility_row(row: &SqlRow<'_>) -> rusqlite::Result<Row> {
211    Ok(Row {
212        status: row.get(1)?,
213        memory_type: row.get(2)?,
214        topic_key: row.get(3)?,
215        source_candidate_id: row.get(4)?,
216        evidence_event_ids: row.get(5)?,
217        source_trust_class: row.get(6)?,
218        confidence: row.get(7)?,
219        valid_from_epoch: row.get(8)?,
220        valid_to_epoch: row.get(9)?,
221        expires_at_epoch: row.get(10)?,
222        state_key_id: row.get(11)?,
223        lesson_has_proof: row.get(12)?,
224        candidate_has_proof: row.get(13)?,
225        direct_evidence_resolves: row.get(14)?,
226        state_key_resolves: row.get(15)?,
227    })
228}
229
230fn missing_row_visibility() -> MemoryVisibility {
231    MemoryVisibility::excluded(
232        MemoryVisibilityClass::LegacyUnverified,
233        MemoryVisibilityReason::RowMissing,
234    )
235}
236
237/// Classify many memories in chunked statements.
238///
239/// Every requested id is present in the result. Ids with no `memories` row stay
240/// at the fail-closed `RowMissing` default rather than being omitted, so callers
241/// cannot mistake an absent key for an admitted memory.
242pub fn classify_memories(
243    conn: &Connection,
244    memory_ids: &[i64],
245    as_of_epoch: i64,
246) -> Result<HashMap<i64, MemoryVisibility>> {
247    let ids = memory_ids.iter().copied().collect::<BTreeSet<_>>();
248    let mut classifications = ids
249        .iter()
250        .map(|id| (*id, missing_row_visibility()))
251        .collect::<HashMap<_, _>>();
252    for chunk in ids
253        .iter()
254        .copied()
255        .collect::<Vec<_>>()
256        .chunks(VISIBILITY_BATCH_CHUNK_SIZE)
257    {
258        let placeholders = std::iter::repeat_n("?", chunk.len())
259            .collect::<Vec<_>>()
260            .join(",");
261        let sql = format!("{VISIBILITY_PROJECTION_SQL} ({placeholders})");
262        let mut statement = conn
263            .prepare(&sql)
264            .context("prepare batch memory trust and visibility classification")?;
265        let rows = statement
266            .query_map(params_from_iter(chunk.iter()), |row| {
267                Ok((row.get::<_, i64>(0)?, read_visibility_row(row)?))
268            })
269            .context("query batch memory trust and visibility classification")?;
270        for row in rows {
271            let (id, visibility_row) =
272                row.context("read batch memory trust and visibility classification row")?;
273            classifications.insert(id, classify_row(&visibility_row, as_of_epoch));
274        }
275    }
276    Ok(classifications)
277}
278
279pub fn classify_memory(
280    conn: &Connection,
281    memory_id: i64,
282    as_of_epoch: i64,
283) -> Result<MemoryVisibility> {
284    Ok(classify_memories(conn, &[memory_id], as_of_epoch)?
285        .remove(&memory_id)
286        .unwrap_or_else(missing_row_visibility))
287}
288
289/// Shared G2 gate for every current-context reader.
290///
291/// SessionStart, UserPromptSubmit, `current_state`, and recall must call this
292/// instead of treating `status='active'` as current.
293/// Honors [`current_context_gate_mode`]; `classify_memory` keeps reporting the
294/// unmodified classification for search, detail, inventory, and doctor.
295pub fn admit_for_current_context(
296    conn: &Connection,
297    memory_id: i64,
298    as_of_epoch: i64,
299) -> Result<MemoryVisibility> {
300    Ok(apply_gate_mode(classify_memory(
301        conn,
302        memory_id,
303        as_of_epoch,
304    )?))
305}
306
307/// Batched form of [`admit_for_current_context`] for readers that gate a whole
308/// candidate set, so a context load costs a few statements instead of one per
309/// memory.
310pub fn admit_many_for_current_context(
311    conn: &Connection,
312    memory_ids: &[i64],
313    as_of_epoch: i64,
314) -> Result<HashMap<i64, MemoryVisibility>> {
315    Ok(classify_memories(conn, memory_ids, as_of_epoch)?
316        .into_iter()
317        .map(|(id, visibility)| (id, apply_gate_mode(visibility)))
318        .collect())
319}
320
321/// G2 admission for a row whose historical lifecycle window has already been
322/// selected by the caller. Stored `stale`/`archived` status must not erase an
323/// otherwise proven claim that was current at the requested epoch.
324pub fn admit_for_historical_context(
325    conn: &Connection,
326    memory_id: i64,
327    as_of_epoch: i64,
328) -> Result<MemoryVisibility> {
329    let sql = format!("{VISIBILITY_PROJECTION_SQL} (?1)");
330    let row = conn
331        .query_row(&sql, [memory_id], read_visibility_row)
332        .optional()?
333        .ok_or_else(|| anyhow::anyhow!("memory visibility row missing for id={memory_id}"))?;
334    let mut historical = row.clone();
335    historical.status = "active".to_string();
336    historical.valid_to_epoch = None;
337    historical.expires_at_epoch = None;
338    Ok(apply_gate_mode(classify_row(&historical, as_of_epoch)))
339}
340
341fn classify_row(row: &Row, as_of: i64) -> MemoryVisibility {
342    if row.status == "quarantined" {
343        return MemoryVisibility::excluded(
344            MemoryVisibilityClass::Quarantined,
345            MemoryVisibilityReason::StatusQuarantined,
346        );
347    }
348    if row.valid_to_epoch.is_some_and(|v| v <= as_of)
349        || row.expires_at_epoch.is_some_and(|v| v <= as_of)
350    {
351        return MemoryVisibility::excluded(
352            MemoryVisibilityClass::Expired,
353            MemoryVisibilityReason::ValidityExpired,
354        );
355    }
356    if row.status == "superseded" {
357        return MemoryVisibility::excluded(
358            MemoryVisibilityClass::Superseded,
359            MemoryVisibilityReason::StatusSuperseded,
360        );
361    }
362    if row.valid_from_epoch.is_some_and(|v| v > as_of) {
363        return MemoryVisibility::excluded(
364            MemoryVisibilityClass::NotYetValid,
365            MemoryVisibilityReason::ValidityNotYetStarted,
366        );
367    }
368    if row.status != "active" {
369        return MemoryVisibility::excluded(
370            MemoryVisibilityClass::Inactive,
371            MemoryVisibilityReason::StatusInactive,
372        );
373    }
374    let direct_user = row.source_trust_class.as_deref() == Some("user_prompt");
375    let explicit_writer_proof = direct_user || row.lesson_has_proof;
376    if !explicit_writer_proof {
377        if row.source_candidate_id.is_none() && row.evidence_event_ids.is_none() {
378            return MemoryVisibility::excluded(
379                MemoryVisibilityClass::LegacyUnverified,
380                MemoryVisibilityReason::ProvenanceMissing,
381            );
382        }
383        let valid_evidence = row.evidence_event_ids.as_deref().is_none_or(|raw| {
384            serde_json::from_str::<Vec<i64>>(raw)
385                .ok()
386                .is_some_and(|ids| !ids.is_empty() && ids.into_iter().all(|id| id > 0))
387        });
388        if !valid_evidence {
389            return MemoryVisibility::excluded(
390                MemoryVisibilityClass::LegacyUnverified,
391                MemoryVisibilityReason::ProvenanceMalformed,
392            );
393        }
394        if row.source_candidate_id.is_some() && !row.candidate_has_proof {
395            return MemoryVisibility::excluded(
396                MemoryVisibilityClass::LegacyUnverified,
397                MemoryVisibilityReason::ProvenanceMalformed,
398            );
399        }
400        if row.evidence_event_ids.is_some() && !row.direct_evidence_resolves {
401            return MemoryVisibility::excluded(
402                MemoryVisibilityClass::LegacyUnverified,
403                MemoryVisibilityReason::ProvenanceMalformed,
404            );
405        }
406        let Some(confidence) = row.confidence else {
407            return MemoryVisibility::excluded(
408                MemoryVisibilityClass::LegacyUnverified,
409                MemoryVisibilityReason::ConfidenceMissing,
410            );
411        };
412        if !confidence.is_finite() || confidence < CURRENT_CONFIDENCE_FLOOR {
413            return MemoryVisibility::excluded(
414                MemoryVisibilityClass::LegacyUnverified,
415                MemoryVisibilityReason::ConfidenceBelowFloor,
416            );
417        }
418        if row.valid_from_epoch.is_none() {
419            return MemoryVisibility::excluded(
420                MemoryVisibilityClass::LegacyUnverified,
421                MemoryVisibilityReason::ValidityStartMissing,
422            );
423        }
424    }
425    let mutable = matches!(
426        row.memory_type.as_str(),
427        "decision" | "architecture" | "preference"
428    ) && row
429        .topic_key
430        .as_deref()
431        .is_some_and(|key| !key.trim().is_empty());
432    if !direct_user && mutable && (row.state_key_id.is_none() || !row.state_key_resolves) {
433        return MemoryVisibility::excluded(
434            MemoryVisibilityClass::LegacyUnverified,
435            MemoryVisibilityReason::MutableStateIdentityMissing,
436        );
437    }
438    MemoryVisibility {
439        classification: MemoryVisibilityClass::Current,
440        reason: MemoryVisibilityReason::CurrentEligible,
441        current_context_eligible: true,
442    }
443}
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    /// Holds the shared process env lock while overriding the gate mode, so
449    /// these tests cannot race other env-sensitive tests in the same binary.
450    struct GateModeEnv {
451        _lock: crate::runtime_config::TestEnvGuard,
452        previous: Option<String>,
453    }
454
455    impl GateModeEnv {
456        fn set(value: Option<&str>) -> Self {
457            let lock = crate::runtime_config::ENV_LOCK
458                .lock()
459                .expect("env lock should acquire");
460            let previous = std::env::var(CURRENT_CONTEXT_GATE_ENV).ok();
461            apply(value);
462            Self {
463                _lock: lock,
464                previous,
465            }
466        }
467    }
468
469    impl Drop for GateModeEnv {
470        fn drop(&mut self) {
471            apply(self.previous.take().as_deref());
472        }
473    }
474
475    fn apply(value: Option<&str>) {
476        match value {
477            Some(value) => unsafe { std::env::set_var(CURRENT_CONTEXT_GATE_ENV, value) },
478            None => unsafe { std::env::remove_var(CURRENT_CONTEXT_GATE_ENV) },
479        }
480    }
481
482    fn row() -> Row {
483        Row {
484            status: "active".into(),
485            memory_type: "bugfix".into(),
486            topic_key: None,
487            source_candidate_id: Some(1),
488            evidence_event_ids: Some("[2]".into()),
489            source_trust_class: Some("local_tool_output".into()),
490            confidence: Some(0.9),
491            valid_from_epoch: Some(10),
492            valid_to_epoch: None,
493            expires_at_epoch: None,
494            state_key_id: None,
495            lesson_has_proof: false,
496            candidate_has_proof: true,
497            direct_evidence_resolves: true,
498            state_key_resolves: true,
499        }
500    }
501    #[test]
502    fn unknown_proof_fails_closed() {
503        let mut value = row();
504        value.confidence = None;
505        assert_eq!(
506            classify_row(&value, 20).reason.as_str(),
507            "legacy_unverified_confidence_missing"
508        );
509    }
510    #[test]
511    fn lifecycle_precedes_proof_and_current_rows_survive() {
512        assert!(classify_row(&row(), 20).current_context_eligible);
513        let mut value = row();
514        value.status = "quarantined".into();
515        value.source_candidate_id = None;
516        assert_eq!(
517            classify_row(&value, 20).classification,
518            MemoryVisibilityClass::Quarantined
519        );
520    }
521
522    #[test]
523    fn lifecycle_collision_order_and_future_validity_are_stable() {
524        let mut value = row();
525        value.status = "superseded".into();
526        value.expires_at_epoch = Some(20);
527        assert_eq!(
528            classify_row(&value, 20).classification,
529            MemoryVisibilityClass::Expired
530        );
531        value.expires_at_epoch = None;
532        value.valid_from_epoch = Some(21);
533        assert_eq!(
534            classify_row(&value, 20).reason,
535            MemoryVisibilityReason::StatusSuperseded
536        );
537        value.status = "active".into();
538        assert_eq!(
539            classify_row(&value, 20).reason,
540            MemoryVisibilityReason::ValidityNotYetStarted
541        );
542    }
543    #[test]
544    fn direct_user_proof_preserves_compatibility() {
545        let mut value = row();
546        value.source_trust_class = Some("user_prompt".into());
547        value.source_candidate_id = None;
548        value.evidence_event_ids = None;
549        value.confidence = None;
550        value.valid_from_epoch = None;
551        assert!(classify_row(&value, 20).current_context_eligible);
552    }
553
554    #[test]
555    fn direct_user_preference_with_writer_state_identity_is_current() {
556        let mut value = row();
557        value.memory_type = "preference".into();
558        value.topic_key = Some("manual-formatting".into());
559        value.state_key_id = None;
560        value.source_trust_class = Some("user_prompt".into());
561        value.source_candidate_id = None;
562        value.evidence_event_ids = None;
563        value.confidence = None;
564        value.valid_from_epoch = None;
565        assert!(classify_row(&value, 20).current_context_eligible);
566    }
567
568    #[test]
569    fn shadow_mode_admits_legacy_unverified_but_never_relaxes_lifecycle() -> Result<()> {
570        let conn = Connection::open_in_memory()?;
571        crate::migrate::run_migrations(&conn)?;
572        // id=1 is excluded only for missing provenance; id=2 is quarantined.
573        conn.execute(
574            "INSERT INTO memories
575             (id, project, title, content, memory_type, created_at_epoch,
576              updated_at_epoch, status, source_trust_class)
577             VALUES (1, '/repo', 'legacy', 'body', 'bugfix',
578                     1, 1, 'active', 'local_tool_output')",
579            [],
580        )?;
581        conn.execute(
582            "INSERT INTO memories
583             (id, project, title, content, memory_type, created_at_epoch,
584              updated_at_epoch, status, source_trust_class)
585             VALUES (2, '/repo', 'poisoned', 'body', 'bugfix',
586                     1, 1, 'quarantined', 'user_prompt')",
587            [],
588        )?;
589
590        let _guard = GateModeEnv::set(Some("shadow"));
591
592        let legacy = admit_for_current_context(&conn, 1, 2)?;
593        assert!(
594            legacy.current_context_eligible,
595            "shadow mode must admit legacy-unverified rows so injection can be measured"
596        );
597        assert_eq!(
598            legacy.classification,
599            MemoryVisibilityClass::LegacyUnverified,
600            "shadow mode must preserve the real classification for reporting"
601        );
602        assert!(legacy.admitted_by_shadow_mode());
603
604        let quarantined = admit_for_current_context(&conn, 2, 2)?;
605        assert!(
606            !quarantined.current_context_eligible,
607            "shadow mode must not relax the quarantine security boundary"
608        );
609        assert!(!quarantined.admitted_by_shadow_mode());
610
611        // classify_* reports the unmodified truth regardless of gate mode.
612        assert!(!classify_memory(&conn, 1, 2)?.current_context_eligible);
613
614        let batched = admit_many_for_current_context(&conn, &[1, 2], 2)?;
615        assert!(batched[&1].current_context_eligible);
616        assert!(!batched[&2].current_context_eligible);
617        Ok(())
618    }
619
620    #[test]
621    fn gate_mode_defaults_to_enforce_for_unset_and_unknown_values() {
622        for value in [None, Some("yes-please-disable"), Some("off"), Some("")] {
623            let _guard = GateModeEnv::set(value);
624            assert_eq!(
625                current_context_gate_mode(),
626                CurrentContextGateMode::Enforce,
627                "only the documented 'shadow' value may relax the gate, got {value:?}"
628            );
629        }
630        let _shadow = GateModeEnv::set(Some(" shadow "));
631        assert_eq!(
632            current_context_gate_mode(),
633            CurrentContextGateMode::Shadow,
634            "surrounding whitespace should not defeat the documented value"
635        );
636    }
637
638    #[test]
639    fn batch_classification_matches_single_rows_and_marks_missing() -> Result<()> {
640        let conn = Connection::open_in_memory()?;
641        crate::migrate::run_migrations(&conn)?;
642        conn.execute(
643            "INSERT INTO memories
644             (id, project, title, content, memory_type, created_at_epoch,
645              updated_at_epoch, status, source_trust_class)
646             VALUES (1, '/repo', 'direct user', 'body', 'bugfix',
647                     1, 1, 'active', 'user_prompt')",
648            [],
649        )?;
650        conn.execute(
651            "INSERT INTO memories
652             (id, project, title, content, memory_type, created_at_epoch,
653              updated_at_epoch, status, source_trust_class)
654             VALUES (2, '/repo', 'legacy', 'body', 'bugfix',
655                     1, 1, 'active', 'local_tool_output')",
656            [],
657        )?;
658
659        let classifications = classify_memories(&conn, &[1, 2, 99, 1], 2)?;
660
661        assert_eq!(classifications.len(), 3);
662        assert_eq!(classifications[&1], classify_memory(&conn, 1, 2)?);
663        assert!(classifications[&1].current_context_eligible);
664        assert_eq!(
665            classifications[&2],
666            classify_memory(&conn, 2, 2)?,
667            "batch and single-row paths must agree on excluded rows"
668        );
669        assert_eq!(
670            classifications[&2].reason,
671            MemoryVisibilityReason::ProvenanceMissing
672        );
673        assert_eq!(
674            classifications[&99].reason,
675            MemoryVisibilityReason::RowMissing
676        );
677        assert!(!classifications[&99].current_context_eligible);
678        Ok(())
679    }
680
681    #[test]
682    fn auto_promoted_candidate_is_valid_writer_proof() -> Result<()> {
683        let conn = Connection::open_in_memory()?;
684        crate::migrate::run_migrations(&conn)?;
685        conn.execute(
686            "INSERT INTO memories
687             (id, project, title, content, memory_type, created_at_epoch,
688              updated_at_epoch, status, source_trust_class)
689             VALUES (1, '/repo', 'automatic capture', 'body', 'bugfix',
690                     1, 1, 'active', 'local_tool_output')",
691            [],
692        )?;
693        crate::truth::test_support::seed_current_memory_proof(&conn, 1)?;
694        conn.execute(
695            "UPDATE memory_candidates SET review_status = 'auto_promoted'
696             WHERE id = (SELECT source_candidate_id FROM memories WHERE id = 1)",
697            [],
698        )?;
699        assert!(classify_memory(&conn, 1, 2)?.current_context_eligible);
700        Ok(())
701    }
702
703    #[test]
704    fn dangling_candidate_and_event_references_fail_closed() -> Result<()> {
705        let conn = Connection::open_in_memory()?;
706        crate::migrate::run_migrations(&conn)?;
707        conn.execute(
708            "INSERT INTO memories
709             (id, project, title, content, memory_type, created_at_epoch,
710              updated_at_epoch, status, source_candidate_id, confidence, valid_from_epoch)
711             VALUES (1, '/repo', 'dangling candidate', 'body', 'bugfix',
712                     1, 1, 'active', 999, 0.9, 1)",
713            [],
714        )?;
715        conn.execute(
716            "INSERT INTO memories
717             (id, project, title, content, memory_type, created_at_epoch,
718              updated_at_epoch, status, evidence_event_ids, confidence, valid_from_epoch)
719             VALUES (2, '/repo', 'dangling event', 'body', 'bugfix',
720                     1, 1, 'active', '[999]', 0.9, 1)",
721            [],
722        )?;
723
724        for id in [1, 2] {
725            let visibility = classify_memory(&conn, id, 2)?;
726            assert_eq!(
727                visibility.reason,
728                MemoryVisibilityReason::ProvenanceMalformed
729            );
730            assert!(!visibility.current_context_eligible);
731        }
732        Ok(())
733    }
734
735    #[test]
736    fn syntactically_malformed_candidate_and_direct_evidence_fail_closed() -> Result<()> {
737        let conn = Connection::open_in_memory()?;
738        crate::migrate::run_migrations(&conn)?;
739        conn.execute(
740            "INSERT INTO memory_candidates
741             (id, scope, memory_type, topic_key, text, evidence_event_ids, confidence,
742              risk_class, review_status, created_at_epoch, updated_at_epoch)
743             VALUES (1, 'project', 'bugfix', 'malformed-proof', 'candidate', 'not-json', 0.9,
744                     'low', 'accepted', 1, 1)",
745            [],
746        )?;
747        conn.execute(
748            "INSERT INTO memories
749             (id, project, title, content, memory_type, created_at_epoch,
750              updated_at_epoch, status, source_candidate_id)
751             VALUES (1, '/repo', 'bad candidate evidence', 'body', 'bugfix',
752                     1, 1, 'active', 1)",
753            [],
754        )?;
755        conn.execute(
756            "INSERT INTO memories
757             (id, project, title, content, memory_type, created_at_epoch,
758              updated_at_epoch, status, evidence_event_ids, confidence, valid_from_epoch)
759             VALUES (2, '/repo', 'bad direct evidence', 'body', 'bugfix',
760                     1, 1, 'active', 'not-json', 0.9, 1)",
761            [],
762        )?;
763
764        for id in [1, 2] {
765            let visibility = classify_memory(&conn, id, 2)?;
766            assert_eq!(
767                visibility.reason,
768                MemoryVisibilityReason::ProvenanceMalformed
769            );
770            assert!(!visibility.current_context_eligible);
771        }
772        Ok(())
773    }
774}