Skip to main content

cortex_store/repo/
memories.rs

1//! Memory repository operations.
2//!
3//! ADR 0038 makes durable memory admission a policy-bearing surface, and
4//! ADR 0026 §2 requires every mutation of that surface to compose through the
5//! policy lattice. The candidate -> active transition
6//! ([`MemoryRepo::accept_candidate`]) and the schema-v2 summary-span / salience
7//! opt-in write ([`MemoryRepo::insert_candidate_with_v2_fields`]) both require
8//! the caller to pass a composed [`PolicyDecision`] whose contributing rules
9//! name the ADR-level invariants being asserted.
10//!
11//! Required contributor rule ids:
12//!
13//! - [`ACCEPT_PROOF_CLOSURE_RULE_ID`], [`ACCEPT_OPEN_CONTRADICTION_RULE_ID`],
14//!   [`ACCEPT_SEMANTIC_TRUST_RULE_ID`], and
15//!   [`ACCEPT_OPERATOR_TEMPORAL_USE_RULE_ID`] for
16//!   [`MemoryRepo::accept_candidate`].
17//! - [`V2_SUMMARY_SPAN_PROOF_RULE_ID`] and
18//!   [`V2_CROSS_SESSION_SALIENCE_RULE_ID`] for
19//!   [`MemoryRepo::insert_candidate_with_v2_fields`].
20//!
21//! The default [`MemoryRepo::insert_candidate`] entry point remains the policy
22//! surface for non-summary-bearing candidates: the lifecycle layer
23//! (`cortex_memory::lifecycle::accept_candidate`) already composes the AXIOM
24//! admission envelope before that store call (punch-list slices #1/#2/#3).
25//!
26//! The v1-named [`MemoryRepo::insert_candidate`] stays the default after the
27//! schema-v2 cutover. `insert_candidate_with_v2_fields` is the explicit opt-in
28//! for memories that carry summary spans or cross-session salience metadata —
29//! making it the default would force every non-summary caller to fabricate
30//! empty span vectors and zero-salience records, which is strictly higher
31//! blast radius than keeping the opt-in shape.
32
33use chrono::{DateTime, Utc};
34use cortex_core::{
35    validate_summary_spans, AuditRecordId, CrossSessionSalience, EventId, MemoryId,
36    OutcomeMemoryRelation, PolicyContribution, PolicyDecision, PolicyOutcome, ProofClosureReport,
37    ProofState, SourceAuthority, SummarySpan, TemporalAuthorityReport,
38};
39use rusqlite::{params, OptionalExtension, Row};
40use serde_json::Value;
41
42use crate::{Pool, StoreError, StoreResult};
43
44/// Required contributor rule id documenting that supporting-memory proof
45/// closure composed into the candidate -> active acceptance decision
46/// (ADR 0021, ADR 0036, ADR 0026 §5). `PARTIAL` / `BROKEN` proof state on
47/// supporting rows MUST surface as `Reject` or `Quarantine`.
48pub const ACCEPT_PROOF_CLOSURE_RULE_ID: &str = "memory.accept.proof_closure";
49/// Required contributor rule id documenting that the open-durable-contradiction
50/// scan composed into the candidate -> active acceptance decision (ADR 0024
51/// `ConflictUnresolved`, ADR 0026 §5). Unresolved open contradictions over the
52/// candidate's claim slot MUST surface as `Reject` or `Quarantine`.
53pub const ACCEPT_OPEN_CONTRADICTION_RULE_ID: &str = "memory.accept.open_contradiction";
54/// Required contributor rule id documenting the semantic-trust posture for the
55/// candidate (ADR 0019 trust tiering, ADR 0038 AXIOM admission). The contributor
56/// must summarise authority class, redaction status, and evidence class.
57pub const ACCEPT_SEMANTIC_TRUST_RULE_ID: &str = "memory.accept.semantic_trust";
58/// Required contributor rule id documenting that the operator promoting the
59/// candidate currently holds the temporal authority required for the
60/// acceptance write (ADR 0023 §3 current-use, ADR 0026 §4).
61/// `BreakGlass` MUST NOT substitute for this contributor.
62pub const ACCEPT_OPERATOR_TEMPORAL_USE_RULE_ID: &str = "memory.accept.operator_temporal_use";
63
64/// Required contributor rule id documenting that a `Validated`
65/// outcome-memory edge carries an ADR 0020 §6 scoped-validation payload
66/// (`validation_scope`). Phase 2.6 D1 closure: an unscoped `Validated` write
67/// is the on-disk twin of `trust_break_utility_to_truth_laundering_001` and
68/// must fail closed.
69pub const OUTCOME_VALIDATION_SCOPE_RULE_ID: &str = "memory.outcome.validation_scope";
70/// Required contributor rule id documenting that the principal who authored a
71/// `Validated` outcome-memory edge satisfies the ADR 0020 §5 trust-tier
72/// proportionality gate for the memory class. Phase 2.6 D1 closure.
73pub const OUTCOME_VALIDATING_PRINCIPAL_TIER_RULE_ID: &str =
74    "memory.outcome.validating_principal_tier";
75/// Required contributor rule id documenting that a `Validated`
76/// outcome-memory edge cites concrete evidence (ADR 0020 §6 `evidence_ref`)
77/// rather than asserting validation from utility alone.
78pub const OUTCOME_EVIDENCE_REF_RULE_ID: &str = "memory.outcome.evidence_ref";
79
80/// Stable invariant name surfaced when [`MemoryRepo::record_outcome_relation`]
81/// refuses a `Validated` write because the composed [`PolicyDecision`] would
82/// launder utility evidence into truth/validation state. Phase 2.6 D1
83/// closure — the durable twin of the in-memory
84/// `EpistemicError::UtilityCannotUpgradeTruth` guard in
85/// `cortex_memory::epistemic`.
86pub const OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT: &str =
87    "memory.outcome.utility_to_truth_promotion_unauthorized";
88
89/// Threshold above which repeated `record_session_use` calls without a
90/// `Validated` outcome edge become weakly negative on the durable side
91/// (Phase 2.6 D2 closure, ADR 0020 §3). Once
92/// `cross_session_use_count > CROSS_SESSION_USE_WEAK_NEGATIVE_THRESHOLD` for
93/// a memory whose `validation_epoch == 0`, the reconcile path surfaces the
94/// stable invariant
95/// [`CROSS_SESSION_USE_REPEATED_UNVALIDATED_WEAK_NEGATIVE_INVARIANT`]. Five
96/// matches the in-memory threshold used by
97/// `cortex_memory::salience::brightness`.
98pub const CROSS_SESSION_USE_WEAK_NEGATIVE_THRESHOLD: u32 = 5;
99
100/// Stable invariant name surfaced when the durable
101/// `cross_session_use_count` for a memory exceeds
102/// [`CROSS_SESSION_USE_WEAK_NEGATIVE_THRESHOLD`] without a single `Validated`
103/// outcome edge having advanced `validation_epoch`. Phase 2.6 D2 closure —
104/// the durable twin of the ADR 0020 §3 "repeated `Used` without `Validated`
105/// is weakly negative" rule. This is a *signal*, not a refusal: the use
106/// edge still writes, but the reconciled row carries this invariant on its
107/// derived weak-negative state so retrieval scoring and operator review can
108/// observe it.
109pub const CROSS_SESSION_USE_REPEATED_UNVALIDATED_WEAK_NEGATIVE_INVARIANT: &str =
110    "memory.cross_session_use.repeated_unvalidated_weak_negative";
111
112/// Reconciled weak-negative status of a memory's cross-session reuse axis
113/// after [`MemoryRepo::record_session_use`] writes the side table.
114///
115/// Phase 2.6 D2 closure: the in-memory `Salience::use_count` carries a small
116/// penalty in `cortex_memory::salience::brightness`, but that scoring path
117/// operates on an in-memory struct that is unreachable from durable
118/// retrieval. This enum is the durable read-side projection of the same
119/// rule, derived from `(cross_session_use_count, validation_epoch)` on
120/// the memory row.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum CrossSessionWeakNegativeStatus {
123    /// Cross-session reuse is at or below the threshold; no weak-negative
124    /// signal applies.
125    BelowThreshold,
126    /// Cross-session reuse exceeded the threshold but a `Validated`
127    /// outcome edge has advanced `validation_epoch` since admission;
128    /// weak-negative is cancelled by that validation.
129    SuppressedByValidation,
130    /// `cross_session_use_count >
131    /// CROSS_SESSION_USE_WEAK_NEGATIVE_THRESHOLD` AND `validation_epoch ==
132    /// 0`. The stable invariant
133    /// [`CROSS_SESSION_USE_REPEATED_UNVALIDATED_WEAK_NEGATIVE_INVARIANT`]
134    /// fires.
135    WeakNegativeAboveThreshold {
136        /// Cross-session use count that crossed the threshold.
137        cross_session_use_count: u32,
138    },
139}
140
141impl CrossSessionWeakNegativeStatus {
142    /// True when the weak-negative invariant fires for this status.
143    #[must_use]
144    pub const fn is_weak_negative(self) -> bool {
145        matches!(self, Self::WeakNegativeAboveThreshold { .. })
146    }
147
148    /// Returns the stable invariant name when the weak-negative signal fires.
149    #[must_use]
150    pub const fn invariant(self) -> Option<&'static str> {
151        match self {
152            Self::WeakNegativeAboveThreshold { .. } => {
153                Some(CROSS_SESSION_USE_REPEATED_UNVALIDATED_WEAK_NEGATIVE_INVARIANT)
154            }
155            _ => None,
156        }
157    }
158}
159
160/// Derive the cross-session weak-negative status from durable salience fields.
161///
162/// Phase 2.6 D2 closure: the rule mirrors
163/// `cortex_memory::salience::brightness`'s `unvalidated_use_penalty`. A
164/// memory with `cross_session_use_count >
165/// CROSS_SESSION_USE_WEAK_NEGATIVE_THRESHOLD` and `validation_epoch == 0`
166/// fires the stable invariant; any positive `validation_epoch` suppresses
167/// the signal because a `Validated` outcome edge has advanced the
168/// authoritative column (and that edge itself was gated by the ADR 0026 +
169/// ADR 0020 §6 envelope on D1).
170#[must_use]
171pub const fn cross_session_weak_negative_status(
172    cross_session_use_count: u32,
173    validation_epoch: u32,
174) -> CrossSessionWeakNegativeStatus {
175    if cross_session_use_count <= CROSS_SESSION_USE_WEAK_NEGATIVE_THRESHOLD {
176        return CrossSessionWeakNegativeStatus::BelowThreshold;
177    }
178    if validation_epoch > 0 {
179        return CrossSessionWeakNegativeStatus::SuppressedByValidation;
180    }
181    CrossSessionWeakNegativeStatus::WeakNegativeAboveThreshold {
182        cross_session_use_count,
183    }
184}
185
186/// Required contributor rule id documenting that the proposed summary spans
187/// validated against ADR 0015 (range/UTF-8 alignment, non-whitespace coverage,
188/// `max_source_authority == authority_fold(...)`) before any v2 INSERT
189/// (ADR 0026 §5). A validation failure MUST surface as `Reject`.
190pub const V2_SUMMARY_SPAN_PROOF_RULE_ID: &str = "memory.v2.summary_span_proof";
191/// Required contributor rule id documenting that the proposed cross-session
192/// salience record satisfies ADR 0017 invariants for a candidate row at insert
193/// time. Pre-populated `cross_session_use_count`, `last_validation_at`, or
194/// `validation_epoch` on a brand-new candidate are forbidden — cross-session
195/// salience must be earned through `record_session_use` and a `Validated`
196/// outcome edge, never minted at insert.
197pub const V2_CROSS_SESSION_SALIENCE_RULE_ID: &str = "memory.v2.cross_session_salience";
198
199macro_rules! memory_select_sql {
200    ($where_clause:literal) => {
201        concat!(
202            "SELECT id, memory_type, status, claim, source_episodes_json, source_events_json,
203                    domains_json, salience_json, confidence, authority, applies_when_json,
204                    does_not_apply_when_json, created_at, updated_at
205             FROM memories ",
206            $where_clause,
207            ";"
208        )
209    };
210}
211
212/// Candidate memory data accepted by [`MemoryRepo::insert_candidate`].
213#[derive(Debug, Clone, PartialEq)]
214pub struct MemoryCandidate {
215    /// Stable memory identifier.
216    pub id: MemoryId,
217    /// Memory type tag.
218    pub memory_type: String,
219    /// Candidate memory claim.
220    pub claim: String,
221    /// Episode lineage as JSON.
222    pub source_episodes_json: Value,
223    /// Event lineage as JSON.
224    pub source_events_json: Value,
225    /// Domain tags as JSON.
226    pub domains_json: Value,
227    /// Salience fields as JSON.
228    pub salience_json: Value,
229    /// Confidence score in `[0, 1]`.
230    pub confidence: f64,
231    /// Authority label.
232    pub authority: String,
233    /// Applicability constraints as JSON.
234    pub applies_when_json: Value,
235    /// Negative applicability constraints as JSON.
236    pub does_not_apply_when_json: Value,
237    /// Creation timestamp.
238    pub created_at: DateTime<Utc>,
239    /// Update timestamp.
240    pub updated_at: DateTime<Utc>,
241}
242
243/// Durable memory row read from the store.
244#[derive(Debug, Clone, PartialEq)]
245pub struct MemoryRecord {
246    /// Stable memory identifier.
247    pub id: MemoryId,
248    /// Memory type tag.
249    pub memory_type: String,
250    /// Lifecycle status.
251    pub status: String,
252    /// Memory claim.
253    pub claim: String,
254    /// Episode lineage as JSON.
255    pub source_episodes_json: Value,
256    /// Event lineage as JSON.
257    pub source_events_json: Value,
258    /// Domain tags as JSON.
259    pub domains_json: Value,
260    /// Salience fields as JSON.
261    pub salience_json: Value,
262    /// Confidence score in `[0, 1]`.
263    pub confidence: f64,
264    /// Authority label.
265    pub authority: String,
266    /// Applicability constraints as JSON.
267    pub applies_when_json: Value,
268    /// Negative applicability constraints as JSON.
269    pub does_not_apply_when_json: Value,
270    /// Creation timestamp.
271    pub created_at: DateTime<Utc>,
272    /// Update timestamp.
273    pub updated_at: DateTime<Utc>,
274}
275
276/// Audit data written by [`MemoryRepo::accept_candidate`].
277#[derive(Debug, Clone, PartialEq)]
278pub struct MemoryAcceptanceAudit {
279    /// Stable audit row identifier.
280    pub id: AuditRecordId,
281    /// Actor descriptor JSON.
282    pub actor_json: Value,
283    /// Operator-facing reason.
284    pub reason: String,
285    /// Source references JSON.
286    pub source_refs_json: Value,
287    /// Creation timestamp.
288    pub created_at: DateTime<Utc>,
289}
290
291/// One schema-v2 cross-session memory use side-table row.
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub struct MemorySessionUse {
294    /// Memory that was reused.
295    pub memory_id: MemoryId,
296    /// Session in which the memory was used.
297    pub session_id: String,
298    /// First observed use for this memory/session pair.
299    pub first_used_at: DateTime<Utc>,
300    /// Last observed use for this memory/session pair.
301    pub last_used_at: DateTime<Utc>,
302    /// Positive use count for this memory/session pair.
303    pub use_count: u32,
304}
305
306/// One schema-v2 outcome-to-memory relation side-table row.
307///
308/// ADR 0020 §6 scoped-validation payload: a `Validated` row MUST carry
309/// `validation_scope`, `validating_principal_id`, and `evidence_ref`.
310/// Non-validation relations leave them `None` because they do not move the
311/// validation axis (Phase 2.6 D1 closure).
312#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct OutcomeMemoryRelationRecord {
314    /// Stable outcome reference.
315    pub outcome_ref: String,
316    /// Memory related to the outcome.
317    pub memory_id: MemoryId,
318    /// Typed outcome/memory relation.
319    pub relation: OutcomeMemoryRelation,
320    /// Relation timestamp.
321    pub recorded_at: DateTime<Utc>,
322    /// Optional source event that introduced the outcome evidence.
323    pub source_event_id: Option<EventId>,
324    /// ADR 0020 §6 scope under which the validation applies. Required for
325    /// `Validated` relations; must be `None` for non-validation relations.
326    pub validation_scope: Option<String>,
327    /// ADR 0020 §6 stable identifier of the principal whose authority backs
328    /// the validation. Required for `Validated` relations; must be `None`
329    /// otherwise.
330    pub validating_principal_id: Option<String>,
331    /// ADR 0020 §6 reference to the concrete evidence (audit row, event,
332    /// attestation digest) supporting the validation. Required for
333    /// `Validated` relations; must be `None` otherwise.
334    pub evidence_ref: Option<String>,
335}
336
337/// Repository for memory candidate lifecycle rows.
338#[derive(Debug)]
339pub struct MemoryRepo<'a> {
340    pool: &'a Pool,
341}
342
343impl<'a> MemoryRepo<'a> {
344    /// Creates a memory repository over an open SQLite connection.
345    #[must_use]
346    pub const fn new(pool: &'a Pool) -> Self {
347        Self { pool }
348    }
349
350    /// Inserts a candidate memory after enforcing minimum lineage.
351    ///
352    /// `operator_note` memories are exempt from the lineage requirement —
353    /// the operator's act of writing the note is itself the provenance.
354    pub fn insert_candidate(&self, memory: &MemoryCandidate) -> StoreResult<()> {
355        let is_operator_note = memory.memory_type == "operator_note";
356        if !is_operator_note
357            && json_array_empty(&memory.source_episodes_json)
358            && json_array_empty(&memory.source_events_json)
359        {
360            return Err(StoreError::Validation(
361                "memory candidate requires episode or event lineage".into(),
362            ));
363        }
364
365        self.pool.execute(
366            "INSERT INTO memories (
367                id, memory_type, status, claim, source_episodes_json, source_events_json,
368                domains_json, salience_json, confidence, authority, applies_when_json,
369                does_not_apply_when_json, created_at, updated_at
370             ) VALUES (?1, ?2, 'candidate', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13);",
371            params![
372                memory.id.to_string(),
373                memory.memory_type,
374                memory.claim,
375                serde_json::to_string(&memory.source_episodes_json)?,
376                serde_json::to_string(&memory.source_events_json)?,
377                serde_json::to_string(&memory.domains_json)?,
378                serde_json::to_string(&memory.salience_json)?,
379                memory.confidence,
380                memory.authority,
381                serde_json::to_string(&memory.applies_when_json)?,
382                serde_json::to_string(&memory.does_not_apply_when_json)?,
383                memory.created_at.to_rfc3339(),
384                memory.updated_at.to_rfc3339(),
385            ],
386        )?;
387
388        Ok(())
389    }
390
391    /// Inserts one candidate memory with explicit schema-v2 summary-span and
392    /// salience fields through the ADR 0026 enforcement lattice.
393    ///
394    /// The schema-v2 cutover has landed; this opt-in API persists rows that
395    /// carry ADR 0015 summary spans and ADR 0017 cross-session salience
396    /// metadata. The default v1-named [`Self::insert_candidate`] path remains
397    /// the entry point for non-summary candidates so the lifecycle-layer
398    /// AXIOM admission gate (`cortex_memory::lifecycle::accept_candidate`)
399    /// continues to compose policy upstream.
400    ///
401    /// `policy` is the composed [`PolicyDecision`] for this insertion and
402    /// MUST satisfy:
403    ///
404    /// 1. The final outcome is one of [`PolicyOutcome::Allow`],
405    ///    [`PolicyOutcome::Warn`], or [`PolicyOutcome::BreakGlass`]. A
406    ///    `Quarantine` or `Reject` decision fails closed and writes nothing.
407    /// 2. The composition includes contributors for both
408    ///    [`V2_SUMMARY_SPAN_PROOF_RULE_ID`] and
409    ///    [`V2_CROSS_SESSION_SALIENCE_RULE_ID`]. The repo refuses callers that
410    ///    skipped composition.
411    ///
412    /// Even when the caller's `policy` argument is `Allow`, the repo
413    /// independently re-validates `summary_spans` against
414    /// [`validate_summary_spans`] and the salience record against the
415    /// ADR 0017 candidate-row invariants enforced by
416    /// [`validate_candidate_cross_session_salience`]. ADR 0026 §2 forbids a
417    /// subsystem-local fuse outside the engine, so a stale `Allow` policy
418    /// can never reach the SQLite INSERT after the underlying invariant
419    /// regressed.
420    pub fn insert_candidate_with_v2_fields(
421        &self,
422        memory: &MemoryCandidate,
423        summary_spans: &[SummarySpan],
424        salience: &CrossSessionSalience,
425        policy: &PolicyDecision,
426    ) -> StoreResult<()> {
427        require_policy_final_outcome(policy, "memory.v2.insert_candidate")?;
428        require_contributor_rule(policy, V2_SUMMARY_SPAN_PROOF_RULE_ID)?;
429        require_contributor_rule(policy, V2_CROSS_SESSION_SALIENCE_RULE_ID)?;
430
431        if json_array_empty(&memory.source_episodes_json)
432            && json_array_empty(&memory.source_events_json)
433        {
434            return Err(StoreError::Validation(
435                "memory candidate requires episode or event lineage".into(),
436            ));
437        }
438        if memory.memory_type.contains("summary") || !summary_spans.is_empty() {
439            validate_summary_spans(&memory.claim, summary_spans, |_| SourceAuthority::Derived)
440                .map_err(|err| {
441                    StoreError::Validation(format!(
442                        "memory summary_spans_json failed {}",
443                        err.invariant()
444                    ))
445                })?;
446        }
447        validate_candidate_cross_session_salience(salience)?;
448
449        self.pool.execute(
450            "INSERT INTO memories (
451                id, memory_type, status, claim, source_episodes_json, source_events_json,
452                domains_json, salience_json, confidence, authority, applies_when_json,
453                does_not_apply_when_json, created_at, updated_at, summary_spans_json,
454                cross_session_use_count, first_used_at, last_cross_session_use_at,
455                last_validation_at, validation_epoch, blessed_until
456             ) VALUES (
457                ?1, ?2, 'candidate', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14,
458                ?15, ?16, ?17, ?18, ?19, ?20
459             );",
460            params![
461                memory.id.to_string(),
462                memory.memory_type,
463                memory.claim,
464                serde_json::to_string(&memory.source_episodes_json)?,
465                serde_json::to_string(&memory.source_events_json)?,
466                serde_json::to_string(&memory.domains_json)?,
467                serde_json::to_string(&memory.salience_json)?,
468                memory.confidence,
469                memory.authority,
470                serde_json::to_string(&memory.applies_when_json)?,
471                serde_json::to_string(&memory.does_not_apply_when_json)?,
472                memory.created_at.to_rfc3339(),
473                memory.updated_at.to_rfc3339(),
474                serde_json::to_string(summary_spans)?,
475                i64::from(salience.cross_session_use_count),
476                salience.first_used_at.map(|value| value.to_rfc3339()),
477                salience
478                    .last_cross_session_use_at
479                    .map(|value| value.to_rfc3339()),
480                salience.last_validation_at.map(|value| value.to_rfc3339()),
481                i64::from(salience.validation_epoch),
482                salience.blessed_until.map(|value| value.to_rfc3339()),
483            ],
484        )?;
485
486        Ok(())
487    }
488
489    /// Records a schema-v2 memory/session use edge and reconciles memory salience columns.
490    ///
491    /// This opt-in API requires the S2 side table and v2 salience columns to exist.
492    pub fn record_session_use(&self, use_row: &MemorySessionUse) -> StoreResult<()> {
493        if use_row.session_id.trim().is_empty() {
494            return Err(StoreError::Validation(
495                "memory session use requires non-empty session_id".into(),
496            ));
497        }
498        if use_row.use_count == 0 {
499            return Err(StoreError::Validation(
500                "memory session use requires positive use_count".into(),
501            ));
502        }
503        if use_row.last_used_at < use_row.first_used_at {
504            return Err(StoreError::Validation(
505                "memory session use last_used_at cannot be earlier than first_used_at".into(),
506            ));
507        }
508
509        let tx = self.pool.unchecked_transaction()?;
510        ensure_memory_exists(&tx, &use_row.memory_id)?;
511        tx.execute(
512            "INSERT INTO memory_session_uses (
513                memory_id, session_id, first_used_at, last_used_at, use_count
514             ) VALUES (?1, ?2, ?3, ?4, ?5)
515             ON CONFLICT(memory_id, session_id) DO UPDATE SET
516                first_used_at = excluded.first_used_at,
517                last_used_at = excluded.last_used_at,
518                use_count = excluded.use_count;",
519            params![
520                use_row.memory_id.to_string(),
521                use_row.session_id.as_str(),
522                use_row.first_used_at.to_rfc3339(),
523                use_row.last_used_at.to_rfc3339(),
524                i64::from(use_row.use_count),
525            ],
526        )?;
527        reconcile_memory_session_salience(&tx, &use_row.memory_id)?;
528        tx.commit()?;
529
530        Ok(())
531    }
532
533    /// Records a schema-v2 outcome/memory relation edge.
534    ///
535    /// Validated outcome relations advance validation freshness; mere use edges never do.
536    ///
537    /// Phase 2.6 D1 closure: a `Validated` write is the durable twin of the
538    /// in-memory `EpistemicError::UtilityCannotUpgradeTruth` guard
539    /// (`cortex_memory::epistemic`). It MUST compose a [`PolicyDecision`]
540    /// satisfying:
541    ///
542    /// 1. The final outcome is one of [`PolicyOutcome::Allow`],
543    ///    [`PolicyOutcome::Warn`], or [`PolicyOutcome::BreakGlass`]. A
544    ///    `Quarantine` or `Reject` decision fails closed and writes nothing.
545    /// 2. The composition includes contributors for
546    ///    [`OUTCOME_VALIDATION_SCOPE_RULE_ID`],
547    ///    [`OUTCOME_VALIDATING_PRINCIPAL_TIER_RULE_ID`], and
548    ///    [`OUTCOME_EVIDENCE_REF_RULE_ID`]. The repo refuses callers that
549    ///    skipped composition with the stable invariant
550    ///    [`OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT`].
551    /// 3. The relation row carries `validation_scope`,
552    ///    `validating_principal_id`, and `evidence_ref` per ADR 0020 §6.
553    ///
554    /// Non-validation relations (Used / Contradicted / Superseded / Rejected)
555    /// do not move the validation axis and are accepted without a
556    /// `PolicyDecision`. Pass `None` for the `policy` parameter in that case;
557    /// passing `Some(..)` for a non-validation relation is rejected so callers
558    /// cannot quietly attach an unrelated decision to a non-validation write.
559    pub fn record_outcome_relation(
560        &self,
561        relation: &OutcomeMemoryRelationRecord,
562        policy: Option<&PolicyDecision>,
563    ) -> StoreResult<()> {
564        if relation.outcome_ref.trim().is_empty() {
565            return Err(StoreError::Validation(
566                "outcome memory relation requires non-empty outcome_ref".into(),
567            ));
568        }
569
570        if relation.relation.advances_validation() {
571            // ADR 0026 §2 + ADR 0020 §4 + ADR 0020 §6: Validated edges must
572            // compose a policy decision and carry the scoped-validation
573            // payload. Reject otherwise — this is the D1 fail-closed path.
574            let policy = policy.ok_or_else(|| {
575                StoreError::Validation(format!(
576                    "{OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT}: Validated outcome relation requires a composed PolicyDecision; caller skipped ADR 0026 composition",
577                ))
578            })?;
579            require_policy_final_outcome_for_validation(policy)?;
580            require_contributor_rule_for_validation(policy, OUTCOME_VALIDATION_SCOPE_RULE_ID)?;
581            require_contributor_rule_for_validation(
582                policy,
583                OUTCOME_VALIDATING_PRINCIPAL_TIER_RULE_ID,
584            )?;
585            require_contributor_rule_for_validation(policy, OUTCOME_EVIDENCE_REF_RULE_ID)?;
586            require_scoped_validation_payload(relation)?;
587        } else if policy.is_some() {
588            return Err(StoreError::Validation(format!(
589                "{OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT}: non-validation outcome relation must not carry a PolicyDecision (relation does not move the validation axis)",
590            )));
591        } else {
592            require_no_scoped_validation_payload(relation)?;
593        }
594
595        let tx = self.pool.unchecked_transaction()?;
596        ensure_memory_exists(&tx, &relation.memory_id)?;
597        let relation_wire = serde_json::to_value(relation.relation)?
598            .as_str()
599            .ok_or_else(|| {
600                StoreError::Validation("outcome memory relation did not serialize as string".into())
601            })?
602            .to_string();
603        tx.execute(
604            "INSERT INTO outcome_memory_relations (
605                outcome_ref, memory_id, relation, recorded_at, source_event_id,
606                validation_scope, validating_principal_id, evidence_ref
607             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
608             ON CONFLICT(outcome_ref, memory_id, relation) DO UPDATE SET
609                recorded_at = excluded.recorded_at,
610                source_event_id = excluded.source_event_id,
611                validation_scope = excluded.validation_scope,
612                validating_principal_id = excluded.validating_principal_id,
613                evidence_ref = excluded.evidence_ref;",
614            params![
615                relation.outcome_ref.as_str(),
616                relation.memory_id.to_string(),
617                relation_wire,
618                relation.recorded_at.to_rfc3339(),
619                relation.source_event_id.as_ref().map(ToString::to_string),
620                relation.validation_scope.as_deref(),
621                relation.validating_principal_id.as_deref(),
622                relation.evidence_ref.as_deref(),
623            ],
624        )?;
625        if relation.relation.advances_validation() {
626            tx.execute(
627                "UPDATE memories
628                 SET last_validation_at = ?2,
629                     validation_epoch = COALESCE(validation_epoch, 0) + 1
630                 WHERE id = ?1;",
631                params![
632                    relation.memory_id.to_string(),
633                    relation.recorded_at.to_rfc3339()
634                ],
635            )?;
636        }
637        tx.commit()?;
638
639        Ok(())
640    }
641
642    /// Returns the durable [`CrossSessionWeakNegativeStatus`] for a memory.
643    ///
644    /// Phase 2.6 D2 closure: derived from
645    /// `(cross_session_use_count, validation_epoch)` on the memory row.
646    /// Returns `Ok(None)` when the memory id does not exist. NULL durable
647    /// columns are treated as `0`.
648    pub fn cross_session_weak_negative_status_for(
649        &self,
650        id: &MemoryId,
651    ) -> StoreResult<Option<CrossSessionWeakNegativeStatus>> {
652        let row: Option<(Option<u32>, Option<u32>)> = self
653            .pool
654            .query_row(
655                "SELECT cross_session_use_count, validation_epoch FROM memories WHERE id = ?1;",
656                params![id.to_string()],
657                |row| Ok((row.get::<_, Option<u32>>(0)?, row.get::<_, Option<u32>>(1)?)),
658            )
659            .optional()?;
660        Ok(row.map(|(use_count, epoch)| {
661            cross_session_weak_negative_status(use_count.unwrap_or(0), epoch.unwrap_or(0))
662        }))
663    }
664
665    /// Returns the durable `validation_epoch` for a memory row.
666    ///
667    /// Phase 2.6 D3 closure: retrieval scoring MUST read validation freshness
668    /// from this authoritative column, not from `salience_json["validation"]`.
669    /// `salience_json` is authored at candidate insert by the candidate author
670    /// and is never updated by any post-admission path; only
671    /// [`Self::record_outcome_relation`] advances `validation_epoch`, and that
672    /// path is gated by the ADR 0020 §4/§6 + ADR 0026 §4 policy envelope.
673    ///
674    /// Returns `Ok(None)` when the memory id does not exist. Returns
675    /// `Ok(Some(0))` when the column is still NULL — the column is nullable in
676    /// the schema (v2 cutover compatibility) and a NULL is treated as "no
677    /// validated edge has ever advanced this memory" rather than as ambient
678    /// trust.
679    pub fn validation_epoch_for(&self, id: &MemoryId) -> StoreResult<Option<u32>> {
680        let row: Option<Option<u32>> = self
681            .pool
682            .query_row(
683                "SELECT validation_epoch FROM memories WHERE id = ?1;",
684                params![id.to_string()],
685                |row| row.get::<_, Option<u32>>(0),
686            )
687            .optional()?;
688        Ok(row.map(|epoch| epoch.unwrap_or(0)))
689    }
690
691    /// Fetches a memory row by id.
692    pub fn get_by_id(&self, id: &MemoryId) -> StoreResult<Option<MemoryRecord>> {
693        let row = self
694            .pool
695            .query_row(
696                memory_select_sql!("WHERE id = ?1"),
697                params![id.to_string()],
698                memory_row,
699            )
700            .optional()?;
701
702        row.map(TryInto::try_into).transpose()
703    }
704
705    /// Phase 4.B fuzzy retrieval — additive FTS5 trigram lookup.
706    ///
707    /// Looks up memory ids whose `claim` or `domains_json` matches `query`
708    /// under the SQLite FTS5 `trigram` tokenizer (migration
709    /// `006_fts5_memories`). Returns `(memory_id, raw_bm25_rank)` pairs in
710    /// FTS5's native BM25 ranking order: most-relevant first.
711    ///
712    /// This is a *read-only* surface intended for the opt-in `--fuzzy` CLI
713    /// path. It does NOT replace the default lexical retrieval scorer in
714    /// `cortex-retrieval`; the existing default path remains byte-for-byte
715    /// unchanged (Phase 4.B eval guardrail). The caller is responsible for
716    /// composing fuzzy ranks with the deterministic lexical scorer via the
717    /// `compose_fuzzy_boost` helper in `cortex_retrieval::fts5`.
718    ///
719    /// `query` MUST be a non-empty trimmed string. The CLI surface and the
720    /// retrieval helper both pre-validate non-emptiness, but this method
721    /// fails closed independently as a defense-in-depth guard.
722    ///
723    /// `limit` caps the number of returned rows; `0` returns an empty vec
724    /// without touching SQLite.
725    ///
726    /// # Fuzzy expression shape
727    ///
728    /// The SQLite `trigram` tokenizer is substring-aware: a token is
729    /// indexed as the set of its 3-grams, and a `MATCH 'foo'` query
730    /// looks for *every* trigram of `foo` to appear in the document.
731    /// That is precision-friendly but it refuses one-character typos
732    /// because the corrupted trigrams never appear anywhere on disk.
733    /// To recover the "typo-of-one-character still hits" property
734    /// Phase 4.B requires, each query token is expanded into
735    /// overlapping 4-grams joined with `OR`: a query token of length
736    /// `n >= 4` becomes `gram1 OR gram2 OR ... OR gram(n-3)`. Each
737    /// 4-gram is itself substring-matched by the trigram tokenizer
738    /// (FTS5 enforces every trigram of the literal, so a 4-gram is
739    /// the smallest piece that still survives a single-character
740    /// substitution at one of its endpoints).
741    ///
742    /// Tokens shorter than four characters are searched as-is — they
743    /// already correspond to one trigram or a substring smaller than
744    /// the tokenizer's minimum unit, so OR-expansion would add
745    /// nothing. Tokens with no alphanumeric characters are dropped
746    /// entirely (FTS5 reserves quotes / parens / colons for its
747    /// expression language).
748    ///
749    /// BM25 ranks remain non-positive floats (smaller = better); the
750    /// raw rank is passed through unchanged so the retrieval layer
751    /// can normalise into the `[0, 1]` band without bias.
752    pub fn fts5_search(&self, query: &str, limit: usize) -> StoreResult<Vec<(MemoryId, f32)>> {
753        let trimmed = query.trim();
754        if trimmed.is_empty() {
755            return Err(StoreError::Validation(
756                "fts5_search: query must not be empty".into(),
757            ));
758        }
759        if limit == 0 {
760            return Ok(Vec::new());
761        }
762
763        let match_expr = fts5_fuzzy_match_expression(trimmed).ok_or_else(|| {
764            StoreError::Validation(
765                "fts5_search: query produced no searchable trigrams after sanitization".into(),
766            )
767        })?;
768
769        let limit_i64 = i64::try_from(limit)
770            .map_err(|_| StoreError::Validation("fts5_search: limit exceeds i64 range".into()))?;
771
772        let mut stmt = self.pool.prepare(
773            "SELECT memory_id, rank \
774             FROM memories_fts \
775             WHERE memories_fts MATCH ?1 \
776             ORDER BY rank \
777             LIMIT ?2;",
778        )?;
779        let rows = stmt.query_map(params![match_expr, limit_i64], |row| {
780            Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?))
781        })?;
782
783        let mut hits = Vec::new();
784        for row in rows {
785            let (id_text, rank) = row?;
786            let id = id_text.parse::<MemoryId>().map_err(|err| {
787                StoreError::Validation(format!(
788                    "fts5_search: memory_id mirror value `{id_text}` failed to parse: {err}"
789                ))
790            })?;
791            hits.push((id, rank as f32));
792        }
793        Ok(hits)
794    }
795
796    /// Fetches a candidate memory row by id.
797    pub fn get_candidate_by_id(&self, id: &MemoryId) -> StoreResult<Option<MemoryRecord>> {
798        let row = self
799            .pool
800            .query_row(
801                memory_select_sql!("WHERE id = ?1 AND status = 'candidate'"),
802                params![id.to_string()],
803                memory_row,
804            )
805            .optional()?;
806
807        row.map(TryInto::try_into).transpose()
808    }
809
810    /// Lists candidate memory rows in deterministic update order.
811    pub fn list_candidates(&self) -> StoreResult<Vec<MemoryRecord>> {
812        self.list_by_status("candidate")
813    }
814
815    /// Lists memory rows matching a lifecycle status.
816    pub fn list_by_status(&self, status: &str) -> StoreResult<Vec<MemoryRecord>> {
817        let mut stmt = self.pool.prepare(memory_select_sql!(
818            "WHERE status = ?1 ORDER BY updated_at DESC, id"
819        ))?;
820        let rows = stmt.query_map(params![status], memory_row)?;
821
822        let mut memories = Vec::new();
823        for row in rows {
824            memories.push(row?.try_into()?);
825        }
826        Ok(memories)
827    }
828
829    /// Lists memory rows matching a lifecycle status whose `domains_json`
830    /// array contains every supplied tag (AND semantics).
831    ///
832    /// This is an additive read-path helper for tag-as-retrieval-surface
833    /// filtering. It uses SQLite JSON1's `json_each(domains_json)` to score
834    /// distinct tag membership, then enforces the AND closure by
835    /// `COUNT(DISTINCT je.value) = ?` against the unique tag set.
836    ///
837    /// An empty `tags` slice is equivalent to [`Self::list_by_status`]: no
838    /// tag filter is applied. Duplicates in `tags` are coalesced before the
839    /// COUNT comparison so that callers can pass user input directly without
840    /// having to dedupe.
841    pub fn list_by_status_with_tags(
842        &self,
843        status: &str,
844        tags: &[String],
845    ) -> StoreResult<Vec<MemoryRecord>> {
846        // Deduplicate the requested tag set. AND semantics over the row's
847        // `domains_json` is "the row contains every requested tag at least
848        // once"; duplicate inputs should not inflate the COUNT target.
849        let mut unique_tags: Vec<String> = Vec::with_capacity(tags.len());
850        for tag in tags {
851            if !unique_tags.iter().any(|existing| existing == tag) {
852                unique_tags.push(tag.clone());
853            }
854        }
855
856        if unique_tags.is_empty() {
857            return self.list_by_status(status);
858        }
859
860        // Build a parameterised IN clause large enough for the deduped tag
861        // set. Each `?` corresponds to one entry in `unique_tags`. The
862        // outer COUNT(DISTINCT je.value) compares against the unique tag
863        // count, so a row that carries `["a","b","b"]` and is asked for
864        // `["a","b"]` still satisfies the closure.
865        let placeholders = std::iter::repeat_n("?", unique_tags.len())
866            .collect::<Vec<_>>()
867            .join(",");
868        let where_clause = format!(
869            "WHERE status = ? AND id IN (
870                SELECT m.id FROM memories m, json_each(m.domains_json) je
871                WHERE je.value IN ({placeholders})
872                GROUP BY m.id
873                HAVING COUNT(DISTINCT je.value) = ?
874             ) ORDER BY updated_at DESC, id"
875        );
876        let sql = format!(
877            "SELECT id, memory_type, status, claim, source_episodes_json, source_events_json, \
878                    domains_json, salience_json, confidence, authority, applies_when_json, \
879                    does_not_apply_when_json, created_at, updated_at \
880             FROM memories {where_clause};"
881        );
882
883        let mut stmt = self.pool.prepare(&sql)?;
884        let unique_count = i64::try_from(unique_tags.len()).map_err(|_| {
885            StoreError::Validation("list_by_status_with_tags: tag count exceeds i64 range".into())
886        })?;
887        let mut bind_values: Vec<rusqlite::types::Value> =
888            Vec::with_capacity(unique_tags.len() + 2);
889        bind_values.push(rusqlite::types::Value::Text(status.to_string()));
890        for tag in &unique_tags {
891            bind_values.push(rusqlite::types::Value::Text(tag.clone()));
892        }
893        bind_values.push(rusqlite::types::Value::Integer(unique_count));
894        let rows = stmt.query_map(rusqlite::params_from_iter(bind_values), memory_row)?;
895
896        let mut memories = Vec::new();
897        for row in rows {
898            memories.push(row?.try_into()?);
899        }
900        Ok(memories)
901    }
902
903    /// Promotes a memory candidate to active.
904    pub fn set_active(&self, id: &MemoryId, updated_at: DateTime<Utc>) -> StoreResult<()> {
905        let changed = self.pool.execute(
906            "UPDATE memories
907             SET status = 'active', updated_at = ?2
908             WHERE id = ?1;",
909            params![id.to_string(), updated_at.to_rfc3339()],
910        )?;
911
912        if changed == 0 {
913            return Err(StoreError::Validation(format!("memory {id} not found")));
914        }
915
916        Ok(())
917    }
918
919    /// Transitions a candidate to `pending_mcp_commit` (ADR 0047).
920    ///
921    /// Only rows currently in `candidate` status are transitioned. If the row
922    /// does not exist or is not a candidate, returns a validation error — the
923    /// caller should handle `"not a candidate"` gracefully as an idempotency
924    /// signal when the row was already promoted by a prior call.
925    pub fn set_pending_mcp_commit(&self, id: &MemoryId, now: DateTime<Utc>) -> StoreResult<()> {
926        let changed = self.pool.execute(
927            "UPDATE memories
928             SET status = 'pending_mcp_commit', updated_at = ?2
929             WHERE id = ?1 AND status = 'candidate';",
930            params![id.to_string(), now.to_rfc3339()],
931        )?;
932
933        if changed == 0 {
934            // Either not found, or not a candidate. Distinguish for the caller.
935            let current: Option<String> = self
936                .pool
937                .query_row(
938                    "SELECT status FROM memories WHERE id = ?1;",
939                    params![id.to_string()],
940                    |row| row.get(0),
941                )
942                .optional()?;
943            return Err(StoreError::Validation(match current.as_deref() {
944                None => format!("memory {id} not found"),
945                Some(s) => format!("memory {id} is not a candidate: {s}"),
946            }));
947        }
948
949        Ok(())
950    }
951
952    /// Promotes all `pending_mcp_commit` rows to `active` (ADR 0047 Path B /
953    /// `cortex_session_commit`).
954    ///
955    /// The single-operator model means there is only one active MCP session at
956    /// a time; this bulk-promotes every pending row regardless of which session
957    /// wrote it. Returns the number of rows promoted.
958    pub fn commit_pending_mcp(
959        &self,
960        _session_receipt_id: &str,
961        now: DateTime<Utc>,
962    ) -> StoreResult<usize> {
963        let changed = self.pool.execute(
964            "UPDATE memories
965             SET status = 'active', updated_at = ?1
966             WHERE status = 'pending_mcp_commit';",
967            params![now.to_rfc3339()],
968        )?;
969        Ok(changed)
970    }
971
972    /// Returns the maximum sensitivity level across all active memories'
973    /// `domains_json` arrays.
974    ///
975    /// Sensitivity tags are `"sensitivity:high"`, `"sensitivity:medium"`, and
976    /// `"sensitivity:low"`. The method scans every active memory row's
977    /// `domains_json` JSON array for these tags and returns the highest level
978    /// found as a string: `"high"`, `"medium"`, `"low"`, or `"none"` when no
979    /// sensitivity tags are present in any active memory.
980    ///
981    /// This replaces the text-scan heuristic in `cortex_llm::sensitivity` with
982    /// a real per-memory domain-tag query (ADR 0048 §3 follow-on).
983    ///
984    /// # Errors
985    ///
986    /// Returns [`StoreError`] when the SQL query or JSON parsing fails.
987    pub fn max_sensitivity_for_active_memories(&self) -> StoreResult<String> {
988        // Use SQLite JSON1 json_each to expand domains_json arrays and look for
989        // sensitivity:* tags. The CASE expression maps tags to a numeric rank so
990        // MAX() finds the highest level without application-side iteration.
991        let max_rank: Option<i64> = self
992            .pool
993            .query_row(
994                "SELECT MAX(CASE je.value \
995                         WHEN 'sensitivity:high'   THEN 3 \
996                         WHEN 'sensitivity:medium' THEN 2 \
997                         WHEN 'sensitivity:low'    THEN 1 \
998                         ELSE                           0 \
999                     END) \
1000                 FROM memories m, json_each(m.domains_json) je \
1001                 WHERE m.status = 'active' \
1002                   AND je.value IN ('sensitivity:high', 'sensitivity:medium', 'sensitivity:low');",
1003                [],
1004                |row| row.get(0),
1005            )
1006            .optional()?
1007            .flatten();
1008
1009        let level = match max_rank {
1010            Some(3) => "high",
1011            Some(2) => "medium",
1012            Some(1) => "low",
1013            _ => "none",
1014        };
1015        Ok(level.to_string())
1016    }
1017
1018    /// Appends a tag to the memory's `domains_json` array.
1019    ///
1020    /// Idempotent: adding an already-present tag is a no-op and returns
1021    /// `Ok(false)`. Returns `Ok(true)` when the tag was newly added.
1022    /// Fails with [`StoreError::Validation`] when the memory id does not exist.
1023    /// The `updated_at` column is advanced to `now` when the tag is added.
1024    pub fn add_domain_tag(
1025        &self,
1026        id: &MemoryId,
1027        tag: &str,
1028        now: DateTime<Utc>,
1029    ) -> StoreResult<bool> {
1030        if tag.trim().is_empty() {
1031            return Err(StoreError::Validation(
1032                "add_domain_tag: tag must not be empty".into(),
1033            ));
1034        }
1035        let tx = self.pool.unchecked_transaction()?;
1036        let domains_raw: Option<String> = tx
1037            .query_row(
1038                "SELECT domains_json FROM memories WHERE id = ?1;",
1039                params![id.to_string()],
1040                |row| row.get(0),
1041            )
1042            .optional()?;
1043        let domains_raw =
1044            domains_raw.ok_or_else(|| StoreError::Validation(format!("memory {id} not found")))?;
1045        let mut tags: Vec<String> = serde_json::from_str(&domains_raw).map_err(|err| {
1046            StoreError::Validation(format!(
1047                "add_domain_tag: domains_json for memory {id} is not a valid JSON array: {err}"
1048            ))
1049        })?;
1050        if tags.iter().any(|t| t == tag) {
1051            return Ok(false);
1052        }
1053        tags.push(tag.to_string());
1054        let new_domains = serde_json::to_string(&tags)?;
1055        tx.execute(
1056            "UPDATE memories SET domains_json = ?2, updated_at = ?3 WHERE id = ?1;",
1057            params![id.to_string(), new_domains, now.to_rfc3339()],
1058        )?;
1059        tx.commit()?;
1060        Ok(true)
1061    }
1062
1063    /// Removes a tag from the memory's `domains_json` array.
1064    ///
1065    /// Idempotent: removing a tag that is not present is a no-op and returns
1066    /// `Ok(false)`. Returns `Ok(true)` when the tag was removed.
1067    /// Fails with [`StoreError::Validation`] when the memory id does not exist.
1068    /// The `updated_at` column is advanced to `now` when the tag is removed.
1069    pub fn remove_domain_tag(
1070        &self,
1071        id: &MemoryId,
1072        tag: &str,
1073        now: DateTime<Utc>,
1074    ) -> StoreResult<bool> {
1075        if tag.trim().is_empty() {
1076            return Err(StoreError::Validation(
1077                "remove_domain_tag: tag must not be empty".into(),
1078            ));
1079        }
1080        let tx = self.pool.unchecked_transaction()?;
1081        let domains_raw: Option<String> = tx
1082            .query_row(
1083                "SELECT domains_json FROM memories WHERE id = ?1;",
1084                params![id.to_string()],
1085                |row| row.get(0),
1086            )
1087            .optional()?;
1088        let domains_raw =
1089            domains_raw.ok_or_else(|| StoreError::Validation(format!("memory {id} not found")))?;
1090        let mut tags: Vec<String> = serde_json::from_str(&domains_raw).map_err(|err| {
1091            StoreError::Validation(format!(
1092                "remove_domain_tag: domains_json for memory {id} is not a valid JSON array: {err}"
1093            ))
1094        })?;
1095        let before_len = tags.len();
1096        tags.retain(|t| t != tag);
1097        if tags.len() == before_len {
1098            return Ok(false);
1099        }
1100        let new_domains = serde_json::to_string(&tags)?;
1101        tx.execute(
1102            "UPDATE memories SET domains_json = ?2, updated_at = ?3 WHERE id = ?1;",
1103            params![id.to_string(), new_domains, now.to_rfc3339()],
1104        )?;
1105        tx.commit()?;
1106        Ok(true)
1107    }
1108
1109    /// Atomically promotes a candidate to active and writes an audit row
1110    /// through the ADR 0026 enforcement lattice.
1111    ///
1112    /// `policy` is the composed [`PolicyDecision`] for this acceptance and
1113    /// MUST satisfy:
1114    ///
1115    /// 1. The final outcome is one of [`PolicyOutcome::Allow`],
1116    ///    [`PolicyOutcome::Warn`], or [`PolicyOutcome::BreakGlass`]. A
1117    ///    `Quarantine` or `Reject` decision fails closed and writes nothing.
1118    /// 2. The composition includes contributors for
1119    ///    [`ACCEPT_PROOF_CLOSURE_RULE_ID`],
1120    ///    [`ACCEPT_OPEN_CONTRADICTION_RULE_ID`],
1121    ///    [`ACCEPT_SEMANTIC_TRUST_RULE_ID`], and
1122    ///    [`ACCEPT_OPERATOR_TEMPORAL_USE_RULE_ID`]. The repo refuses callers
1123    ///    that skipped composition.
1124    /// 3. Per ADR 0026 §4, the operator temporal-use contributor MUST be
1125    ///    [`PolicyOutcome::Allow`] even when the final decision is
1126    ///    `BreakGlass`. Break-glass never substitutes for current-use temporal
1127    ///    authority at this surface.
1128    ///
1129    /// Production callers compose the proof-closure contributor from
1130    /// [`crate::verify_memory_proof_closure`], the open-contradiction
1131    /// contributor from the conflict resolver, the semantic-trust contributor
1132    /// from the AXIOM admission envelope (ADR 0038), and the operator
1133    /// temporal-use contributor from ADR 0023 `revalidate_temporal_authority`.
1134    pub fn accept_candidate(
1135        &self,
1136        id: &MemoryId,
1137        updated_at: DateTime<Utc>,
1138        audit: &MemoryAcceptanceAudit,
1139        policy: &PolicyDecision,
1140    ) -> StoreResult<MemoryRecord> {
1141        require_policy_final_outcome(policy, "memory.accept")?;
1142        require_contributor_rule(policy, ACCEPT_PROOF_CLOSURE_RULE_ID)?;
1143        require_contributor_rule(policy, ACCEPT_OPEN_CONTRADICTION_RULE_ID)?;
1144        require_contributor_rule(policy, ACCEPT_SEMANTIC_TRUST_RULE_ID)?;
1145        require_contributor_rule(policy, ACCEPT_OPERATOR_TEMPORAL_USE_RULE_ID)?;
1146        require_attestation_not_break_glassed(
1147            policy,
1148            ACCEPT_OPERATOR_TEMPORAL_USE_RULE_ID,
1149            "memory.accept",
1150        )?;
1151
1152        let tx = self.pool.unchecked_transaction()?;
1153        let before = tx
1154            .query_row(
1155                "SELECT status FROM memories WHERE id = ?1;",
1156                params![id.to_string()],
1157                |row| row.get::<_, String>(0),
1158            )
1159            .optional()?;
1160
1161        match before.as_deref() {
1162            Some("candidate") => {}
1163            Some(status) => {
1164                return Err(StoreError::Validation(format!(
1165                    "memory {id} is not a candidate: {status}"
1166                )));
1167            }
1168            None => {
1169                return Err(StoreError::Validation(format!("memory {id} not found")));
1170            }
1171        }
1172
1173        tx.execute(
1174            "UPDATE memories
1175             SET status = 'active', updated_at = ?2
1176             WHERE id = ?1 AND status = 'candidate';",
1177            params![id.to_string(), updated_at.to_rfc3339()],
1178        )?;
1179        tx.execute(
1180            "INSERT INTO audit_records (
1181                id, operation, target_ref, before_hash, after_hash, reason,
1182                actor_json, source_refs_json, created_at
1183             ) VALUES (?1, 'memory.accept', ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
1184            params![
1185                audit.id.to_string(),
1186                id.to_string(),
1187                "status:candidate",
1188                "status:active",
1189                audit.reason,
1190                serde_json::to_string(&audit.actor_json)?,
1191                serde_json::to_string(&audit.source_refs_json)?,
1192                audit.created_at.to_rfc3339(),
1193            ],
1194        )?;
1195
1196        let row = tx.query_row(
1197            memory_select_sql!("WHERE id = ?1"),
1198            params![id.to_string()],
1199            memory_row,
1200        )?;
1201        tx.commit()?;
1202
1203        row.try_into()
1204    }
1205}
1206
1207fn json_array_empty(value: &Value) -> bool {
1208    value.as_array().is_some_and(Vec::is_empty)
1209}
1210
1211/// Build an FTS5 MATCH expression that surfaces fuzzy hits over the
1212/// `trigram` tokenizer (Phase 4.B). See the doc comment on
1213/// [`MemoryRepo::fts5_search`] for the design rationale.
1214///
1215/// Returns `None` when the sanitised input produced zero searchable
1216/// trigrams — the caller turns that into a stable validation error.
1217fn fts5_fuzzy_match_expression(query: &str) -> Option<String> {
1218    /// Smallest gram width that can survive a single-character substitution
1219    /// at one of its endpoints. The trigram tokenizer indexes every 3-gram,
1220    /// so a 4-character literal in the MATCH expression is itself decomposed
1221    /// into two overlapping trigrams — the one that does NOT contain the
1222    /// substituted character still hits the document.
1223    const FUZZY_GRAM_WIDTH: usize = 4;
1224
1225    let mut grams: Vec<String> = Vec::new();
1226    for raw_token in query.split_whitespace() {
1227        let sanitised: String = raw_token
1228            .chars()
1229            .filter(|character| character.is_ascii_alphanumeric())
1230            .collect::<String>()
1231            .to_ascii_lowercase();
1232        if sanitised.is_empty() {
1233            continue;
1234        }
1235
1236        if sanitised.len() < FUZZY_GRAM_WIDTH {
1237            // Tokens shorter than a fuzzy gram (e.g. "of", "an") are
1238            // searched as-is. FTS5 tokenises them into one or two trigrams;
1239            // either matches a substring of any document containing them.
1240            grams.push(sanitised);
1241            continue;
1242        }
1243
1244        // Expand the token into overlapping FUZZY_GRAM_WIDTH-grams. For
1245        // "retrieval" (len = 9) this is six 4-grams: "retr", "etri",
1246        // "trie", "riev", "ieva", "eval". OR-ing them lets a query like
1247        // "retrievaal" still surface "retrieval" — only the 4-grams that
1248        // include the substituted character drop out, the rest still hit.
1249        let bytes = sanitised.as_bytes();
1250        for start in 0..=bytes.len() - FUZZY_GRAM_WIDTH {
1251            // SAFETY: `sanitised` is pure ASCII alphanumerics, so every byte
1252            // is its own UTF-8 boundary and slicing on bytes is safe.
1253            let gram = &sanitised[start..start + FUZZY_GRAM_WIDTH];
1254            grams.push(gram.to_string());
1255        }
1256    }
1257
1258    if grams.is_empty() {
1259        return None;
1260    }
1261
1262    // Deduplicate while preserving first-seen order so identical 4-grams
1263    // from overlapping tokens do not inflate the OR expression.
1264    let mut unique = Vec::with_capacity(grams.len());
1265    for gram in grams {
1266        if !unique.contains(&gram) {
1267            unique.push(gram);
1268        }
1269    }
1270
1271    // Quote each gram so FTS5 treats it as a literal phrase. The
1272    // sanitisation above already removed every character FTS5 treats as
1273    // syntax (parens, colons, quotes), but quoting is the documented way
1274    // to feed a literal substring through MATCH and is safe against any
1275    // future change to the sanitiser.
1276    let mut expression = String::new();
1277    for (idx, gram) in unique.iter().enumerate() {
1278        if idx > 0 {
1279            expression.push_str(" OR ");
1280        }
1281        expression.push('"');
1282        expression.push_str(gram);
1283        expression.push('"');
1284    }
1285    Some(expression)
1286}
1287
1288fn require_policy_final_outcome(policy: &PolicyDecision, surface: &str) -> StoreResult<()> {
1289    match policy.final_outcome {
1290        PolicyOutcome::Allow | PolicyOutcome::Warn | PolicyOutcome::BreakGlass => Ok(()),
1291        PolicyOutcome::Quarantine | PolicyOutcome::Reject => Err(StoreError::Validation(format!(
1292            "{surface} preflight: composed policy outcome {:?} blocks memory mutation",
1293            policy.final_outcome,
1294        ))),
1295    }
1296}
1297
1298fn require_contributor_rule(policy: &PolicyDecision, rule_id: &str) -> StoreResult<()> {
1299    let contains_rule = policy
1300        .contributing
1301        .iter()
1302        .chain(policy.discarded.iter())
1303        .any(|contribution| contribution.rule_id.as_str() == rule_id);
1304    if contains_rule {
1305        Ok(())
1306    } else {
1307        Err(StoreError::Validation(format!(
1308            "policy decision missing required contributor `{rule_id}`; caller skipped ADR 0026 composition",
1309        )))
1310    }
1311}
1312
1313fn require_policy_final_outcome_for_validation(policy: &PolicyDecision) -> StoreResult<()> {
1314    // Phase 2.6 D1 closure: a Quarantine/Reject final outcome on a Validated
1315    // edge is the on-disk twin of UtilityCannotUpgradeTruth — surface the
1316    // stable invariant rather than the generic policy-outcome message.
1317    match policy.final_outcome {
1318        PolicyOutcome::Allow | PolicyOutcome::Warn | PolicyOutcome::BreakGlass => Ok(()),
1319        PolicyOutcome::Quarantine | PolicyOutcome::Reject => Err(StoreError::Validation(format!(
1320            "{OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT}: composed policy outcome {:?} blocks Validated outcome relation write",
1321            policy.final_outcome,
1322        ))),
1323    }
1324}
1325
1326fn require_contributor_rule_for_validation(
1327    policy: &PolicyDecision,
1328    rule_id: &str,
1329) -> StoreResult<()> {
1330    let contains_rule = policy
1331        .contributing
1332        .iter()
1333        .chain(policy.discarded.iter())
1334        .any(|contribution| contribution.rule_id.as_str() == rule_id);
1335    if contains_rule {
1336        Ok(())
1337    } else {
1338        Err(StoreError::Validation(format!(
1339            "{OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT}: policy decision missing required contributor `{rule_id}` for Validated outcome relation",
1340        )))
1341    }
1342}
1343
1344#[cfg(test)]
1345mod tests {
1346    use super::*;
1347    use cortex_core::compose_policy_outcomes;
1348
1349    fn relation_record(
1350        relation: OutcomeMemoryRelation,
1351        with_scope: bool,
1352    ) -> OutcomeMemoryRelationRecord {
1353        OutcomeMemoryRelationRecord {
1354            outcome_ref: "outcome:test".into(),
1355            memory_id: "mem_01ARZ3NDEKTSV4RRFFQ69G5V40".parse().unwrap(),
1356            relation,
1357            recorded_at: DateTime::parse_from_rfc3339("2026-05-04T12:00:00Z")
1358                .unwrap()
1359                .with_timezone(&Utc),
1360            source_event_id: None,
1361            validation_scope: if with_scope {
1362                Some("scope:test".into())
1363            } else {
1364                None
1365            },
1366            validating_principal_id: if with_scope {
1367                Some("principal:test-operator".into())
1368            } else {
1369                None
1370            },
1371            evidence_ref: if with_scope {
1372                Some("aud:test".into())
1373            } else {
1374                None
1375            },
1376        }
1377    }
1378
1379    fn seed_pool_with_memory() -> Pool {
1380        let pool = rusqlite::Connection::open_in_memory().expect("open in-memory pool");
1381        crate::migrate::apply_pending(&pool).expect("apply migrations");
1382        pool.execute(
1383            "INSERT INTO memories (
1384                id, memory_type, status, claim, source_episodes_json, source_events_json,
1385                domains_json, salience_json, confidence, authority, applies_when_json,
1386                does_not_apply_when_json, created_at, updated_at, validation_epoch
1387             ) VALUES (
1388                'mem_01ARZ3NDEKTSV4RRFFQ69G5V40', 'semantic', 'active', 'test memory',
1389                '[]', '[\"evt_01ARZ3NDEKTSV4RRFFQ69G5V40\"]', '[]',
1390                '{}', 0.7, 'candidate', '{}', '{}',
1391                '2026-05-04T12:00:00Z', '2026-05-04T12:00:00Z', 0
1392             );",
1393            [],
1394        )
1395        .expect("seed memory");
1396        pool
1397    }
1398
1399    #[test]
1400    fn record_outcome_relation_validated_refuses_when_policy_decision_is_missing() {
1401        let pool = seed_pool_with_memory();
1402        let repo = MemoryRepo::new(&pool);
1403
1404        let err = repo
1405            .record_outcome_relation(
1406                &relation_record(OutcomeMemoryRelation::Validated, true),
1407                None,
1408            )
1409            .expect_err("missing policy decision must fail closed");
1410
1411        let msg = err.to_string();
1412        assert!(
1413            msg.contains(OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT),
1414            "expected stable invariant in error: {msg}"
1415        );
1416    }
1417
1418    #[test]
1419    fn record_outcome_relation_validated_refuses_when_policy_outcome_denies() {
1420        let pool = seed_pool_with_memory();
1421        let repo = MemoryRepo::new(&pool);
1422
1423        // Compose a Reject final outcome — the on-disk twin of
1424        // EpistemicError::UtilityCannotUpgradeTruth on the durable path.
1425        let deny_policy = compose_policy_outcomes(
1426            vec![
1427                PolicyContribution::new(
1428                    OUTCOME_VALIDATION_SCOPE_RULE_ID,
1429                    PolicyOutcome::Reject,
1430                    "test: validation scope rejected (untrusted tier)",
1431                )
1432                .unwrap(),
1433                PolicyContribution::new(
1434                    OUTCOME_VALIDATING_PRINCIPAL_TIER_RULE_ID,
1435                    PolicyOutcome::Reject,
1436                    "test: principal trust tier below class gate",
1437                )
1438                .unwrap(),
1439                PolicyContribution::new(
1440                    OUTCOME_EVIDENCE_REF_RULE_ID,
1441                    PolicyOutcome::Reject,
1442                    "test: evidence_ref does not cite a concrete row",
1443                )
1444                .unwrap(),
1445            ],
1446            None,
1447        );
1448
1449        let err = repo
1450            .record_outcome_relation(
1451                &relation_record(OutcomeMemoryRelation::Validated, true),
1452                Some(&deny_policy),
1453            )
1454            .expect_err("deny policy must fail closed");
1455
1456        let msg = err.to_string();
1457        assert!(
1458            msg.contains(OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT),
1459            "expected stable invariant in error: {msg}"
1460        );
1461    }
1462
1463    #[test]
1464    fn record_outcome_relation_validated_refuses_missing_scoped_payload() {
1465        let pool = seed_pool_with_memory();
1466        let repo = MemoryRepo::new(&pool);
1467
1468        let err = repo
1469            .record_outcome_relation(
1470                &relation_record(OutcomeMemoryRelation::Validated, false),
1471                Some(&record_outcome_relation_policy_decision_test_allow()),
1472            )
1473            .expect_err("missing scope payload must fail closed");
1474
1475        let msg = err.to_string();
1476        assert!(
1477            msg.contains(OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT),
1478            "expected stable invariant in error: {msg}"
1479        );
1480    }
1481
1482    #[test]
1483    fn record_outcome_relation_validated_refuses_missing_contributor_rule_ids() {
1484        let pool = seed_pool_with_memory();
1485        let repo = MemoryRepo::new(&pool);
1486        let policy_missing_evidence = compose_policy_outcomes(
1487            vec![
1488                PolicyContribution::new(
1489                    OUTCOME_VALIDATION_SCOPE_RULE_ID,
1490                    PolicyOutcome::Allow,
1491                    "test: scope ok",
1492                )
1493                .unwrap(),
1494                PolicyContribution::new(
1495                    OUTCOME_VALIDATING_PRINCIPAL_TIER_RULE_ID,
1496                    PolicyOutcome::Allow,
1497                    "test: tier ok",
1498                )
1499                .unwrap(),
1500                // Deliberately missing OUTCOME_EVIDENCE_REF_RULE_ID.
1501            ],
1502            None,
1503        );
1504
1505        let err = repo
1506            .record_outcome_relation(
1507                &relation_record(OutcomeMemoryRelation::Validated, true),
1508                Some(&policy_missing_evidence),
1509            )
1510            .expect_err("missing contributor must fail closed");
1511
1512        let msg = err.to_string();
1513        assert!(
1514            msg.contains(OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT),
1515            "expected stable invariant in error: {msg}"
1516        );
1517        assert!(
1518            msg.contains(OUTCOME_EVIDENCE_REF_RULE_ID),
1519            "error must name the missing contributor: {msg}"
1520        );
1521    }
1522
1523    #[test]
1524    fn record_outcome_relation_non_validation_relation_admits_without_policy() {
1525        let pool = seed_pool_with_memory();
1526        let repo = MemoryRepo::new(&pool);
1527
1528        // A `Used` edge is utility — it must not require a PolicyDecision and
1529        // must not advance `validation_epoch`.
1530        repo.record_outcome_relation(&relation_record(OutcomeMemoryRelation::Used, false), None)
1531            .expect("non-validation relation admits without policy");
1532
1533        let epoch = repo
1534            .validation_epoch_for(&"mem_01ARZ3NDEKTSV4RRFFQ69G5V40".parse().unwrap())
1535            .expect("read validation_epoch")
1536            .expect("memory row exists");
1537        assert_eq!(
1538            epoch, 0,
1539            "non-validation relation must not advance validation_epoch"
1540        );
1541    }
1542
1543    #[test]
1544    fn record_outcome_relation_non_validation_relation_refuses_attached_policy_decision() {
1545        let pool = seed_pool_with_memory();
1546        let repo = MemoryRepo::new(&pool);
1547
1548        // Attaching a policy decision to a non-validation edge is a category
1549        // error — the relation does not move the validation axis, so the
1550        // composed envelope is meaningless. Refuse so callers cannot stash an
1551        // unrelated decision on a `Used` row.
1552        let err = repo
1553            .record_outcome_relation(
1554                &relation_record(OutcomeMemoryRelation::Used, false),
1555                Some(&record_outcome_relation_policy_decision_test_allow()),
1556            )
1557            .expect_err("attached policy on non-validation must fail closed");
1558
1559        let msg = err.to_string();
1560        assert!(
1561            msg.contains(OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT),
1562            "expected stable invariant in error: {msg}"
1563        );
1564    }
1565
1566    #[test]
1567    fn record_outcome_relation_validated_advances_validation_epoch_on_happy_path() {
1568        let pool = seed_pool_with_memory();
1569        let repo = MemoryRepo::new(&pool);
1570
1571        repo.record_outcome_relation(
1572            &relation_record(OutcomeMemoryRelation::Validated, true),
1573            Some(&record_outcome_relation_policy_decision_test_allow()),
1574        )
1575        .expect("happy path admits the row");
1576
1577        let epoch = repo
1578            .validation_epoch_for(&"mem_01ARZ3NDEKTSV4RRFFQ69G5V40".parse().unwrap())
1579            .expect("read validation_epoch")
1580            .expect("memory row exists");
1581        assert_eq!(
1582            epoch, 1,
1583            "Validated relation must advance validation_epoch from 0 to 1"
1584        );
1585    }
1586
1587    #[test]
1588    fn validation_epoch_for_returns_none_for_missing_memory() {
1589        let pool = seed_pool_with_memory();
1590        let repo = MemoryRepo::new(&pool);
1591        let missing = repo
1592            .validation_epoch_for(&"mem_01ARZ3NDEKTSV4RRFFQ69G5V4Z".parse().unwrap())
1593            .expect("read validation_epoch");
1594        assert!(missing.is_none());
1595    }
1596
1597    #[test]
1598    fn cross_session_weak_negative_status_thresholds_fire_only_when_unvalidated() {
1599        // Below threshold — no weak negative.
1600        assert!(matches!(
1601            cross_session_weak_negative_status(CROSS_SESSION_USE_WEAK_NEGATIVE_THRESHOLD, 0),
1602            CrossSessionWeakNegativeStatus::BelowThreshold
1603        ));
1604        // Above threshold with no validation — invariant fires.
1605        let status =
1606            cross_session_weak_negative_status(CROSS_SESSION_USE_WEAK_NEGATIVE_THRESHOLD + 1, 0);
1607        assert!(status.is_weak_negative());
1608        assert_eq!(
1609            status.invariant(),
1610            Some(CROSS_SESSION_USE_REPEATED_UNVALIDATED_WEAK_NEGATIVE_INVARIANT)
1611        );
1612        // Above threshold with at least one Validated edge — suppressed.
1613        assert!(matches!(
1614            cross_session_weak_negative_status(CROSS_SESSION_USE_WEAK_NEGATIVE_THRESHOLD + 1, 1),
1615            CrossSessionWeakNegativeStatus::SuppressedByValidation
1616        ));
1617    }
1618
1619    #[test]
1620    fn score_inputs_validation_reads_authoritative_epoch_not_salience_json_blob() {
1621        // Phase 2.6 D3 closure: this is the unit test the audit explicitly
1622        // requests — a malicious candidate ships `salience_json.validation =
1623        // 1.0` at insert. With the new score_inputs glue, retrieval must
1624        // ignore that field and read validation_epoch instead.
1625        //
1626        // We can't import cortex-cli's private score_inputs from here, so we
1627        // reproduce the validation gate as a local helper to pin the contract.
1628        fn validation_gate(salience_json_validation: f32, validation_epoch: u32) -> f32 {
1629            // Mirrors crates/cortex-cli/src/cmd/memory.rs::score_inputs.
1630            // The blob value is intentionally ignored.
1631            let _ = salience_json_validation;
1632            if validation_epoch > 0 {
1633                1.0
1634            } else {
1635                0.0
1636            }
1637        }
1638        assert_eq!(validation_gate(1.0, 0), 0.0);
1639        assert_eq!(validation_gate(0.0, 1), 1.0);
1640        assert_eq!(validation_gate(1.0, 1), 1.0);
1641    }
1642}
1643
1644fn require_scoped_validation_payload(relation: &OutcomeMemoryRelationRecord) -> StoreResult<()> {
1645    if relation
1646        .validation_scope
1647        .as_deref()
1648        .is_none_or(str::is_empty)
1649    {
1650        return Err(StoreError::Validation(format!(
1651            "{OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT}: Validated outcome relation requires non-empty validation_scope (ADR 0020 §6)",
1652        )));
1653    }
1654    if relation
1655        .validating_principal_id
1656        .as_deref()
1657        .is_none_or(str::is_empty)
1658    {
1659        return Err(StoreError::Validation(format!(
1660            "{OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT}: Validated outcome relation requires non-empty validating_principal_id (ADR 0020 §6)",
1661        )));
1662    }
1663    if relation.evidence_ref.as_deref().is_none_or(str::is_empty) {
1664        return Err(StoreError::Validation(format!(
1665            "{OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT}: Validated outcome relation requires non-empty evidence_ref (ADR 0020 §6)",
1666        )));
1667    }
1668    Ok(())
1669}
1670
1671fn require_no_scoped_validation_payload(relation: &OutcomeMemoryRelationRecord) -> StoreResult<()> {
1672    if relation.validation_scope.is_some()
1673        || relation.validating_principal_id.is_some()
1674        || relation.evidence_ref.is_some()
1675    {
1676        return Err(StoreError::Validation(format!(
1677            "{OUTCOME_UTILITY_TO_TRUTH_PROMOTION_UNAUTHORIZED_INVARIANT}: non-validation outcome relation must leave validation_scope/validating_principal_id/evidence_ref unset",
1678        )));
1679    }
1680    Ok(())
1681}
1682
1683fn require_attestation_not_break_glassed(
1684    policy: &PolicyDecision,
1685    rule_id: &str,
1686    surface: &str,
1687) -> StoreResult<()> {
1688    // ADR 0026 §4: BreakGlass MUST NOT substitute for a required current-use
1689    // attestation contributor. The named contributor must itself have voted
1690    // either `Allow` (the surface has a bound operator key whose timeline
1691    // revalidation passed) OR `Warn` (the surface has no bound operator
1692    // key — the *honest no-attestation floor* documented at
1693    // `crates/cortex-cli/src/cmd/memory.rs::ACCEPT_OPERATOR_TEMPORAL_AUTHORITY_WARN_NO_ATTESTATION_INVARIANT`).
1694    // `Warn` is NOT a BreakGlass substitution: BreakGlass overrides a
1695    // failing attestation; the honest floor explicitly disclaims one. The
1696    // §4 wall holds because:
1697    //
1698    //   - `Allow`: real attestation passed.
1699    //   - `Warn`:  no attestation claim was made; the surface stays
1700    //              operational at the honest floor without laundering
1701    //              authority. Downstream consumers see a `Warn` final
1702    //              outcome (or `Quarantine`/`Reject` if another
1703    //              contributor failed) rather than a misleading `Allow`.
1704    //   - Anything else (`Quarantine` / `Reject` / `BreakGlass`):
1705    //              forbidden — the §4 substitution rule applies.
1706    let attestation = policy
1707        .contributing
1708        .iter()
1709        .chain(policy.discarded.iter())
1710        .find(|contribution| contribution.rule_id.as_str() == rule_id)
1711        .ok_or_else(|| {
1712            StoreError::Validation(format!(
1713                "{surface} preflight: required attestation contributor `{rule_id}` is absent from the policy decision",
1714            ))
1715        })?;
1716    match attestation.outcome {
1717        PolicyOutcome::Allow | PolicyOutcome::Warn => Ok(()),
1718        other => Err(StoreError::Validation(format!(
1719            "{surface} preflight: attestation contributor `{rule_id}` returned {other:?}; ADR 0026 §4 forbids BreakGlass substituting for attestation",
1720        ))),
1721    }
1722}
1723
1724/// ADR 0017 invariants for a newly inserted candidate row.
1725///
1726/// Cross-session salience is earned: every advancement of
1727/// `cross_session_use_count`, `last_cross_session_use_at`, or
1728/// `last_validation_at` must come from a `record_session_use` /
1729/// `record_outcome_relation` write after the candidate is admitted, never from
1730/// minting metadata at insert time. `validation_epoch` similarly only advances
1731/// through `cortex memory bless` or an attested validation. `first_used_at`
1732/// MUST be `None` because the candidate has not yet been used.
1733///
1734/// A non-zero or otherwise pre-populated salience record on insert would let an
1735/// attacker — or a buggy ingest path — back-date legitimacy. This validator
1736/// fails closed in that shape.
1737pub fn validate_candidate_cross_session_salience(
1738    salience: &CrossSessionSalience,
1739) -> StoreResult<()> {
1740    if salience.cross_session_use_count != 0 {
1741        return Err(StoreError::Validation(format!(
1742            "memory.v2.cross_session_salience preflight: candidate cross_session_use_count must be 0 at insert, observed {}",
1743            salience.cross_session_use_count,
1744        )));
1745    }
1746    if salience.first_used_at.is_some() {
1747        return Err(StoreError::Validation(
1748            "memory.v2.cross_session_salience preflight: candidate first_used_at must be unset at insert".into(),
1749        ));
1750    }
1751    if salience.last_cross_session_use_at.is_some() {
1752        return Err(StoreError::Validation(
1753            "memory.v2.cross_session_salience preflight: candidate last_cross_session_use_at must be unset at insert".into(),
1754        ));
1755    }
1756    if salience.last_validation_at.is_some() {
1757        return Err(StoreError::Validation(
1758            "memory.v2.cross_session_salience preflight: candidate last_validation_at must be unset at insert".into(),
1759        ));
1760    }
1761    if salience.validation_epoch != 0 {
1762        return Err(StoreError::Validation(format!(
1763            "memory.v2.cross_session_salience preflight: candidate validation_epoch must be 0 at insert, observed {}",
1764            salience.validation_epoch,
1765        )));
1766    }
1767    if salience.blessed_until.is_some() {
1768        return Err(StoreError::Validation(
1769            "memory.v2.cross_session_salience preflight: candidate blessed_until must be unset at insert (bless is an operator-attested post-admit action)".into(),
1770        ));
1771    }
1772    Ok(())
1773}
1774
1775/// Build the [`V2_SUMMARY_SPAN_PROOF_RULE_ID`] contributor for an
1776/// `insert_candidate_with_v2_fields` composition from a memory candidate and
1777/// its proposed summary spans.
1778///
1779/// The contributor's outcome mirrors ADR 0015's structural validator:
1780///
1781/// - Spans satisfy [`validate_summary_spans`] (or the row is non-summary with
1782///   no spans supplied) -> [`PolicyOutcome::Allow`]
1783/// - Validation fails (range/UTF-8/coverage/authority mismatch) ->
1784///   [`PolicyOutcome::Reject`]
1785///
1786/// `authority_fold` recomputes the expected `max_source_authority` for one
1787/// span from its `derived_from_event_ids`. Production callers fold the resolved
1788/// `EventSource` per ADR 0015's three-point lattice
1789/// (`User > Agent > Derived`); tests typically pass `|_| SourceAuthority::Derived`.
1790pub fn summary_span_proof_contribution<F>(
1791    memory: &MemoryCandidate,
1792    summary_spans: &[SummarySpan],
1793    authority_fold: F,
1794) -> PolicyContribution
1795where
1796    F: FnMut(&[EventId]) -> SourceAuthority,
1797{
1798    let validation = if memory.memory_type.contains("summary") || !summary_spans.is_empty() {
1799        validate_summary_spans(&memory.claim, summary_spans, authority_fold)
1800    } else {
1801        Ok(())
1802    };
1803
1804    let (outcome, reason): (PolicyOutcome, String) = match validation {
1805        Ok(()) => (
1806            PolicyOutcome::Allow,
1807            "summary spans satisfy ADR 0015 structural invariants".to_string(),
1808        ),
1809        Err(err) => (
1810            PolicyOutcome::Reject,
1811            format!(
1812                "summary spans violate ADR 0015 invariant `{}`",
1813                err.invariant()
1814            ),
1815        ),
1816    };
1817
1818    PolicyContribution::new(V2_SUMMARY_SPAN_PROOF_RULE_ID, outcome, reason)
1819        .expect("v2 summary span proof contribution shape is statically valid")
1820}
1821
1822/// Build the [`V2_CROSS_SESSION_SALIENCE_RULE_ID`] contributor for an
1823/// `insert_candidate_with_v2_fields` composition from the proposed salience
1824/// record.
1825///
1826/// The contributor's outcome mirrors
1827/// [`validate_candidate_cross_session_salience`]:
1828///
1829/// - Salience matches the ADR 0017 candidate-row invariants ->
1830///   [`PolicyOutcome::Allow`]
1831/// - Any field is pre-populated (back-dated legitimacy) ->
1832///   [`PolicyOutcome::Reject`]
1833#[must_use]
1834pub fn cross_session_salience_contribution(salience: &CrossSessionSalience) -> PolicyContribution {
1835    let (outcome, reason): (PolicyOutcome, String) =
1836        match validate_candidate_cross_session_salience(salience) {
1837            Ok(()) => (
1838                PolicyOutcome::Allow,
1839                "candidate salience matches the ADR 0017 insert-time invariants".to_string(),
1840            ),
1841            Err(err) => (PolicyOutcome::Reject, err.to_string()),
1842        };
1843
1844    PolicyContribution::new(V2_CROSS_SESSION_SALIENCE_RULE_ID, outcome, reason)
1845        .expect("v2 cross-session salience contribution shape is statically valid")
1846}
1847
1848/// Build a [`PolicyDecision`] that satisfies
1849/// [`MemoryRepo::accept_candidate`] for the happy path. Intended for tests and
1850/// fixtures only.
1851///
1852/// Production callers MUST compose [`ACCEPT_PROOF_CLOSURE_RULE_ID`],
1853/// [`ACCEPT_OPEN_CONTRADICTION_RULE_ID`], [`ACCEPT_SEMANTIC_TRUST_RULE_ID`],
1854/// and [`ACCEPT_OPERATOR_TEMPORAL_USE_RULE_ID`] from real proof closure,
1855/// resolver, admission, and ADR 0023 temporal authority evidence. This helper
1856/// is exposed unconditionally because integration test crates outside
1857/// `cortex-store` need the same fixture shape; the `_test_allow` suffix is the
1858/// contract that documents intent.
1859#[must_use]
1860pub fn accept_candidate_policy_decision_test_allow() -> PolicyDecision {
1861    use cortex_core::compose_policy_outcomes;
1862    compose_policy_outcomes(
1863        vec![
1864            PolicyContribution::new(
1865                ACCEPT_PROOF_CLOSURE_RULE_ID,
1866                PolicyOutcome::Allow,
1867                "test fixture: supporting-memory proof closure verified",
1868            )
1869            .expect("static test contribution is valid"),
1870            PolicyContribution::new(
1871                ACCEPT_OPEN_CONTRADICTION_RULE_ID,
1872                PolicyOutcome::Allow,
1873                "test fixture: no open durable contradiction on candidate slot",
1874            )
1875            .expect("static test contribution is valid"),
1876            PolicyContribution::new(
1877                ACCEPT_SEMANTIC_TRUST_RULE_ID,
1878                PolicyOutcome::Allow,
1879                "test fixture: semantic trust posture satisfied",
1880            )
1881            .expect("static test contribution is valid"),
1882            PolicyContribution::new(
1883                ACCEPT_OPERATOR_TEMPORAL_USE_RULE_ID,
1884                PolicyOutcome::Allow,
1885                "test fixture: operator temporal authority is currently valid",
1886            )
1887            .expect("static test contribution is valid"),
1888        ],
1889        None,
1890    )
1891}
1892
1893/// Build the [`ACCEPT_PROOF_CLOSURE_RULE_ID`] contributor for an
1894/// `accept_candidate` composition from a real proof-closure report.
1895///
1896/// The contributor's outcome mirrors [`ProofClosureReport::policy_decision`]:
1897///
1898/// - `FullChainVerified` -> [`PolicyOutcome::Allow`]
1899/// - `Partial` -> [`PolicyOutcome::Quarantine`]
1900/// - `Broken` -> [`PolicyOutcome::Reject`]
1901#[must_use]
1902pub fn accept_proof_closure_contribution(report: &ProofClosureReport) -> PolicyContribution {
1903    let (outcome, reason): (PolicyOutcome, &'static str) = match report.state() {
1904        ProofState::FullChainVerified => (
1905            PolicyOutcome::Allow,
1906            "supporting-memory proof closure is fully verified",
1907        ),
1908        ProofState::Partial => (
1909            PolicyOutcome::Quarantine,
1910            "supporting-memory proof closure is partial; promotion fails closed",
1911        ),
1912        ProofState::Broken => (
1913            PolicyOutcome::Reject,
1914            "supporting-memory proof closure is broken; promotion fails closed",
1915        ),
1916    };
1917    PolicyContribution::new(ACCEPT_PROOF_CLOSURE_RULE_ID, outcome, reason)
1918        .expect("static proof closure contribution shape is valid")
1919}
1920
1921/// Build the [`ACCEPT_OPEN_CONTRADICTION_RULE_ID`] contributor for an
1922/// `accept_candidate` composition.
1923///
1924/// Pass the number of open durable contradictions (status `unresolved` or
1925/// `interpreted`) that touch the candidate's claim slot. The contributor maps:
1926///
1927/// - `0` open contradictions -> [`PolicyOutcome::Allow`]
1928/// - any positive count -> [`PolicyOutcome::Reject`]
1929///
1930/// ADR 0024 §3 says a `ConflictUnresolved` slot MUST NOT silently allow
1931/// default promotion. Callers compute the count from a
1932/// [`super::ContradictionRepo`] query.
1933#[must_use]
1934pub fn accept_open_contradiction_contribution(open_contradictions: usize) -> PolicyContribution {
1935    let (outcome, reason): (PolicyOutcome, String) = if open_contradictions == 0 {
1936        (
1937            PolicyOutcome::Allow,
1938            "no open durable contradiction touches the candidate slot".to_string(),
1939        )
1940    } else {
1941        (
1942            PolicyOutcome::Reject,
1943            format!(
1944                "{open_contradictions} open durable contradiction(s) touch the candidate slot; ADR 0024 forbids silent promotion"
1945            ),
1946        )
1947    };
1948    PolicyContribution::new(ACCEPT_OPEN_CONTRADICTION_RULE_ID, outcome, reason)
1949        .expect("static open contradiction contribution shape is valid")
1950}
1951
1952/// Build the [`ACCEPT_OPERATOR_TEMPORAL_USE_RULE_ID`] contributor for an
1953/// `accept_candidate` composition from a real temporal-authority report.
1954///
1955/// The contributor's outcome mirrors the temporal authority's own
1956/// `policy_decision` outcome:
1957///
1958/// - `valid_now` -> [`PolicyOutcome::Allow`]
1959/// - `valid_at_event_time && !valid_now` -> [`PolicyOutcome::Quarantine`]
1960///   (historical signature, current use blocked)
1961/// - otherwise -> [`PolicyOutcome::Reject`]
1962///
1963/// Per ADR 0026 §4 [`MemoryRepo::accept_candidate`] requires this contributor
1964/// to be `Allow`: break-glass MUST NOT substitute for current-use temporal
1965/// authority at the durable acceptance surface.
1966#[must_use]
1967pub fn accept_operator_temporal_use_contribution(
1968    report: &TemporalAuthorityReport,
1969) -> PolicyContribution {
1970    let (outcome, reason): (PolicyOutcome, &'static str) = if report.valid_now {
1971        (
1972            PolicyOutcome::Allow,
1973            "operator temporal authority is currently valid",
1974        )
1975    } else if report.valid_at_event_time {
1976        (
1977            PolicyOutcome::Quarantine,
1978            "operator temporal authority is historical only; current use blocked",
1979        )
1980    } else {
1981        (
1982            PolicyOutcome::Reject,
1983            "operator temporal authority was invalid at event time",
1984        )
1985    };
1986    PolicyContribution::new(ACCEPT_OPERATOR_TEMPORAL_USE_RULE_ID, outcome, reason)
1987        .expect("static operator temporal use contribution shape is valid")
1988}
1989
1990/// Build a [`PolicyDecision`] that satisfies
1991/// [`MemoryRepo::record_outcome_relation`] for a `Validated` write on the
1992/// happy path. Intended for tests and fixtures only.
1993///
1994/// Production callers MUST compose
1995/// [`OUTCOME_VALIDATION_SCOPE_RULE_ID`],
1996/// [`OUTCOME_VALIDATING_PRINCIPAL_TIER_RULE_ID`], and
1997/// [`OUTCOME_EVIDENCE_REF_RULE_ID`] from real ADR 0020 §5/§6 evidence — the
1998/// validating principal's current trust tier, the scope under which the
1999/// validation applies, and a concrete evidence reference (audit row, event,
2000/// attestation digest). This helper is exposed unconditionally because tests
2001/// outside `cortex-store` need the same fixture shape.
2002#[must_use]
2003pub fn record_outcome_relation_policy_decision_test_allow() -> PolicyDecision {
2004    use cortex_core::compose_policy_outcomes;
2005    compose_policy_outcomes(
2006        vec![
2007            PolicyContribution::new(
2008                OUTCOME_VALIDATION_SCOPE_RULE_ID,
2009                PolicyOutcome::Allow,
2010                "test fixture: validation scope declared and recorded on the relation row",
2011            )
2012            .expect("static test contribution is valid"),
2013            PolicyContribution::new(
2014                OUTCOME_VALIDATING_PRINCIPAL_TIER_RULE_ID,
2015                PolicyOutcome::Allow,
2016                "test fixture: validating principal trust tier meets ADR 0020 §5 gate",
2017            )
2018            .expect("static test contribution is valid"),
2019            PolicyContribution::new(
2020                OUTCOME_EVIDENCE_REF_RULE_ID,
2021                PolicyOutcome::Allow,
2022                "test fixture: evidence_ref cites concrete attestation/audit row",
2023            )
2024            .expect("static test contribution is valid"),
2025        ],
2026        None,
2027    )
2028}
2029
2030/// Build a [`PolicyDecision`] that satisfies
2031/// [`MemoryRepo::insert_candidate_with_v2_fields`] for the happy path.
2032/// Intended for tests and fixtures only.
2033///
2034/// Production callers MUST fold honest
2035/// [`summary_span_proof_contribution`] and
2036/// [`cross_session_salience_contribution`] outcomes into the composition
2037/// instead of these `Allow` placeholders.
2038#[must_use]
2039pub fn insert_candidate_v2_policy_decision_test_allow() -> PolicyDecision {
2040    use cortex_core::compose_policy_outcomes;
2041    compose_policy_outcomes(
2042        vec![
2043            PolicyContribution::new(
2044                V2_SUMMARY_SPAN_PROOF_RULE_ID,
2045                PolicyOutcome::Allow,
2046                "test fixture: summary spans validated",
2047            )
2048            .expect("static test contribution is valid"),
2049            PolicyContribution::new(
2050                V2_CROSS_SESSION_SALIENCE_RULE_ID,
2051                PolicyOutcome::Allow,
2052                "test fixture: candidate salience matches insert-time invariants",
2053            )
2054            .expect("static test contribution is valid"),
2055        ],
2056        None,
2057    )
2058}
2059
2060fn ensure_memory_exists(tx: &rusqlite::Transaction<'_>, id: &MemoryId) -> StoreResult<()> {
2061    let exists = tx
2062        .query_row(
2063            "SELECT 1 FROM memories WHERE id = ?1;",
2064            params![id.to_string()],
2065            |_| Ok(()),
2066        )
2067        .optional()?
2068        .is_some();
2069    if !exists {
2070        return Err(StoreError::Validation(format!("memory {id} not found")));
2071    }
2072    Ok(())
2073}
2074
2075fn reconcile_memory_session_salience(
2076    tx: &rusqlite::Transaction<'_>,
2077    id: &MemoryId,
2078) -> StoreResult<()> {
2079    tx.execute(
2080        "UPDATE memories
2081         SET cross_session_use_count = (
2082                SELECT COALESCE(SUM(use_count), 0) FROM memory_session_uses WHERE memory_id = ?1
2083             ),
2084             first_used_at = (
2085                SELECT MIN(first_used_at) FROM memory_session_uses WHERE memory_id = ?1
2086             ),
2087             last_cross_session_use_at = (
2088                SELECT MAX(last_used_at) FROM memory_session_uses WHERE memory_id = ?1
2089             ),
2090             validation_epoch = COALESCE(validation_epoch, 0)
2091         WHERE id = ?1;",
2092        params![id.to_string()],
2093    )?;
2094    Ok(())
2095}
2096
2097#[derive(Debug)]
2098struct MemoryRow {
2099    id: String,
2100    memory_type: String,
2101    status: String,
2102    claim: String,
2103    source_episodes_json: String,
2104    source_events_json: String,
2105    domains_json: String,
2106    salience_json: String,
2107    confidence: f64,
2108    authority: String,
2109    applies_when_json: String,
2110    does_not_apply_when_json: String,
2111    created_at: String,
2112    updated_at: String,
2113}
2114
2115fn memory_row(row: &Row<'_>) -> rusqlite::Result<MemoryRow> {
2116    Ok(MemoryRow {
2117        id: row.get(0)?,
2118        memory_type: row.get(1)?,
2119        status: row.get(2)?,
2120        claim: row.get(3)?,
2121        source_episodes_json: row.get(4)?,
2122        source_events_json: row.get(5)?,
2123        domains_json: row.get(6)?,
2124        salience_json: row.get(7)?,
2125        confidence: row.get(8)?,
2126        authority: row.get(9)?,
2127        applies_when_json: row.get(10)?,
2128        does_not_apply_when_json: row.get(11)?,
2129        created_at: row.get(12)?,
2130        updated_at: row.get(13)?,
2131    })
2132}
2133
2134impl TryFrom<MemoryRow> for MemoryRecord {
2135    type Error = StoreError;
2136
2137    fn try_from(row: MemoryRow) -> StoreResult<Self> {
2138        Ok(Self {
2139            id: row.id.parse()?,
2140            memory_type: row.memory_type,
2141            status: row.status,
2142            claim: row.claim,
2143            source_episodes_json: serde_json::from_str(&row.source_episodes_json)?,
2144            source_events_json: serde_json::from_str(&row.source_events_json)?,
2145            domains_json: serde_json::from_str(&row.domains_json)?,
2146            salience_json: serde_json::from_str(&row.salience_json)?,
2147            confidence: row.confidence,
2148            authority: row.authority,
2149            applies_when_json: serde_json::from_str(&row.applies_when_json)?,
2150            does_not_apply_when_json: serde_json::from_str(&row.does_not_apply_when_json)?,
2151            created_at: DateTime::parse_from_rfc3339(&row.created_at)?.with_timezone(&Utc),
2152            updated_at: DateTime::parse_from_rfc3339(&row.updated_at)?.with_timezone(&Utc),
2153        })
2154    }
2155}