Skip to main content

mati_core/store/
record.rs

1//! Core data types for the mati knowledge store.
2//!
3//! All types in this module are the canonical definitions used throughout
4//! every layer of mati (storage, graph, search, MCP, CLI). Do not redefine
5//! these elsewhere — import from `mati_core::store`.
6//!
7//! Key namespacing convention:
8//! ```text
9//! gotcha:<slug>          file:<path>          decision:<slug>
10//! stage:current          dep:<ecosystem>:<name> dev_note:<slug>
11//! session:<timestamp>    analytics:<type>_<date>  policy:<slug>
12//! graph:edge:<from>:<kind>:<to>
13//! ```
14//!
15//! # Float equality note
16//!
17//! Structs containing `f32` score fields (`QualityScore`, `StalenessScore`,
18//! `ConfidenceScore`, and anything that embeds them) intentionally do **not**
19//! derive `PartialEq`. Floating-point arithmetic produces values that are
20//! semantically equal but bitwise distinct, making derived `==` a footgun for
21//! computed scores. Use field-level epsilon comparison in tests and comparators.
22
23use std::collections::BTreeMap;
24
25use serde::{Deserialize, Serialize};
26use serde_json::Value as JsonValue;
27use uuid::Uuid;
28
29// ─────────────────────────────────────────────
30// Primitive aliases
31// ─────────────────────────────────────────────
32
33/// UUID v7 generated once on first use, persisted at `~/.mati/device_id`
34/// (`store::device::stable_device_id`). Stamps every record write for
35/// Lamport-clock conflict resolution.
36///
37/// Requires the `uuid` crate with `features = ["v4", "v7"]`.
38/// NOTE: records written by pre-device-id binaries carry per-record v4
39/// placeholders — the v0.2 `MergeEngine` must treat those as
40/// attribution-unknown, not as distinct devices.
41pub type DeviceId = Uuid;
42
43// ─────────────────────────────────────────────
44// Enums — record metadata
45// ─────────────────────────────────────────────
46
47/// Which layer of mati produced this record.
48#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
49#[serde(rename_all = "snake_case")]
50pub enum RecordSource {
51    /// tree-sitter, git, dep parsing — Layer 0
52    StaticAnalysis,
53    /// `mati enrich` batch — Layer 1
54    ClaudeEnrich,
55    /// session-end harvest — Layer 2
56    SessionHook,
57    /// `mati gotcha add` / `mati note`
58    DeveloperManual,
59    /// `mati import` (CLAUDE.md or JSON)
60    Import,
61}
62
63/// Which agent issued a daemon request, used for attribution in
64/// `MutationEvent` and `Record.created_by` / `Record.last_modified_by`.
65///
66/// Client-declared, not server-verified — same-UID processes are trusted
67/// (THREAT_MODEL.md section 3.C, section 3.I; ADR-018). The daemon stamps `pid` from
68/// `SO_PEERCRED` separately; this enum is the human/tool side of attribution.
69#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
70#[serde(rename_all = "snake_case")]
71pub enum AgentKind {
72    /// MCP stdio client (Claude Code's rmcp transport).
73    Claude,
74    /// Codex hooks (`codex-*` variants).
75    Codex,
76    /// Direct CLI invocation by the developer.
77    Cli,
78    /// Daemon-internal operations (e.g. repair on startup, periodic
79    /// reparse). Stamped server-side, never client-declared.
80    Supervisor,
81    /// Attribution unknown or pre-v2 record.
82    Unknown,
83}
84
85/// Semantic category of a record. Determines key prefix and injection behaviour.
86#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
87#[serde(rename_all = "snake_case")]
88pub enum Category {
89    Gotcha,
90    File,
91    Decision,
92    Stage,
93    Dependency,
94    DevNote,
95    Session,
96    Analytics,
97    Policy,
98}
99
100/// Severity / importance ranking.
101///
102/// Derived `Ord`: `Low(0) < Normal(1) < High(2) < Critical(3)`.
103///
104/// **Do not reorder variants.** The derived ordering depends on declaration
105/// position. Reordering silently inverts all priority comparisons.
106#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
107#[serde(rename_all = "snake_case")]
108pub enum Priority {
109    Low,
110    Normal,
111    High,
112    Critical,
113}
114
115// ─────────────────────────────────────────────
116// Quality scoring
117// ─────────────────────────────────────────────
118
119/// Computed tier from `QualityScore::value` (half-open intervals):
120///
121/// ```text
122/// Suppressed  [0.0, 0.2)   never injected — worse than nothing
123/// Poor        [0.2, 0.4)   injected with "[mati] LOW QUALITY — verify"
124/// Acceptable  [0.4, 0.7)   injected normally
125/// Good        [0.7, 0.9)   prioritised in bootstrap
126/// Excellent   [0.9, 1.0]   used as template in `mati garden`
127/// ```
128#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
129#[serde(rename_all = "snake_case")]
130pub enum QualityTier {
131    Suppressed,
132    Poor,
133    Acceptable,
134    Good,
135    Excellent,
136}
137
138/// Individual signals that raise or lower the computed quality score.
139#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
140#[serde(rename_all = "snake_case")]
141pub enum QualitySignal {
142    // ── Positive ────────────────────────────
143    HasImperativeVerb,
144    HasCausality,
145    HasSeveritySet,
146    HasReference,
147    RuleLengthAdequate,
148    ReasonLengthAdequate,
149    AffectedFilesSpecified,
150    HasSpecificIdentifier,
151    // ── Negative (penalties) ────────────────
152    VaguePhrasing,
153    NoActionableRule,
154    NoReason,
155    TooShort,
156    DuplicatesFilePurpose,
157}
158
159/// Composite quality score for a [`Record`].
160///
161/// Formula (ARCHITECTURE.md section 5):
162/// ```text
163/// quality =
164///   has_imperative_verb  × 0.20
165///   + has_causality      × 0.25
166///   + has_severity       × 0.10
167///   + has_reference      × 0.15
168///   + length_score       × 0.15
169///   + specificity_score  × 0.15
170///
171/// penalties:
172///   vague_phrase_detected → × 0.5
173///   no_reason             → × 0.6
174///   too_short             → × 0.4
175/// ```
176/// Layer 0 `StaticAnalysis` records default to `0.10` (Suppressed).
177/// Recomputed by `RecordQualityAnalyzer` on every write and `mati enrich`.
178///
179/// Does **not** derive `PartialEq` — see module-level float equality note.
180#[derive(Serialize, Deserialize, Debug, Clone)]
181pub struct QualityScore {
182    /// 0.0 (useless) → 1.0 (Claude-optimal)
183    pub value: f32,
184    pub tier: QualityTier,
185    pub signals: Vec<QualitySignal>,
186    /// Unix timestamp (seconds) when this score was last computed.
187    /// `0` = not yet computed (sentinel).
188    pub computed_at: u64,
189}
190
191impl QualityScore {
192    /// Default for a Layer 0 `StaticAnalysis` stub — Suppressed, never injected.
193    pub fn layer0_default() -> Self {
194        Self {
195            value: 0.10,
196            tier: QualityTier::Suppressed,
197            signals: vec![],
198            computed_at: 0,
199        }
200    }
201
202    /// Quality for a file record whose purpose was extracted from a language-
203    /// canonical doc comment (Rust `//!`, Go `// Package`, Python docstring).
204    ///
205    /// `Acceptable` tier (0.40) passes the `quality >= 0.4` injection gate.
206    /// Paired with `confidence = 0.45` in `init.rs`, these records surface as
207    /// `additionalContext` (allow + attach) rather than deny + inject.
208    pub fn doc_comment_default() -> Self {
209        Self {
210            value: 0.40,
211            tier: QualityTier::Acceptable,
212            signals: vec![],
213            computed_at: 0,
214        }
215    }
216
217    /// Quality for an auto-generated co-change gotcha (normal signal).
218    ///
219    /// `Acceptable` tier (0.40): passes quality gate.
220    /// Paired with `confidence = 0.45` (0.3–0.6 band) → additionalContext injection.
221    /// `confirmed: true` is set on the gotcha because co-change is objective git data,
222    /// but the confidence band keeps it out of the deny+inject path.
223    /// Quality for a developer-manually-added record (`mati gotcha add`, `mati note`).
224    ///
225    /// `Good` tier (0.65): developer is explicitly asserting the record is important.
226    /// Paired with `DeveloperManual` confidence (0.80) + `confirmed=true` → deny+inject path.
227    pub fn developer_entry_default() -> Self {
228        Self {
229            value: 0.65,
230            tier: QualityTier::Good,
231            signals: vec![],
232            computed_at: 0,
233        }
234    }
235
236    pub fn cochange_default() -> Self {
237        Self {
238            value: 0.40,
239            tier: QualityTier::Acceptable,
240            signals: vec![],
241            computed_at: 0,
242        }
243    }
244
245    /// Quality for a strong co-change gotcha (ratio >= 0.90 AND count >= 20).
246    ///
247    /// `Acceptable` tier (0.60): passes quality gate.
248    /// Paired with `confidence = 0.65` → deny+inject path.
249    /// A near-perfect co-change ratio over 20+ commits is strong enough evidence
250    /// that Claude should be forced to see the coupling before editing either file.
251    pub fn cochange_strong() -> Self {
252        Self {
253            value: 0.60,
254            tier: QualityTier::Acceptable,
255            signals: vec![],
256            computed_at: 0,
257        }
258    }
259
260    /// Derive `QualityTier` from a raw score value (half-open intervals).
261    ///
262    /// ```text
263    /// [0.0, 0.2) → Suppressed
264    /// [0.2, 0.4) → Poor
265    /// [0.4, 0.7) → Acceptable
266    /// [0.7, 0.9) → Good
267    /// [0.9, 1.0] → Excellent
268    /// ```
269    pub fn tier_from_value(value: f32) -> QualityTier {
270        // Non-finite values (NaN, ±∞) would pass all comparisons silently
271        // and land in the else-Excellent branch — a hook-injection security bug.
272        if !value.is_finite() || value < 0.2 {
273            QualityTier::Suppressed
274        } else if value < 0.4 {
275            QualityTier::Poor
276        } else if value < 0.7 {
277            QualityTier::Acceptable
278        } else if value < 0.9 {
279            QualityTier::Good
280        } else {
281            QualityTier::Excellent
282        }
283    }
284}
285
286// ─────────────────────────────────────────────
287// Staleness scoring
288// ─────────────────────────────────────────────
289
290/// Staleness tier — determines injection behaviour, not enforcement.
291///
292/// At `Liability` and `Tombstone`, the file record's own content is degraded
293/// or excluded from injection entirely — trusting a wrong record silently is
294/// a worse failure mode than a cache miss (ARCHITECTURE.md section 17). A
295/// confirmed gotcha still denies at either tier: `hooks::decide::evaluate`
296/// runs the gotcha loop before it ever checks the tier (section 10.1). The
297/// one unconditional pass-through is the literal `FileDeleted` signal, not
298/// the tier by itself.
299///
300/// Sync merge rule: `Tombstone > Liability > Stale > Aging > Fresh`.
301#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
302#[serde(rename_all = "snake_case")]
303pub enum StalenessTier {
304    Fresh,
305    Aging,
306    Stale,
307    /// Injection degrades to a read-the-file warning. Enforcement unaffected.
308    Liability,
309    /// Injection fully excluded. Enforcement unaffected unless the record
310    /// also carries a `FileDeleted` signal.
311    Tombstone,
312}
313
314/// Individual signals that feed the staleness composite score.
315///
316/// Derives `PartialEq` (not `Eq`) because `LinesChangedPct(f32)` contains f32.
317#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
318#[serde(rename_all = "snake_case")]
319pub enum StalenessSignal {
320    NotAccessedDays(u32),
321    /// Percentage of lines changed since last confirmation (0.0–1.0).
322    LinesChangedPct(f32),
323    EntryPointsChanged(u32),
324    ImportsChanged(u32),
325    FileDeleted,
326    FileRenamed {
327        new_path: String,
328    },
329    DependencyBumped {
330        dep: String,
331        old_ver: String,
332        new_ver: String,
333    },
334    LinkedFileChanged {
335        path: String,
336    },
337    /// Another decision or gotcha this record depends on was modified.
338    CascadeFromDecision(String),
339    /// TODOs were added, removed, or changed.
340    TodosChanged,
341    /// Net change in `unsafe` block count (positive = added, negative = removed).
342    UnsafeCountChanged(i32),
343    /// Net change in `.unwrap()` call count (positive = added, negative = removed).
344    UnwrapCountChanged(i32),
345    /// Number of commits touching this file since last staleness confirmation.
346    GitCommitsSince(u32),
347}
348
349impl std::fmt::Display for StalenessSignal {
350    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
351        match self {
352            Self::NotAccessedDays(d) => write!(f, "not accessed for {d} days"),
353            Self::LinesChangedPct(pct) => write!(f, "{:.0}% of lines changed", pct * 100.0),
354            Self::EntryPointsChanged(n) => write!(f, "{n} entry points changed"),
355            Self::ImportsChanged(n) => write!(f, "{n} imports changed"),
356            Self::FileDeleted => write!(f, "source file deleted"),
357            Self::FileRenamed { new_path } => write!(f, "file renamed to {new_path}"),
358            Self::DependencyBumped {
359                dep,
360                old_ver,
361                new_ver,
362            } => write!(f, "{dep} bumped {old_ver} \u{2192} {new_ver}"),
363            Self::LinkedFileChanged { path } => write!(f, "linked file {path} changed"),
364            Self::CascadeFromDecision(key) => write!(f, "cascaded from {key}"),
365            Self::TodosChanged => write!(f, "TODOs changed"),
366            Self::UnsafeCountChanged(delta) => write!(f, "unsafe count changed by {delta}"),
367            Self::UnwrapCountChanged(delta) => write!(f, "unwrap count changed by {delta}"),
368            Self::GitCommitsSince(n) => write!(f, "{n} commits since last confirmation"),
369        }
370    }
371}
372
373/// Replaces the flat `stale: bool` with a scored, tiered system.
374///
375/// Formula (ARCHITECTURE.md section 17):
376/// ```text
377/// staleness =
378///   time_factor       × 0.20
379///   + git_factor      × 0.35
380///   + semantic_factor × 0.25
381///   + dep_factor      × 0.10
382///   + cascade_factor  × 0.10
383/// ```
384/// Hard overrides:
385/// - `FileDeleted`  → `Tombstone` (1.0)
386/// - `FileRenamed`  → `Liability` (0.85) until path is corrected
387///
388/// Does **not** derive `PartialEq` — see module-level float equality note.
389#[derive(Serialize, Deserialize, Debug, Clone)]
390pub struct StalenessScore {
391    /// 0.0 (completely fresh) → 1.0 (tombstone)
392    pub value: f32,
393    pub tier: StalenessTier,
394    pub signals: Vec<StalenessSignal>,
395    /// Unix timestamp (seconds) when this score was last computed.
396    /// `0` = not yet computed (sentinel).
397    pub computed_at: u64,
398    /// Git SHA of the source file at the time this record was last confirmed.
399    /// Empty string = not yet established.
400    pub last_record_sha: String,
401}
402
403impl StalenessScore {
404    /// Fresh record with no signals — used when a record is first created.
405    pub fn fresh() -> Self {
406        Self {
407            value: 0.0,
408            tier: StalenessTier::Fresh,
409            signals: vec![],
410            computed_at: 0,
411            last_record_sha: String::new(),
412        }
413    }
414
415    /// Derive `StalenessTier` from a raw score value (half-open intervals).
416    ///
417    /// ```text
418    /// [0.0, 0.2) → Fresh
419    /// [0.2, 0.4) → Aging
420    /// [0.4, 0.7) → Stale
421    /// [0.7, 0.9) → Liability
422    /// [0.9, 1.0] → Tombstone
423    /// ```
424    pub fn tier_from_value(value: f32) -> StalenessTier {
425        if !value.is_finite() {
426            return StalenessTier::Stale;
427        }
428        if value < 0.2 {
429            StalenessTier::Fresh
430        } else if value < 0.4 {
431            StalenessTier::Aging
432        } else if value < 0.7 {
433            StalenessTier::Stale
434        } else if value < 0.9 {
435            StalenessTier::Liability
436        } else {
437            StalenessTier::Tombstone
438        }
439    }
440}
441
442// ─────────────────────────────────────────────
443// Confidence scoring
444// ─────────────────────────────────────────────
445
446/// How much the system trusts this record's accuracy.
447///
448/// Formula (ARCHITECTURE.md section 13.1):
449/// ```text
450/// base_score:
451///   DeveloperManual → 0.80
452///   Import          → 0.70
453///   ClaudeEnrich    → 0.60
454///   SessionHook     → 0.50
455///   StaticAnalysis  → 0.10
456///
457/// confidence = base_score
458///   × log2(confirmation_count + 2)
459///   × contributor_factor              1.0 solo, +0.1 per extra, capped at 3
460///   × recency_weight(last_accessed)   90-day half-life
461///   × ref_boost                       1.5× if ref_url set
462/// ```
463/// `health::confidence::recompute` implements this and currently has no
464/// callers — every stored value comes from `for_new_record`. Wiring it into
465/// `mem_get` is planned, not done.
466///
467/// Hook injection thresholds:
468/// ```text
469/// >= 0.6 + confirmed  → deny file read, inject record
470/// 0.3 – 0.6           → allow read + attach as additionalContext
471/// < 0.3               → allow read, no injection
472/// ```
473///
474/// Does **not** derive `PartialEq` — see module-level float equality note.
475#[derive(Serialize, Deserialize, Debug, Clone)]
476pub struct ConfidenceScore {
477    /// 0.0 → 1.0
478    pub value: f32,
479    /// How many times this record has been explicitly confirmed correct.
480    pub confirmation_count: u32,
481    /// How many distinct contributors have written or confirmed this record.
482    pub contributor_count: u32,
483    /// Unix timestamp of the last time this record was challenged or disputed.
484    pub last_challenged: Option<u64>,
485    pub challenge_count: u32,
486}
487
488impl ConfidenceScore {
489    /// Initial confidence value for a freshly created record by source type.
490    pub fn base_for_source(source: &RecordSource) -> f32 {
491        match source {
492            RecordSource::DeveloperManual => 0.80,
493            RecordSource::Import => 0.70,
494            RecordSource::ClaudeEnrich => 0.60,
495            RecordSource::SessionHook => 0.50,
496            RecordSource::StaticAnalysis => 0.10,
497        }
498    }
499
500    /// Construct a [`ConfidenceScore`] for a newly created record.
501    ///
502    /// Sets `value` from `base_for_source` and zeros all counters. Use this
503    /// instead of constructing manually to prevent `value` from diverging from
504    /// the source-derived base.
505    pub fn for_new_record(source: &RecordSource) -> Self {
506        Self {
507            value: Self::base_for_source(source),
508            confirmation_count: 0,
509            contributor_count: 1,
510            last_challenged: None,
511            challenge_count: 0,
512        }
513    }
514}
515
516// ─────────────────────────────────────────────
517// Record lifecycle
518// ─────────────────────────────────────────────
519
520/// Why a record was tombstoned.
521#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
522#[serde(rename_all = "snake_case")]
523pub enum TombstoneReason {
524    FileDeleted,
525    FileRenamed {
526        new_path: String,
527    },
528    ManualDeletion,
529    Superseded,
530    /// Retired by a schema migration, not by anyone's decision.
531    MigrationRepair,
532}
533
534/// Current lifecycle state of a record.
535///
536/// Sync merge rule: `Tombstoned > Superseded > Active` (severity wins).
537#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
538#[serde(rename_all = "snake_case")]
539pub enum RecordLifecycle {
540    Active,
541    Tombstoned { reason: TombstoneReason, at: u64 },
542    Superseded { by_key: String },
543}
544
545// ─────────────────────────────────────────────
546// Sync / versioning
547// ─────────────────────────────────────────────
548
549/// Lamport clock + wall clock per record write.
550///
551/// Wall clock is **never** used for conflict ordering — only for display.
552/// All ordering uses `logical_clock` (see ARCHITECTURE.md section 20).
553#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
554pub struct RecordVersion {
555    /// UUID v7, generated once per device at `mati init`.
556    pub device_id: DeviceId,
557    /// Lamport clock — incremented on every local write.
558    pub logical_clock: u64,
559    /// Wall clock at time of write — display only, never for conflict ordering.
560    pub wall_clock: u64,
561}
562
563// ─────────────────────────────────────────────
564// Universal record
565// ─────────────────────────────────────────────
566
567/// The universal store entry. All categories (gotcha, file, decision, …)
568/// share this struct. Category-specific detail is in `value` (human-readable)
569/// and in the typed `FileRecord` / `GotchaRecord` for Layer 0/1 fast paths.
570///
571/// Does **not** derive `PartialEq` — see module-level float equality note.
572///
573/// Key namespacing:
574/// ```text
575/// gotcha:<slug>     file:<path>     decision:<slug>
576/// stage:current     dep:<ecosystem>:<name> dev_note:<slug>
577/// ```
578#[derive(Serialize, Deserialize, Debug, Clone)]
579pub struct Record {
580    /// Namespaced key — primary storage identifier and graph node key.
581    pub key: String,
582    /// Human-readable content: purpose (file), rule (gotcha), body (decision).
583    /// Indexed by tantivy for full-text search.
584    pub value: String,
585    pub category: Category,
586    pub priority: Priority,
587    /// Free-form tags for search and filtering.
588    pub tags: Vec<String>,
589    /// Unix timestamp (seconds) when this record was first created.
590    pub created_at: u64,
591    /// Unix timestamp (seconds) of the last write.
592    pub updated_at: u64,
593    /// URL to a PR, issue, doc, or incident that explains this record.
594    pub ref_url: Option<String>,
595    pub staleness: StalenessScore,
596    pub lifecycle: RecordLifecycle,
597    /// Versioning for Lamport-clock conflict resolution (see [`RecordVersion`]).
598    /// Use `record.version.device_id` to identify the authoring device.
599    pub version: RecordVersion,
600    pub quality: QualityScore,
601    /// How many times this record has been read via `mem_get` or hooks.
602    pub access_count: u32,
603    /// Unix timestamp (seconds) of the last access.
604    pub last_accessed: u64,
605    pub source: RecordSource,
606    pub confidence: ConfidenceScore,
607    /// Pre-computed gap risk score: `change_frequency × (1 - coverage_score)`.
608    pub gap_analysis_score: f32,
609    /// Structured per-category payload — typed data in JSON form.
610    ///
611    /// - `file:*`     → `FileRecord`
612    /// - `gotcha:*`   → `GotchaRecord`
613    /// - `decision:*` → serialized decision body (TBD Layer 1)
614    /// - `analytics:*`, `session:*` → arbitrary JSON blob (DailyAgg, StaleReviewPayload, …)
615    ///
616    /// `value` is always the human-readable text: rule, purpose, body.
617    /// `payload` carries all structured fields so read sites never parse `value` as JSON.
618    /// Stored as-is in MessagePack (serde_json::Value → msgpack map).
619    #[serde(default)]
620    pub payload: Option<JsonValue>,
621}
622
623impl Record {
624    /// The device that last wrote this record.
625    ///
626    /// Convenience accessor — delegates to `self.version.device_id`.
627    pub fn device_id(&self) -> DeviceId {
628        self.version.device_id
629    }
630
631    /// Deserialize the structured payload into a typed value.
632    ///
633    /// Returns `None` when `payload` is absent or the JSON shape does not match `T`.
634    /// Always prefer this over `serde_json::from_str(&self.value)`.
635    pub fn payload_as<T: serde::de::DeserializeOwned>(&self) -> Option<T> {
636        self.payload
637            .as_ref()
638            .and_then(|p| serde_json::from_value(p.clone()).ok())
639    }
640
641    /// Construct a layer-0 file stub for `file:<path>`.
642    ///
643    /// This is the persisted companion to [`FileRecord::layer0_stub`].
644    /// Layer 0 file records start empty on purpose/value, but still get the
645    /// suppressed quality default so they never surface in Claude-facing
646    /// injection paths until enrichment raises them.
647    pub fn layer0_file_stub(
648        key: impl Into<String>,
649        device_id: DeviceId,
650        logical_clock: u64,
651        wall_clock: u64,
652    ) -> Self {
653        Self {
654            key: key.into(),
655            value: String::new(),
656            category: Category::File,
657            priority: Priority::Normal,
658            tags: vec![],
659            created_at: wall_clock,
660            updated_at: wall_clock,
661            ref_url: None,
662            staleness: StalenessScore::fresh(),
663            lifecycle: RecordLifecycle::Active,
664            version: RecordVersion {
665                device_id,
666                logical_clock,
667                wall_clock,
668            },
669            quality: QualityScore::layer0_default(),
670            access_count: 0,
671            last_accessed: 0,
672            source: RecordSource::StaticAnalysis,
673            confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
674            gap_analysis_score: 0.0,
675            payload: None,
676        }
677    }
678}
679
680// ─────────────────────────────────────────────
681// File record
682// ─────────────────────────────────────────────
683
684/// Kind of inline developer comment extracted by tree-sitter.
685#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
686#[serde(rename_all = "snake_case")]
687pub enum TodoKind {
688    Todo,
689    Fixme,
690    Hack,
691    Note,
692    Deprecated,
693}
694
695/// A TODO/FIXME/HACK comment extracted from source code by tree-sitter.
696#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
697pub struct TodoComment {
698    pub text: String,
699    pub line: u32,
700    pub kind: TodoKind,
701}
702
703/// Per-file knowledge — stored under `file:<path>`, linked to `gotcha:*`
704/// and `decision:*` via graph edges.
705///
706/// Does **not** derive `PartialEq` — contains `token_cost_estimate` which
707/// may be computed and is not meaningful to compare directly.
708#[derive(Serialize, Deserialize, Debug, Clone)]
709pub struct FileRecord {
710    pub path: String,
711    /// One-sentence purpose extracted by Layer 1 enrichment. Empty at Layer 0.
712    pub purpose: String,
713    /// Public functions / types / entry points visible from other modules.
714    pub entry_points: Vec<String>,
715    /// Import / use paths found by tree-sitter.
716    pub imports: Vec<String>,
717    /// Keys of associated `gotcha:*` records.
718    pub gotcha_keys: Vec<String>,
719    /// Keys of associated `decision:*` records.
720    pub decision_keys: Vec<String>,
721    pub todos: Vec<TodoComment>,
722    pub unsafe_count: u32,
723    pub unwrap_count: u32,
724    /// Commit count touching this file (from git2, capped at 5 000 most recent non-merge commits).
725    pub change_frequency: u32,
726    pub last_author: Option<String>,
727    /// True when `change_frequency` puts this file in the top 10% of the repo.
728    pub is_hotspot: bool,
729    /// Rough token count estimate for `mem_bootstrap` budget enforcement.
730    pub token_cost_estimate: u32,
731    /// Session timestamp of the last time this record was updated.
732    pub last_modified_session: u64,
733    /// SHA-256 hex digest of file content at the time of last Layer 0 scan.
734    /// `None` for non-parseable files or the first scan (no stored baseline).
735    #[serde(default)]
736    pub content_hash: Option<String>,
737    /// Newline count at last scan (≈ line count). 0 for non-parseable files.
738    #[serde(default)]
739    pub line_count: u32,
740    /// Blast radius — how many files depend on this one (direct + transitive).
741    /// Computed during `mati init` Phase 10a from Imports edges in the graph.
742    /// `None` for stores created before blast radius was introduced.
743    #[serde(default)]
744    pub blast_radius: Option<crate::analysis::blast_radius::BlastRadius>,
745    /// Staleness inherited from upstream stale sources via Imports edges.
746    /// `None` for stores created before staleness propagation was introduced.
747    #[serde(default)]
748    pub propagated_staleness: Option<crate::analysis::propagation::PropagatedStaleness>,
749}
750
751impl FileRecord {
752    /// Construct a layer-0 file stub from static-analysis signals.
753    ///
754    /// `purpose`, `gotcha_keys`, and `decision_keys` intentionally start empty.
755    /// The Layer 0 pipeline only records structural facts; enrichment fills in
756    /// the human-readable purpose later.
757    #[allow(clippy::too_many_arguments)]
758    pub fn layer0_stub(
759        path: impl Into<String>,
760        entry_points: Vec<String>,
761        imports: Vec<String>,
762        todos: Vec<TodoComment>,
763        unsafe_count: u32,
764        unwrap_count: u32,
765        change_frequency: u32,
766        last_author: Option<String>,
767        is_hotspot: bool,
768        token_cost_estimate: u32,
769        last_modified_session: u64,
770    ) -> Self {
771        Self {
772            path: path.into(),
773            purpose: String::new(),
774            entry_points,
775            imports,
776            gotcha_keys: vec![],
777            decision_keys: vec![],
778            todos,
779            unsafe_count,
780            unwrap_count,
781            change_frequency,
782            last_author,
783            is_hotspot,
784            token_cost_estimate,
785            last_modified_session,
786            content_hash: None,
787            line_count: 0,
788            blast_radius: None,
789            propagated_staleness: None,
790        }
791    }
792}
793
794// ─────────────────────────────────────────────
795// Gotcha record
796// ─────────────────────────────────────────────
797
798/// A confirmed (or candidate) gotcha — a non-obvious rule that Claude must
799/// know before reading or editing the associated file(s).
800///
801/// `confirmed: false` = Layer 0 candidate stub. Never injected.
802/// `confirmed: true` + `confidence >= 0.6` + `quality >= 0.4`
803///   → pre-read hook denies the file read and injects this record instead.
804///
805/// Does **not** derive `PartialEq` — embedded via `Record` which carries scores.
806#[derive(Serialize, Deserialize, Debug, Clone)]
807pub struct GotchaRecord {
808    /// The actionable rule. Must start with an imperative verb for Good quality.
809    pub rule: String,
810    /// Why this rule exists. Causality sentence.
811    pub reason: String,
812    pub severity: Priority,
813    #[serde(default)]
814    pub affected_files: Vec<String>,
815    #[serde(default)]
816    pub ref_url: Option<String>,
817    /// Timestamp of the session in which this gotcha was first discovered.
818    #[serde(default)]
819    pub discovered_session: u64,
820    /// Whether a developer has explicitly confirmed this record is accurate.
821    /// Layer 0 stubs are always `false` until confirmed via `mati gotcha add`.
822    #[serde(default)]
823    pub confirmed: bool,
824    /// SHA-256 digest of each affected file's content at the moment a human
825    /// last confirmed this rule — `<path> → hex`, the same digest
826    /// [`FileRecord::content_hash`] already carries. Written by the confirm
827    /// paths only; nothing else re-stamps it.
828    ///
829    /// This is the baseline that *content drift* compares against: when a
830    /// file's current hash differs from the one stamped here, the code the
831    /// rule describes changed after the last human sign-off, so the rule may
832    /// now be **wrong** rather than merely dusty. Purely informational — see
833    /// [`crate::health::drift`]. It feeds no score, no tier, and no hook
834    /// decision, deliberately: the read gate must keep denying on a drifted
835    /// gotcha (ARCHITECTURE.md section 10.1).
836    ///
837    /// Keyed per file rather than as one combined digest so the report can
838    /// name *which* file moved, and so an entry with no indexed hash (a glob
839    /// like `src/payments/**`, an unparsed file, a path indexed before
840    /// content hashing existed) is simply absent. An absent entry means
841    /// "unknown", never "drifted" — which is also what a record confirmed
842    /// before this field existed deserializes to.
843    #[serde(default)]
844    pub confirmed_content: BTreeMap<String, String>,
845}
846
847/// Declarative action predicate stored on a local policy.
848///
849/// This is the persisted shape consumed by the pure policy matcher.
850///
851/// Unknown fields are rejected: every field is optional and an absent one is a
852/// wildcard, so a misspelled key would otherwise deserialize into an all-`None`
853/// trigger that matches every governed action.
854#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
855#[serde(deny_unknown_fields)]
856pub struct PolicyTrigger {
857    #[serde(default, skip_serializing_if = "Option::is_none")]
858    pub tool: Option<String>,
859    #[serde(default, skip_serializing_if = "Option::is_none")]
860    pub host_glob: Option<String>,
861    #[serde(default, skip_serializing_if = "Option::is_none")]
862    pub target_path_glob: Option<String>,
863    /// Glob matched against the normalized command tokens joined by single
864    /// spaces (`action.argv`, after wrapper stripping and `sh -c` unwrap). The
865    /// only predicate that can gate a pathless verb (`dd`, `terraform destroy`,
866    /// `git reset --hard`). Lexical and best-effort: quoting, variable
867    /// expansion, and aliases bypass it, so a block on it is a tripwire, never
868    /// a guarantee.
869    #[serde(default, skip_serializing_if = "Option::is_none")]
870    pub command_glob: Option<String>,
871}
872
873#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
874#[serde(rename_all = "snake_case")]
875pub enum PolicyMode {
876    Steer,
877    Block,
878}
879
880/// Rollout stage for a local policy.
881#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
882#[serde(rename_all = "snake_case")]
883pub enum PolicyStage {
884    Off,
885    Shadow,
886    Enforce,
887}
888
889#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
890#[serde(rename_all = "snake_case")]
891pub enum ReceiptSource {
892    DbIntrospection,
893    HookContext,
894    MemGet,
895}
896
897/// Receipt sources a Codex session can actually produce.
898///
899/// `DbIntrospection` is absent on purpose and is a platform limit, not a gap to
900/// close: Codex's `PostToolUse` payload carries no exit status — its published
901/// schema types `tool_response` as unconstrained and defines no status field —
902/// so mati cannot tell a successful introspection from one that exited 127, and
903/// deliberately does not mint. A block policy accepting only sources outside
904/// this list can never be satisfied in a Codex session.
905///
906/// Both the hook-time degrade and the authoring-time warning read this, so the
907/// two can never disagree about what Codex can attest to. Keep it in step with
908/// the mint sites: `HookContext` belongs here because `fire_events` runs on the
909/// Codex pre-bash and apply-patch paths.
910pub const CODEX_PRODUCIBLE_SOURCES: &[ReceiptSource] =
911    &[ReceiptSource::MemGet, ReceiptSource::HookContext];
912
913/// Default consultation freshness. Mirrors the `mati policy add --requires`
914/// clap default (`src/cli/policy.rs`); a `policy_requires_default_matches_cli`
915/// test locks the two together so the CLI and `mem_set` authoring paths agree.
916const DEFAULT_POLICY_TTL_SECS: u64 = 900;
917
918fn default_policy_ttl_secs() -> u64 {
919    DEFAULT_POLICY_TTL_SECS
920}
921
922fn default_policy_freshness() -> PolicyFreshness {
923    PolicyFreshness {
924        ttl_secs: DEFAULT_POLICY_TTL_SECS,
925        fingerprint: false,
926    }
927}
928
929/// The default `requires` for a policy authored without one. Empty key/via means
930/// "no consultation gate" — the same inert default `mati policy add` writes.
931fn default_policy_requires() -> PolicyRequires {
932    PolicyRequires {
933        key: String::new(),
934        via: Vec::new(),
935        freshness: default_policy_freshness(),
936    }
937}
938
939#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
940pub struct PolicyFreshness {
941    #[serde(default = "default_policy_ttl_secs")]
942    pub ttl_secs: u64,
943    #[serde(default)]
944    pub fingerprint: bool,
945}
946
947#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
948pub struct PolicyRequires {
949    #[serde(default)]
950    pub key: String,
951    #[serde(default)]
952    pub via: Vec<ReceiptSource>,
953    #[serde(default = "default_policy_freshness")]
954    pub freshness: PolicyFreshness,
955}
956
957/// A developer-authored local policy stored under `policy:<slug>`.
958#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
959pub struct PolicyRecord {
960    pub name: String,
961    pub rule: String,
962    pub reason: String,
963    pub scope: String,
964    pub mode: PolicyMode,
965    pub trigger: PolicyTrigger,
966    #[serde(default = "default_policy_requires")]
967    pub requires: PolicyRequires,
968    pub stage: PolicyStage,
969    pub severity: Priority,
970    pub created_by: String,
971}
972
973// ─────────────────────────────────────────────
974// Stale review (M-13-C)
975// ─────────────────────────────────────────────
976
977/// A single entry in a stale-review session payload.
978///
979/// Surfaced to Claude via `mem_bootstrap` stale warnings section.
980/// Stored inside `StaleReviewPayload` in `session:<ts>` records.
981#[derive(Serialize, Deserialize, Debug, Clone)]
982pub struct StaleReviewEntry {
983    pub key: String,
984    pub staleness_value: f32,
985    pub tier: StalenessTier,
986    pub last_updated: u64,
987    pub signals: Vec<String>,
988}
989
990/// Payload written to `session:<ts>` after a stale-review pass.
991#[derive(Serialize, Deserialize, Debug, Clone)]
992pub struct StaleReviewPayload {
993    pub session_timestamp: u64,
994    pub entries: Vec<StaleReviewEntry>,
995}
996
997// ─────────────────────────────────────────────
998// Knowledge gaps
999// ─────────────────────────────────────────────
1000
1001/// Classification of why a knowledge gap exists.
1002///
1003/// Computed by `KnowledgeGapAnalyzer` — async, post-session, non-blocking.
1004/// Gap severity formula: `change_frequency × (1 - coverage_score)`.
1005#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1006#[serde(rename_all = "snake_case")]
1007pub enum GapType {
1008    /// Hot file with no record at all.
1009    HotFileNoRecord,
1010    /// Hot file has a record but `purpose` is empty.
1011    HotFileNoPurpose,
1012    /// Hot file has no associated `gotcha:*` records.
1013    HotFileNoGotchas,
1014    /// File read frequently by Claude but never enriched past Layer 0.
1015    FrequentlyReadNoEnrich,
1016    /// A `decision:*` record with no `affected_files`.
1017    OrphanedDecision,
1018    /// A `dep:*` record with no confirmed gotchas.
1019    DependencyUnknown,
1020    /// Two files co-change in >70% of commits but have no explicit graph edge.
1021    CoChangePairUnmapped,
1022    /// Hot file's record hasn't been updated since a significant refactor.
1023    StaleHotspot,
1024    /// Hotspot file with no corresponding test file detected in the repo.
1025    HotFileNoTests,
1026    /// File imported by many others but has no gotchas or decisions documented.
1027    HighFanInNoContract,
1028}
1029
1030/// A detected knowledge gap with risk score and suggested resolution action.
1031///
1032/// Does **not** derive `PartialEq` — `risk_score` is a computed f32.
1033#[derive(Serialize, Deserialize, Debug, Clone)]
1034pub struct KnowledgeGap {
1035    /// Namespaced key of the file, dep, or decision with the gap.
1036    pub key: String,
1037    pub gap_type: GapType,
1038    /// Computed risk score: `change_frequency × (1 - coverage_score)`.
1039    pub risk_score: f32,
1040    pub description: String,
1041    /// Suggested `mati` CLI command to resolve the gap.
1042    pub action_hint: String,
1043}
1044
1045// ─────────────────────────────────────────────
1046// Context packet (mem_bootstrap output)
1047// ─────────────────────────────────────────────
1048
1049/// What `mem_bootstrap()` returns to Claude. Token-budgeted to 2,000 tokens.
1050///
1051/// Assembly order (ARCHITECTURE.md section 6):
1052/// 1. Resolve `context_files` to graph nodes
1053/// 2. Traverse `HasGotcha` edges — direct gotchas for each file
1054/// 3. Traverse `Imports` one hop — gotchas for imported files
1055/// 4. Traverse `AffectedBy` edges — relevant architectural decisions
1056/// 5. Token-budget the result to 2,000 tokens
1057/// 6. Sort gotchas by `confidence × severity`
1058///
1059/// The MCP tool returns `injection_string` as the top-level tool result text.
1060/// The full struct is used internally for structured rendering and debugging.
1061///
1062/// Does **not** derive `PartialEq` — transitively contains f32 score fields.
1063#[derive(Serialize, Deserialize, Debug, Clone)]
1064pub struct ContextPacket {
1065    /// Current `stage:current` record, if set.
1066    pub stage: Option<Record>,
1067    /// Gotchas sorted by `confidence × severity`. Only `confirmed: true` records.
1068    /// Type is [`Record`] (not `GotchaRecord`) — the base record is the storage
1069    /// unit. `mem_bootstrap` callers must look up the typed detail via
1070    /// `mati_core::store::GotchaRecord` when the rule/reason fields are needed.
1071    pub critical_gotchas: Vec<Record>,
1072    /// File records for the requested context files.
1073    pub file_records: Vec<FileRecord>,
1074    /// Decision records reached via `AffectedBy` graph traversal.
1075    pub related_decisions: Vec<Record>,
1076    /// Plain-text summary of the last session (from `session-harvest`).
1077    pub recent_session: Option<String>,
1078    /// Estimated token count of this packet.
1079    pub token_estimate: u32,
1080    /// Human-readable staleness warnings for records approaching Liability tier.
1081    pub stale_warnings: Vec<String>,
1082    /// Keys of `confirmed: false` Layer 0 stubs surfaced for developer review.
1083    pub unconfirmed_candidates: Vec<String>,
1084    /// Top knowledge gaps ranked by risk score.
1085    pub knowledge_gaps: Vec<KnowledgeGap>,
1086    /// Compliance rate for the last 7 days. Present only when < 0.85.
1087    pub compliance_rate: Option<f32>,
1088    /// Pre-formatted markdown string returned as the MCP tool result text.
1089    pub injection_string: String,
1090}
1091
1092// ─────────────────────────────────────────────
1093// Health / onboarding
1094// ─────────────────────────────────────────────
1095
1096/// Onboarding time estimate based on current knowledge coverage.
1097///
1098/// Formula (ARCHITECTURE.md section 13.3):
1099/// ```text
1100/// base_time = 22 minutes
1101///
1102/// reduction_factors:
1103///   hotspot_coverage  × 0.40
1104///   gotcha_coverage   × 0.25
1105///   decision_coverage × 0.15
1106///   confidence_weight × 0.20
1107///
1108/// estimated_minutes = base_time × (1 - weighted_reduction)
1109/// ```
1110/// Stored as `analytics:onboarding_score` with `Durability::Eventual`.
1111///
1112/// Does **not** derive `PartialEq` — all fields are computed f32 values.
1113#[derive(Serialize, Deserialize, Debug, Clone)]
1114pub struct OnboardingScore {
1115    pub estimated_minutes: f32,
1116    /// Fraction of hotspot files with a non-empty purpose (0.0–1.0).
1117    pub critical_files_covered: f32,
1118    /// Fraction of hotspot files with ≥1 confirmed gotcha (0.0–1.0).
1119    pub gotcha_coverage: f32,
1120    /// Fraction of architectural decisions documented (0.0–1.0).
1121    pub decision_coverage: f32,
1122    /// Average confidence across all confirmed records.
1123    pub avg_confidence: f32,
1124    pub computed_at: u64,
1125}
1126
1127// ─────────────────────────────────────────────
1128// Tests
1129// ─────────────────────────────────────────────
1130
1131#[cfg(test)]
1132mod tests {
1133    use super::*;
1134
1135    // ── Helpers ─────────────────────────────────────────────────────────────
1136
1137    fn device_id() -> DeviceId {
1138        Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()
1139    }
1140
1141    fn sample_record() -> Record {
1142        Record {
1143            key: "gotcha:inference-async".to_string(),
1144            value: "Never call .await inside a rayon::spawn closure — it panics.".to_string(),
1145            category: Category::Gotcha,
1146            priority: Priority::Critical,
1147            tags: vec!["async".to_string(), "rayon".to_string()],
1148            created_at: 1_710_520_800,
1149            updated_at: 1_710_520_800,
1150            ref_url: Some("https://github.com/example/issue/42".to_string()),
1151            staleness: StalenessScore::fresh(),
1152            lifecycle: RecordLifecycle::Active,
1153            version: RecordVersion {
1154                device_id: device_id(),
1155                logical_clock: 1,
1156                wall_clock: 1_710_520_800,
1157            },
1158            quality: QualityScore {
1159                value: 0.85,
1160                tier: QualityTier::Good,
1161                signals: vec![
1162                    QualitySignal::HasImperativeVerb,
1163                    QualitySignal::HasCausality,
1164                ],
1165                computed_at: 1_710_520_800,
1166            },
1167            access_count: 0,
1168            last_accessed: 0,
1169            source: RecordSource::DeveloperManual,
1170            confidence: ConfidenceScore::for_new_record(&RecordSource::DeveloperManual),
1171            gap_analysis_score: 0.0,
1172            payload: None,
1173        }
1174    }
1175
1176    fn sample_file_record() -> FileRecord {
1177        FileRecord {
1178            path: "src/store/db.rs".to_string(),
1179            purpose: "Initialises SurrealKV trees and exposes the Store handle.".to_string(),
1180            entry_points: vec!["Store::open".to_string()],
1181            imports: vec!["surrealkv".to_string()],
1182            gotcha_keys: vec!["gotcha:inference-async".to_string()],
1183            decision_keys: vec![],
1184            todos: vec![TodoComment {
1185                text: "add fsync benchmark".to_string(),
1186                line: 42,
1187                kind: TodoKind::Todo,
1188            }],
1189            unsafe_count: 0,
1190            unwrap_count: 1,
1191            change_frequency: 12,
1192            last_author: Some("ioni".to_string()),
1193            is_hotspot: false,
1194            token_cost_estimate: 180,
1195            last_modified_session: 1_710_520_800,
1196            content_hash: None,
1197            line_count: 0,
1198            blast_radius: None,
1199            propagated_staleness: None,
1200        }
1201    }
1202
1203    fn sample_context_packet() -> ContextPacket {
1204        ContextPacket {
1205            stage: None,
1206            critical_gotchas: vec![sample_record()],
1207            file_records: vec![sample_file_record()],
1208            related_decisions: vec![],
1209            recent_session: Some(
1210                "Implemented storage layer. SurrealKV tree opened cleanly.".to_string(),
1211            ),
1212            token_estimate: 420,
1213            stale_warnings: vec![],
1214            unconfirmed_candidates: vec!["file:src/analysis/walker.rs".to_string()],
1215            knowledge_gaps: vec![KnowledgeGap {
1216                key: "file:src/analysis/parser.rs".to_string(),
1217                gap_type: GapType::HotFileNoGotchas,
1218                risk_score: 0.72,
1219                description: "Hot file with 23 commits in 60d and no gotchas".to_string(),
1220                action_hint: "mati gotcha add src/analysis/parser.rs".to_string(),
1221            }],
1222            compliance_rate: None,
1223            injection_string: String::new(),
1224        }
1225    }
1226
1227    /// Round-trip helper: serialise, deserialise, re-serialise and compare
1228    /// JSON strings. This avoids relying on `PartialEq` for f32-containing
1229    /// types while still fully exercising the serde impls.
1230    fn assert_serde_roundtrip<T>(value: &T)
1231    where
1232        T: Serialize + for<'de> Deserialize<'de>,
1233    {
1234        let json1 = serde_json::to_string(value).expect("serialization failed");
1235        let restored: T = serde_json::from_str(&json1).expect("deserialization failed");
1236        let json2 = serde_json::to_string(&restored).expect("re-serialization failed");
1237        assert_eq!(json1, json2, "serde round-trip produced different JSON");
1238    }
1239
1240    // ── Round-trip tests ─────────────────────────────────────────────────────
1241
1242    #[test]
1243    fn record_serde_roundtrip() {
1244        assert_serde_roundtrip(&sample_record());
1245    }
1246
1247    #[test]
1248    fn file_record_serde_roundtrip() {
1249        assert_serde_roundtrip(&sample_file_record());
1250    }
1251
1252    /// Old stores serialized FileRecord without the `blast_radius` field.
1253    /// `#[serde(default)]` on the field must make deserialization succeed
1254    /// with `blast_radius == None`.
1255    #[test]
1256    fn file_record_backward_compat_no_blast_radius() {
1257        let json = r#"{
1258            "path": "src/main.rs",
1259            "purpose": "Entry point",
1260            "entry_points": ["main"],
1261            "imports": [],
1262            "gotcha_keys": [],
1263            "decision_keys": [],
1264            "todos": [],
1265            "unsafe_count": 0,
1266            "unwrap_count": 0,
1267            "change_frequency": 5,
1268            "last_author": "dev",
1269            "is_hotspot": false,
1270            "token_cost_estimate": 100,
1271            "last_modified_session": 1710520800
1272        }"#;
1273        let fr: FileRecord = serde_json::from_str(json).unwrap();
1274        assert!(fr.blast_radius.is_none());
1275        assert_eq!(fr.path, "src/main.rs");
1276        assert_eq!(fr.content_hash, None);
1277        assert_eq!(fr.line_count, 0);
1278    }
1279
1280    #[test]
1281    fn gotcha_record_serde_roundtrip() {
1282        let gotcha = GotchaRecord {
1283            rule: "Never hold a write transaction across an await point.".to_string(),
1284            reason: "SurrealKV write txns are not Send; the future will not compile.".to_string(),
1285            severity: Priority::Critical,
1286            affected_files: vec!["src/store/db.rs".to_string()],
1287            ref_url: Some("https://github.com/example/issue/99".to_string()),
1288            discovered_session: 1_710_520_800,
1289            confirmed: true,
1290            confirmed_content: Default::default(),
1291        };
1292        assert_serde_roundtrip(&gotcha);
1293    }
1294
1295    #[test]
1296    fn policy_record_roundtrips_through_record_messagepack() {
1297        let policy = PolicyRecord {
1298            name: "Production query safety".into(),
1299            rule: "Consult the production schema before querying it.".into(),
1300            reason: "Queries can target incompatible production tables because schemas drift."
1301                .into(),
1302            scope: "repo".into(),
1303            mode: PolicyMode::Block,
1304            trigger: PolicyTrigger {
1305                tool: Some("db_client".into()),
1306                host_glob: Some("*prod*".into()),
1307                target_path_glob: None,
1308                command_glob: None,
1309            },
1310            requires: PolicyRequires {
1311                key: "schema:orders_db".into(),
1312                via: vec![ReceiptSource::DbIntrospection, ReceiptSource::MemGet],
1313                freshness: PolicyFreshness {
1314                    ttl_secs: 900,
1315                    fingerprint: false,
1316                },
1317            },
1318            stage: PolicyStage::Enforce,
1319            severity: Priority::Critical,
1320            created_by: "developer".into(),
1321        };
1322        let record = Record {
1323            key: "policy:production-query-safety".into(),
1324            value: policy.rule.clone(),
1325            category: Category::Policy,
1326            priority: policy.severity.clone(),
1327            tags: vec![],
1328            created_at: 1_710_520_800,
1329            updated_at: 1_710_520_800,
1330            ref_url: None,
1331            staleness: StalenessScore::fresh(),
1332            lifecycle: RecordLifecycle::Active,
1333            version: RecordVersion {
1334                device_id: device_id(),
1335                logical_clock: 1,
1336                wall_clock: 1_710_520_800,
1337            },
1338            quality: QualityScore::layer0_default(),
1339            access_count: 0,
1340            last_accessed: 0,
1341            source: RecordSource::DeveloperManual,
1342            confidence: ConfidenceScore::for_new_record(&RecordSource::DeveloperManual),
1343            gap_analysis_score: 0.0,
1344            payload: Some(serde_json::to_value(&policy).unwrap()),
1345        };
1346        let bytes = rmp_serde::to_vec_named(&record).unwrap();
1347        let restored: Record = rmp_serde::from_slice(&bytes).unwrap();
1348        assert_eq!(restored.category, Category::Policy);
1349        assert_eq!(restored.payload_as::<PolicyRecord>().unwrap(), policy);
1350    }
1351
1352    #[test]
1353    fn policy_requires_defaults_when_payload_omits_it() {
1354        // A policy payload authored via `mem_set` may omit `requires`; it must
1355        // deserialize to the inert default instead of erroring. Regression for
1356        // the observed mem_set "missing field `requires`" retry.
1357        let full = PolicyRecord {
1358            name: "n".into(),
1359            rule: "r".into(),
1360            reason: "why".into(),
1361            scope: "repo".into(),
1362            mode: PolicyMode::Steer,
1363            trigger: PolicyTrigger {
1364                tool: Some("path".into()),
1365                host_glob: None,
1366                target_path_glob: Some("src/secrets/**".into()),
1367                command_glob: None,
1368            },
1369            requires: default_policy_requires(),
1370            stage: PolicyStage::Off,
1371            severity: Priority::High,
1372            created_by: "developer".into(),
1373        };
1374        let mut value = serde_json::to_value(&full).unwrap();
1375        value.as_object_mut().unwrap().remove("requires");
1376        let parsed: PolicyRecord = serde_json::from_value(value).unwrap();
1377        assert_eq!(parsed.requires, default_policy_requires());
1378        assert_eq!(parsed.requires.freshness.ttl_secs, DEFAULT_POLICY_TTL_SECS);
1379        assert!(parsed.requires.key.is_empty());
1380    }
1381
1382    #[test]
1383    fn policy_requires_default_matches_cli_literal() {
1384        // Lock the serde default to the `mati policy add --requires` clap default
1385        // so the CLI and mem_set authoring paths cannot drift apart.
1386        let cli = r#"{"key":"","via":[],"freshness":{"ttl_secs":900}}"#;
1387        let from_cli: PolicyRequires = serde_json::from_str(cli).unwrap();
1388        assert_eq!(from_cli, default_policy_requires());
1389    }
1390
1391    #[test]
1392    fn context_packet_serde_roundtrip() {
1393        assert_serde_roundtrip(&sample_context_packet());
1394    }
1395
1396    // ── Lifecycle & tombstone serde ──────────────────────────────────────────
1397
1398    #[test]
1399    fn record_lifecycle_tombstoned_serde() {
1400        let lifecycle = RecordLifecycle::Tombstoned {
1401            reason: TombstoneReason::FileDeleted,
1402            at: 1_710_520_800,
1403        };
1404        assert_serde_roundtrip(&lifecycle);
1405    }
1406
1407    #[test]
1408    fn record_lifecycle_superseded_serde() {
1409        let lifecycle = RecordLifecycle::Superseded {
1410            by_key: "gotcha:inference-async-v2".to_string(),
1411        };
1412        assert_serde_roundtrip(&lifecycle);
1413    }
1414
1415    #[test]
1416    fn tombstone_reason_file_renamed_serde() {
1417        let reason = TombstoneReason::FileRenamed {
1418            new_path: "src/store/backend.rs".to_string(),
1419        };
1420        assert_serde_roundtrip(&reason);
1421    }
1422
1423    // ── Staleness signal serde ───────────────────────────────────────────────
1424
1425    #[test]
1426    fn staleness_signal_dependency_bumped_serde() {
1427        let signal = StalenessSignal::DependencyBumped {
1428            dep: "tokio".to_string(),
1429            old_ver: "1.40".to_string(),
1430            new_ver: "1.50".to_string(),
1431        };
1432        assert_serde_roundtrip(&signal);
1433    }
1434
1435    #[test]
1436    fn staleness_signal_file_renamed_serde() {
1437        let signal = StalenessSignal::FileRenamed {
1438            new_path: "src/store/backend.rs".to_string(),
1439        };
1440        assert_serde_roundtrip(&signal);
1441    }
1442
1443    #[test]
1444    fn staleness_signal_cascade_serde() {
1445        let signal = StalenessSignal::CascadeFromDecision("decision:storage-engine".to_string());
1446        assert_serde_roundtrip(&signal);
1447    }
1448
1449    #[test]
1450    fn staleness_score_fresh_default() {
1451        let s = StalenessScore::fresh();
1452        assert_eq!(s.tier, StalenessTier::Fresh);
1453        assert_eq!(s.value, 0.0);
1454        assert!(s.signals.is_empty());
1455        assert_eq!(s.computed_at, 0, "0 = not yet computed sentinel");
1456        assert!(s.last_record_sha.is_empty());
1457    }
1458
1459    // ── Quality tier thresholds ──────────────────────────────────────────────
1460
1461    #[test]
1462    fn quality_tier_ranges() {
1463        assert_eq!(QualityScore::tier_from_value(0.00), QualityTier::Suppressed);
1464        assert_eq!(QualityScore::tier_from_value(0.10), QualityTier::Suppressed);
1465        assert_eq!(QualityScore::tier_from_value(0.19), QualityTier::Suppressed);
1466        assert_eq!(QualityScore::tier_from_value(0.20), QualityTier::Poor);
1467        assert_eq!(QualityScore::tier_from_value(0.30), QualityTier::Poor);
1468        assert_eq!(QualityScore::tier_from_value(0.39), QualityTier::Poor);
1469        assert_eq!(QualityScore::tier_from_value(0.40), QualityTier::Acceptable);
1470        assert_eq!(QualityScore::tier_from_value(0.55), QualityTier::Acceptable);
1471        assert_eq!(QualityScore::tier_from_value(0.69), QualityTier::Acceptable);
1472        assert_eq!(QualityScore::tier_from_value(0.70), QualityTier::Good);
1473        assert_eq!(QualityScore::tier_from_value(0.80), QualityTier::Good);
1474        assert_eq!(QualityScore::tier_from_value(0.89), QualityTier::Good);
1475        // 0.9 is the start of Excellent [0.9, 1.0]
1476        assert_eq!(QualityScore::tier_from_value(0.90), QualityTier::Excellent);
1477        assert_eq!(QualityScore::tier_from_value(0.95), QualityTier::Excellent);
1478        assert_eq!(QualityScore::tier_from_value(1.00), QualityTier::Excellent);
1479    }
1480
1481    // ── Confidence score ─────────────────────────────────────────────────────
1482
1483    #[test]
1484    fn confidence_base_scores_by_source() {
1485        assert_eq!(
1486            ConfidenceScore::base_for_source(&RecordSource::DeveloperManual),
1487            0.80
1488        );
1489        assert_eq!(
1490            ConfidenceScore::base_for_source(&RecordSource::Import),
1491            0.70
1492        );
1493        assert_eq!(
1494            ConfidenceScore::base_for_source(&RecordSource::ClaudeEnrich),
1495            0.60
1496        );
1497        assert_eq!(
1498            ConfidenceScore::base_for_source(&RecordSource::SessionHook),
1499            0.50
1500        );
1501        assert_eq!(
1502            ConfidenceScore::base_for_source(&RecordSource::StaticAnalysis),
1503            0.10
1504        );
1505    }
1506
1507    #[test]
1508    fn confidence_for_new_record_value_matches_base() {
1509        let source = RecordSource::ClaudeEnrich;
1510        let score = ConfidenceScore::for_new_record(&source);
1511        assert_eq!(score.value, ConfidenceScore::base_for_source(&source));
1512        assert_eq!(score.confirmation_count, 0);
1513        assert_eq!(score.contributor_count, 1);
1514        assert!(score.last_challenged.is_none());
1515        assert_eq!(score.challenge_count, 0);
1516    }
1517
1518    // ── Priority ordering ────────────────────────────────────────────────────
1519
1520    #[test]
1521    fn priority_total_ordering() {
1522        assert!(Priority::Critical > Priority::High);
1523        assert!(Priority::High > Priority::Normal);
1524        assert!(Priority::Normal > Priority::Low);
1525        assert!(Priority::Critical > Priority::Low);
1526        assert_eq!(Priority::High, Priority::High);
1527    }
1528
1529    // ── Device ID accessor ───────────────────────────────────────────────────
1530
1531    #[test]
1532    fn record_device_id_accessor_matches_version() {
1533        let rec = sample_record();
1534        assert_eq!(rec.device_id(), rec.version.device_id);
1535    }
1536
1537    // ── Quality tier: out-of-range & non-finite ──────────────────────────────
1538
1539    #[test]
1540    fn quality_tier_non_finite_is_suppressed() {
1541        // NaN, +∞, and -∞ must never reach Excellent — they would satisfy the
1542        // hook injection gate (quality >= 0.4) and inject untrusted records.
1543        assert_eq!(
1544            QualityScore::tier_from_value(f32::NAN),
1545            QualityTier::Suppressed
1546        );
1547        assert_eq!(
1548            QualityScore::tier_from_value(f32::INFINITY),
1549            QualityTier::Suppressed
1550        );
1551        assert_eq!(
1552            QualityScore::tier_from_value(f32::NEG_INFINITY),
1553            QualityTier::Suppressed
1554        );
1555    }
1556
1557    #[test]
1558    fn quality_tier_out_of_range_finite_saturates() {
1559        // Finite values outside [0, 1] saturate without panicking.
1560        assert_eq!(QualityScore::tier_from_value(-1.0), QualityTier::Suppressed);
1561        assert_eq!(
1562            QualityScore::tier_from_value(-0.001),
1563            QualityTier::Suppressed
1564        );
1565        assert_eq!(QualityScore::tier_from_value(1.001), QualityTier::Excellent);
1566        assert_eq!(QualityScore::tier_from_value(100.0), QualityTier::Excellent);
1567    }
1568
1569    #[test]
1570    fn layer0_default_quality_is_suppressed_tier() {
1571        let q = QualityScore::layer0_default();
1572        assert_eq!(q.tier, QualityTier::Suppressed);
1573        assert_eq!(q.value, 0.10);
1574        assert!(q.signals.is_empty());
1575        assert_eq!(q.computed_at, 0, "0 = not yet computed sentinel");
1576    }
1577
1578    // ── Confidence: all sources ───────────────────────────────────────────────
1579
1580    #[test]
1581    fn confidence_for_new_record_all_sources_correct() {
1582        let cases: &[(RecordSource, f32)] = &[
1583            (RecordSource::DeveloperManual, 0.80),
1584            (RecordSource::Import, 0.70),
1585            (RecordSource::ClaudeEnrich, 0.60),
1586            (RecordSource::SessionHook, 0.50),
1587            (RecordSource::StaticAnalysis, 0.10),
1588        ];
1589        for (source, expected) in cases {
1590            let score = ConfidenceScore::for_new_record(source);
1591            assert!(
1592                (score.value - expected).abs() < f32::EPSILON,
1593                "{source:?}: expected {expected}, got {}",
1594                score.value
1595            );
1596            assert_eq!(score.confirmation_count, 0);
1597            assert_eq!(score.contributor_count, 1);
1598            assert!(score.last_challenged.is_none());
1599            assert_eq!(score.challenge_count, 0);
1600        }
1601    }
1602
1603    #[test]
1604    fn confidence_base_scores_are_all_distinct() {
1605        let scores: Vec<f32> = [
1606            RecordSource::DeveloperManual,
1607            RecordSource::Import,
1608            RecordSource::ClaudeEnrich,
1609            RecordSource::SessionHook,
1610            RecordSource::StaticAnalysis,
1611        ]
1612        .iter()
1613        .map(ConfidenceScore::base_for_source)
1614        .collect();
1615
1616        for i in 0..scores.len() {
1617            for j in (i + 1)..scores.len() {
1618                assert!(
1619                    (scores[i] - scores[j]).abs() > f32::EPSILON,
1620                    "sources {i} and {j} have identical base score {}",
1621                    scores[i]
1622                );
1623            }
1624        }
1625    }
1626
1627    // ── Priority: exhaustive ordering ─────────────────────────────────────────
1628
1629    #[test]
1630    fn priority_exhaustive_pairwise_ordering() {
1631        use std::cmp::Ordering::*;
1632        let pairs = [
1633            (Priority::Low, Priority::Normal, Less),
1634            (Priority::Low, Priority::High, Less),
1635            (Priority::Low, Priority::Critical, Less),
1636            (Priority::Normal, Priority::High, Less),
1637            (Priority::Normal, Priority::Critical, Less),
1638            (Priority::High, Priority::Critical, Less),
1639            (Priority::Low, Priority::Low, Equal),
1640            (Priority::Normal, Priority::Normal, Equal),
1641            (Priority::High, Priority::High, Equal),
1642            (Priority::Critical, Priority::Critical, Equal),
1643        ];
1644        for (a, b, expected) in pairs {
1645            assert_eq!(
1646                a.cmp(&b),
1647                expected,
1648                "{a:?}.cmp({b:?}) should be {expected:?}"
1649            );
1650            // Antisymmetry: if a < b then b > a
1651            if expected == Less {
1652                assert_eq!(b.cmp(&a), std::cmp::Ordering::Greater, "{b:?}.cmp({a:?})");
1653            }
1654        }
1655    }
1656
1657    // ── StalenessSignal: all variants round-trip ──────────────────────────────
1658
1659    #[test]
1660    fn staleness_all_signal_variants_serde() {
1661        let signals: Vec<StalenessSignal> = vec![
1662            StalenessSignal::NotAccessedDays(30),
1663            StalenessSignal::LinesChangedPct(0.75),
1664            StalenessSignal::EntryPointsChanged(2),
1665            StalenessSignal::ImportsChanged(5),
1666            StalenessSignal::FileDeleted,
1667            StalenessSignal::FileRenamed {
1668                new_path: "src/foo.rs".to_string(),
1669            },
1670            StalenessSignal::DependencyBumped {
1671                dep: "tokio".to_string(),
1672                old_ver: "1.40".to_string(),
1673                new_ver: "1.50".to_string(),
1674            },
1675            StalenessSignal::LinkedFileChanged {
1676                path: "src/bar.rs".to_string(),
1677            },
1678            StalenessSignal::CascadeFromDecision("decision:arch".to_string()),
1679            StalenessSignal::TodosChanged,
1680            StalenessSignal::UnsafeCountChanged(3),
1681            StalenessSignal::UnwrapCountChanged(-2),
1682            StalenessSignal::GitCommitsSince(7),
1683        ];
1684        for signal in &signals {
1685            let json = serde_json::to_string(signal).expect("serialize");
1686            let restored: StalenessSignal = serde_json::from_str(&json).expect("deserialize");
1687            let json2 = serde_json::to_string(&restored).expect("re-serialize");
1688            assert_eq!(json, json2, "roundtrip failed for: {json}");
1689        }
1690    }
1691
1692    // ── TombstoneReason: all variants ────────────────────────────────────────
1693
1694    #[test]
1695    fn tombstone_reason_all_variants_serde() {
1696        let reasons = vec![
1697            TombstoneReason::FileDeleted,
1698            TombstoneReason::FileRenamed {
1699                new_path: "src/new.rs".to_string(),
1700            },
1701            TombstoneReason::ManualDeletion,
1702            TombstoneReason::Superseded,
1703            TombstoneReason::MigrationRepair,
1704        ];
1705        for reason in &reasons {
1706            assert_serde_roundtrip(reason);
1707        }
1708    }
1709
1710    // ── Serde snake_case contracts ────────────────────────────────────────────
1711
1712    #[test]
1713    fn category_serializes_as_snake_case() {
1714        let cases = [
1715            (Category::Gotcha, "\"gotcha\""),
1716            (Category::File, "\"file\""),
1717            (Category::Decision, "\"decision\""),
1718            (Category::Stage, "\"stage\""),
1719            (Category::Dependency, "\"dependency\""),
1720            (Category::DevNote, "\"dev_note\""),
1721            (Category::Session, "\"session\""),
1722            (Category::Analytics, "\"analytics\""),
1723        ];
1724        for (cat, expected_json) in cases {
1725            let json = serde_json::to_string(&cat).unwrap();
1726            assert_eq!(json, expected_json, "Category::{cat:?}");
1727        }
1728    }
1729
1730    #[test]
1731    fn record_source_serializes_as_snake_case() {
1732        let cases = [
1733            (RecordSource::StaticAnalysis, "\"static_analysis\""),
1734            (RecordSource::ClaudeEnrich, "\"claude_enrich\""),
1735            (RecordSource::SessionHook, "\"session_hook\""),
1736            (RecordSource::DeveloperManual, "\"developer_manual\""),
1737            (RecordSource::Import, "\"import\""),
1738        ];
1739        for (src, expected_json) in cases {
1740            let json = serde_json::to_string(&src).unwrap();
1741            assert_eq!(json, expected_json, "RecordSource::{src:?}");
1742        }
1743    }
1744
1745    #[test]
1746    fn staleness_tier_serializes_as_snake_case() {
1747        // Sync merge rule depends on the wire format being stable.
1748        let cases = [
1749            (StalenessTier::Fresh, "\"fresh\""),
1750            (StalenessTier::Aging, "\"aging\""),
1751            (StalenessTier::Stale, "\"stale\""),
1752            (StalenessTier::Liability, "\"liability\""),
1753            (StalenessTier::Tombstone, "\"tombstone\""),
1754        ];
1755        for (tier, expected_json) in cases {
1756            let json = serde_json::to_string(&tier).unwrap();
1757            assert_eq!(json, expected_json, "StalenessTier::{tier:?}");
1758        }
1759    }
1760
1761    // ── GotchaRecord: confirmed flag ─────────────────────────────────────────
1762
1763    #[test]
1764    fn gotcha_record_layer0_stub_is_unconfirmed() {
1765        // Layer 0 stubs must start unconfirmed; the hook decision matrix never
1766        // injects confirmed:false records regardless of confidence or quality.
1767        let stub = GotchaRecord {
1768            rule: "Do not call .await inside rayon::spawn.".to_string(),
1769            reason: "rayon threads have no tokio runtime.".to_string(),
1770            severity: Priority::Critical,
1771            affected_files: vec!["src/analysis/walker.rs".to_string()],
1772            ref_url: None,
1773            discovered_session: 0,
1774            confirmed: false,
1775            confirmed_content: Default::default(),
1776        };
1777        assert!(
1778            !stub.confirmed,
1779            "Layer 0 stubs must be unconfirmed on construction"
1780        );
1781
1782        // Serde roundtrip preserves the flag
1783        let json = serde_json::to_string(&stub).unwrap();
1784        let restored: GotchaRecord = serde_json::from_str(&json).unwrap();
1785        assert!(
1786            !restored.confirmed,
1787            "confirmed flag must survive serde roundtrip"
1788        );
1789        // The JSON wire format must contain "confirmed":false explicitly
1790        assert!(json.contains("\"confirmed\":false"), "wire format: {json}");
1791    }
1792
1793    #[test]
1794    fn gotcha_record_confirmed_true_roundtrips() {
1795        let confirmed = GotchaRecord {
1796            rule: "Use SurrealKV::with_versioning(true, 0) for indefinite retention.".to_string(),
1797            reason: "0 means retain all versions forever, not disabled.".to_string(),
1798            severity: Priority::High,
1799            affected_files: vec!["src/store/db.rs".to_string()],
1800            ref_url: Some("https://github.com/example/issue/5".to_string()),
1801            discovered_session: 1_710_520_800,
1802            confirmed: true,
1803            confirmed_content: Default::default(),
1804        };
1805        assert_serde_roundtrip(&confirmed);
1806        let json = serde_json::to_string(&confirmed).unwrap();
1807        assert!(json.contains("\"confirmed\":true"));
1808    }
1809
1810    // ─── Complex serde round-trips ────────────────────────────────────────────
1811
1812    #[test]
1813    fn staleness_score_fully_populated_serde() {
1814        let s = StalenessScore {
1815            value: 0.87,
1816            tier: StalenessTier::Liability,
1817            signals: vec![
1818                StalenessSignal::NotAccessedDays(90),
1819                StalenessSignal::LinesChangedPct(0.6),
1820                StalenessSignal::EntryPointsChanged(3),
1821                StalenessSignal::FileRenamed {
1822                    new_path: "src/store/backend.rs".to_string(),
1823                },
1824            ],
1825            computed_at: 1_710_520_800,
1826            last_record_sha: "deadbeefcafe0123".to_string(),
1827        };
1828        assert_serde_roundtrip(&s);
1829        let json = serde_json::to_string(&s).unwrap();
1830        let restored: StalenessScore = serde_json::from_str(&json).unwrap();
1831        assert_eq!(restored.tier, StalenessTier::Liability);
1832        assert_eq!(restored.signals.len(), 4);
1833        assert_eq!(restored.last_record_sha, "deadbeefcafe0123");
1834    }
1835
1836    #[test]
1837    fn quality_score_with_all_positive_signals_serde() {
1838        let q = QualityScore {
1839            value: 0.92,
1840            tier: QualityTier::Excellent,
1841            signals: vec![
1842                QualitySignal::HasImperativeVerb,
1843                QualitySignal::HasCausality,
1844                QualitySignal::HasSeveritySet,
1845                QualitySignal::HasReference,
1846                QualitySignal::RuleLengthAdequate,
1847                QualitySignal::ReasonLengthAdequate,
1848                QualitySignal::AffectedFilesSpecified,
1849                QualitySignal::HasSpecificIdentifier,
1850            ],
1851            computed_at: 1_710_520_800,
1852        };
1853        assert_serde_roundtrip(&q);
1854        let json = serde_json::to_string(&q).unwrap();
1855        let restored: QualityScore = serde_json::from_str(&json).unwrap();
1856        assert_eq!(restored.tier, QualityTier::Excellent);
1857        assert_eq!(restored.signals.len(), 8);
1858    }
1859
1860    #[test]
1861    fn confidence_score_with_challenge_history_serde() {
1862        // last_challenged: Some(u64) — a real production state for a disputed record.
1863        let c = ConfidenceScore {
1864            value: 0.45,
1865            confirmation_count: 1,
1866            contributor_count: 3,
1867            last_challenged: Some(1_710_500_000),
1868            challenge_count: 2,
1869        };
1870        let json = serde_json::to_string(&c).unwrap();
1871        let restored: ConfidenceScore = serde_json::from_str(&json).unwrap();
1872        assert_eq!(restored.last_challenged, Some(1_710_500_000));
1873        assert_eq!(restored.challenge_count, 2);
1874        assert_eq!(restored.contributor_count, 3);
1875        let json2 = serde_json::to_string(&restored).unwrap();
1876        assert_eq!(json, json2);
1877    }
1878
1879    #[test]
1880    fn record_ref_url_none_does_not_become_some() {
1881        // ref_url: None must not silently become Some("") or Some("null").
1882        let mut r = sample_record();
1883        r.ref_url = None;
1884        let json = serde_json::to_string(&r).unwrap();
1885        let restored: Record = serde_json::from_str(&json).unwrap();
1886        assert!(
1887            restored.ref_url.is_none(),
1888            "ref_url: None must not become Some after roundtrip"
1889        );
1890        assert!(
1891            json.contains("\"ref_url\":null"),
1892            "wire format must encode None as null"
1893        );
1894    }
1895
1896    #[test]
1897    fn context_packet_zero_knowledge_case_serde() {
1898        // The "blank slate" scenario: mati installed but nothing indexed yet.
1899        let empty = ContextPacket {
1900            stage: None,
1901            critical_gotchas: vec![],
1902            file_records: vec![],
1903            related_decisions: vec![],
1904            recent_session: None,
1905            token_estimate: 0,
1906            stale_warnings: vec![],
1907            unconfirmed_candidates: vec![],
1908            knowledge_gaps: vec![],
1909            compliance_rate: None,
1910            injection_string: String::new(),
1911        };
1912        assert_serde_roundtrip(&empty);
1913        let json = serde_json::to_string(&empty).unwrap();
1914        let restored: ContextPacket = serde_json::from_str(&json).unwrap();
1915        assert!(restored.critical_gotchas.is_empty());
1916        assert!(restored.file_records.is_empty());
1917        assert!(restored.stage.is_none());
1918        assert_eq!(restored.token_estimate, 0);
1919    }
1920
1921    #[test]
1922    fn record_tags_empty_and_many_both_survive_serde() {
1923        let mut r = sample_record();
1924
1925        r.tags = vec![];
1926        let json_empty = serde_json::to_string(&r).unwrap();
1927        let restored_empty: Record = serde_json::from_str(&json_empty).unwrap();
1928        assert!(
1929            restored_empty.tags.is_empty(),
1930            "empty tags must remain empty"
1931        );
1932
1933        r.tags = (0..50).map(|i| format!("tag-{i:03}")).collect();
1934        let json_many = serde_json::to_string(&r).unwrap();
1935        let restored_many: Record = serde_json::from_str(&json_many).unwrap();
1936        assert_eq!(restored_many.tags.len(), 50);
1937        assert_eq!(restored_many.tags[0], "tag-000");
1938        assert_eq!(restored_many.tags[49], "tag-049");
1939    }
1940
1941    #[test]
1942    fn file_record_layer0_stub_serde() {
1943        // Layer 0: file exists, but purpose and entry_points are empty.
1944        let stub = FileRecord::layer0_stub(
1945            "src/analysis/walker.rs",
1946            vec![],
1947            vec!["ignore".to_string(), "rayon".to_string()],
1948            vec![],
1949            0,
1950            3,
1951            17,
1952            None,
1953            true,
1954            0,
1955            0,
1956        );
1957        assert_serde_roundtrip(&stub);
1958        let json = serde_json::to_string(&stub).unwrap();
1959        let restored: FileRecord = serde_json::from_str(&json).unwrap();
1960        assert!(
1961            restored.purpose.is_empty(),
1962            "empty purpose must remain empty"
1963        );
1964        assert!(restored.entry_points.is_empty());
1965        assert!(restored.last_author.is_none());
1966        assert!(restored.is_hotspot);
1967        assert_eq!(restored.unwrap_count, 3);
1968    }
1969
1970    #[test]
1971    fn layer0_file_record_builder_sets_suppressed_quality() {
1972        let record =
1973            Record::layer0_file_stub("file:src/analysis/walker.rs", device_id(), 7, 1_710_520_800);
1974
1975        assert_eq!(record.key, "file:src/analysis/walker.rs");
1976        assert_eq!(record.category, Category::File);
1977        assert!(record.value.is_empty());
1978        assert_eq!(record.quality.value, 0.10);
1979        assert_eq!(record.quality.tier, QualityTier::Suppressed);
1980        assert_eq!(record.source, RecordSource::StaticAnalysis);
1981        assert_eq!(record.confidence.value, 0.10);
1982        assert_eq!(record.confidence.contributor_count, 1);
1983    }
1984
1985    // ── StaleReviewEntry / StaleReviewPayload serde ──────────────────────────
1986
1987    #[test]
1988    fn stale_review_entry_serde_roundtrip() {
1989        let entry = StaleReviewEntry {
1990            key: "file:src/store/db.rs".to_string(),
1991            staleness_value: 0.72,
1992            tier: StalenessTier::Stale,
1993            last_updated: 1_710_520_800,
1994            signals: vec![
1995                "not accessed for 45 days".to_string(),
1996                "3 entry points changed".to_string(),
1997            ],
1998        };
1999        assert_serde_roundtrip(&entry);
2000    }
2001
2002    #[test]
2003    fn stale_review_payload_serde_roundtrip() {
2004        let payload = StaleReviewPayload {
2005            session_timestamp: 1_710_520_800,
2006            entries: vec![
2007                StaleReviewEntry {
2008                    key: "file:src/store/db.rs".to_string(),
2009                    staleness_value: 0.72,
2010                    tier: StalenessTier::Stale,
2011                    last_updated: 1_710_500_000,
2012                    signals: vec!["not accessed for 45 days".to_string()],
2013                },
2014                StaleReviewEntry {
2015                    key: "gotcha:inference-async".to_string(),
2016                    staleness_value: 0.85,
2017                    tier: StalenessTier::Liability,
2018                    last_updated: 1_710_400_000,
2019                    signals: vec![
2020                        "90 commits since last confirmation".to_string(),
2021                        "75% of lines changed".to_string(),
2022                    ],
2023                },
2024            ],
2025        };
2026        assert_serde_roundtrip(&payload);
2027        let json = serde_json::to_string(&payload).unwrap();
2028        let restored: StaleReviewPayload = serde_json::from_str(&json).unwrap();
2029        assert_eq!(restored.entries.len(), 2);
2030        assert_eq!(restored.session_timestamp, 1_710_520_800);
2031    }
2032
2033    #[test]
2034    fn stale_review_payload_empty_entries_serde() {
2035        let payload = StaleReviewPayload {
2036            session_timestamp: 1_710_520_800,
2037            entries: vec![],
2038        };
2039        assert_serde_roundtrip(&payload);
2040        let json = serde_json::to_string(&payload).unwrap();
2041        let restored: StaleReviewPayload = serde_json::from_str(&json).unwrap();
2042        assert!(restored.entries.is_empty());
2043    }
2044
2045    // ── GitCommitsSince signal ───────────────────────────────────────────────
2046
2047    #[test]
2048    fn staleness_signal_git_commits_since_serde() {
2049        let signal = StalenessSignal::GitCommitsSince(42);
2050        assert_serde_roundtrip(&signal);
2051        let json = serde_json::to_string(&signal).unwrap();
2052        assert!(json.contains("git_commits_since"), "wire format: {json}");
2053    }
2054
2055    #[test]
2056    fn staleness_signal_git_commits_since_display() {
2057        let signal = StalenessSignal::GitCommitsSince(7);
2058        assert_eq!(signal.to_string(), "7 commits since last confirmation");
2059    }
2060
2061    #[test]
2062    fn staleness_signal_display_all_variants() {
2063        // Smoke test: every variant produces a non-empty string.
2064        let signals: Vec<StalenessSignal> = vec![
2065            StalenessSignal::NotAccessedDays(30),
2066            StalenessSignal::LinesChangedPct(0.75),
2067            StalenessSignal::EntryPointsChanged(2),
2068            StalenessSignal::ImportsChanged(5),
2069            StalenessSignal::FileDeleted,
2070            StalenessSignal::FileRenamed {
2071                new_path: "src/foo.rs".to_string(),
2072            },
2073            StalenessSignal::DependencyBumped {
2074                dep: "tokio".to_string(),
2075                old_ver: "1.40".to_string(),
2076                new_ver: "1.50".to_string(),
2077            },
2078            StalenessSignal::LinkedFileChanged {
2079                path: "src/bar.rs".to_string(),
2080            },
2081            StalenessSignal::CascadeFromDecision("decision:arch".to_string()),
2082            StalenessSignal::TodosChanged,
2083            StalenessSignal::UnsafeCountChanged(3),
2084            StalenessSignal::UnwrapCountChanged(-2),
2085            StalenessSignal::GitCommitsSince(7),
2086        ];
2087        for signal in &signals {
2088            let display = signal.to_string();
2089            assert!(
2090                !display.is_empty(),
2091                "Display for {signal:?} should not be empty"
2092            );
2093        }
2094    }
2095}