Skip to main content

ai_memory/models/
memory.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use super::default_metadata;
8
9// Canonical `MemoryKind` spellings duplicated across `as_str` / `from_str`
10// (#1558 batch 6).
11const KIND_OBSERVATION: &str = "observation";
12const KIND_REFLECTION: &str = "reflection";
13
14/// L1-1 (v0.7.0) — typed memory-kind discriminator stored in the
15/// `memories.memory_kind` column (schema v30).
16///
17/// `Observation` and `Reflection` exist since v0.7.0. `Persona`
18/// landed in v0.7.0 QW-2 (schema v36) as the substrate-native
19/// Tencent-pattern L3 persona artefact.
20///
21/// v0.7.x Form 6 (issue #759) — Batman taxonomy extension. The
22/// `Concept | Entity | Claim | Relation | Event | Conversation |
23/// Decision` variants give downstream readers a richer atom-type
24/// vocabulary aligned with the Batman framework's exemplar
25/// (Tolaria's frontmatter-as-type schema). All seven variants
26/// serialize as snake_case strings via the existing
27/// `memory_kind TEXT` column — no schema migration is required
28/// because the column has no CHECK constraint. Old rows with no
29/// kind read as `Observation` (the SQL `DEFAULT 'observation'`).
30/// A future-schema variant a binary doesn't recognise reads as
31/// `Observation` via the `unwrap_or_default()` chain in
32/// `row_to_memory` (forward-compat).
33///
34/// `Observation` is the default for every memory created before v30 (the
35/// `DEFAULT 'observation'` SQL column handles the backfill contract for
36/// rows that pre-date the migration; new inserts that omit the field also
37/// land at `Observation`). `Reflection` is set by the `memory_reflect`
38/// write path in addition to the existing `metadata.type='reflection'`
39/// back-compat marker. `Persona` is set by the QW-2
40/// `PersonaGenerator` and the `memory_persona_generate` MCP tool.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
42#[serde(rename_all = "snake_case")]
43pub enum MemoryKind {
44    /// Default — a direct observation or note from the caller.
45    #[default]
46    Observation,
47    /// A memory synthesised by the reflection pass over lower-depth
48    /// peers (set by `memory_reflect` and the curator reflection pass).
49    Reflection,
50    /// v0.7.0 QW-2 — Persona-as-artifact. A curator-generated
51    /// Markdown profile summarising an entity, derived from a
52    /// cluster of Reflection-kind memories about that entity. The
53    /// `entity_id` + `persona_version` columns on `memories` are
54    /// populated only for this variant.
55    Persona,
56    /// v0.7.x Form 6 — abstract definition / vocabulary term
57    /// ("ownership is a Rust borrow-checker rule").
58    Concept,
59    /// v0.7.x Form 6 — named real-world thing (person, org, product,
60    /// system component). Pairs with `entity_id` on the row when the
61    /// caller has registered the entity in the KG.
62    Entity,
63    /// v0.7.x Form 6 — factual assertion the caller is recording
64    /// ("the build broke at 14:32 UTC"). Distinct from
65    /// `Observation` in that a `Claim` is a propositional commitment;
66    /// a `Reflection` chain may agree or contradict it.
67    Claim,
68    /// v0.7.x Form 6 — typed pair / triple. Anchors a KG relation
69    /// inside the memory substrate so an operator can query the
70    /// relation set with the same recall pipeline used for free-text.
71    Relation,
72    /// v0.7.x Form 6 — temporally-bounded happening
73    /// ("deploy at 09:00", "incident at 14:32"). Distinct from
74    /// `Observation` only when the caller wants the
75    /// downstream-filtering surface to separate "what I saw" from
76    /// "what happened".
77    Event,
78    /// v0.7.x Form 6 — captured dialogue turn (the substrate also
79    /// stores conversations as `Observation`-kind today; this kind
80    /// makes the type explicit for callers that want to filter to
81    /// just conversational atoms).
82    Conversation,
83    /// v0.7.x Form 6 (L1-6 reservation) — choice point with
84    /// rationale. Distinct from `Reflection` in that a `Decision`
85    /// commits to a course of action; reflections summarise. The
86    /// L1-6 work (v0.8.0) will likely add columns for
87    /// rationale / alternatives, but the variant lands now so
88    /// callers can start typing decisions.
89    Decision,
90}
91
92impl MemoryKind {
93    /// Column-wire string (matches the SQL `DEFAULT 'observation'` value).
94    #[must_use]
95    pub fn as_str(&self) -> &'static str {
96        match self {
97            Self::Observation => KIND_OBSERVATION,
98            Self::Reflection => KIND_REFLECTION,
99            Self::Persona => "persona",
100            Self::Concept => "concept",
101            Self::Entity => "entity",
102            Self::Claim => "claim",
103            Self::Relation => "relation",
104            Self::Event => "event",
105            Self::Conversation => "conversation",
106            Self::Decision => "decision",
107        }
108    }
109
110    /// Parse the column-wire string. Returns `None` on unrecognised values
111    /// so callers can fall back to `Observation` (forward-compat with
112    /// future variants that land in a newer DB on an older binary).
113    #[must_use]
114    pub fn from_str(s: &str) -> Option<Self> {
115        match s {
116            KIND_OBSERVATION => Some(Self::Observation),
117            KIND_REFLECTION => Some(Self::Reflection),
118            "persona" => Some(Self::Persona),
119            "concept" => Some(Self::Concept),
120            "entity" => Some(Self::Entity),
121            "claim" => Some(Self::Claim),
122            "relation" => Some(Self::Relation),
123            "event" => Some(Self::Event),
124            "conversation" => Some(Self::Conversation),
125            "decision" => Some(Self::Decision),
126            _ => None,
127        }
128    }
129
130    /// Enumerate every variant in declaration order. Used by the
131    /// capabilities surface (Form 6 `CapabilityMemoryKindVocab`) and
132    /// by the recall filter parser when the caller passes `"all"`.
133    #[must_use]
134    pub fn all() -> &'static [Self] {
135        &[
136            Self::Observation,
137            Self::Reflection,
138            Self::Persona,
139            Self::Concept,
140            Self::Entity,
141            Self::Claim,
142            Self::Relation,
143            Self::Event,
144            Self::Conversation,
145            Self::Decision,
146        ]
147    }
148
149    /// v0.7.x Form 6 — parse a comma-separated list of kind names
150    /// into a deduplicated `Vec<MemoryKind>`.
151    ///
152    /// Two distinct empty cases are intentionally preserved (Cluster E
153    /// audit COR-4 — issue #767):
154    ///   * Input is **empty** (whitespace-only or zero non-empty tokens
155    ///     after trim) → `None`. Callers treat this as "no filter
156    ///     declared, return everything".
157    ///   * Input is **non-empty but every token is unrecognised** (e.g.
158    ///     `"reflektion,observetion"`) → `Some(vec![])`. Callers treat
159    ///     this as "an intentional filter was declared and matched
160    ///     nothing", returning zero rows. Collapsing this case to
161    ///     `None` (the pre-COR-4 behaviour) silently inverted a typo
162    ///     into "show ALL kinds", which is the bug the v0.7.0 audit
163    ///     flagged.
164    ///
165    /// Known tokens are deduplicated; unknown tokens are dropped
166    /// silently (forward-compat — a future variant emitted by a newer
167    /// client should not break recall on an older binary), but the
168    /// distinction above means dropping every token does NOT collapse
169    /// into "no filter".
170    #[must_use]
171    pub fn parse_csv(s: &str) -> Option<Vec<Self>> {
172        let mut out: Vec<Self> = Vec::new();
173        let mut saw_any_token = false;
174        for tok in s.split(',') {
175            let t = tok.trim();
176            if t.is_empty() {
177                continue;
178            }
179            saw_any_token = true;
180            if let Some(k) = Self::from_str(t)
181                && !out.contains(&k)
182            {
183                out.push(k);
184            }
185        }
186        if !saw_any_token {
187            // Input was empty / whitespace-only — caller treats as
188            // "no filter declared".
189            None
190        } else {
191            // At least one non-empty token was supplied. Return the
192            // recognised set verbatim — including the empty-vec case
193            // when every token was unknown, so the caller can apply a
194            // strict "match nothing" filter rather than silently
195            // collapsing to "match everything".
196            Some(out)
197        }
198    }
199}
200
201impl std::fmt::Display for MemoryKind {
202    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203        f.write_str(self.as_str())
204    }
205}
206
207/// v0.7.0 Form 5 (issue #758) — typed discriminator for the provenance
208/// of a memory's `confidence` value.
209///
210/// Stored on `memories.confidence_source TEXT NOT NULL DEFAULT
211/// 'caller_provided'` (schema v39 sqlite / v38 postgres). The auto-
212/// derive engine in [`crate::confidence::derive`] writes
213/// `AutoDerived` when [`crate::confidence::derive`] computes a fresh
214/// value; the calibration sweep writes `Calibrated` when it replaces
215/// the live value with a per-source baseline; the decay updater writes
216/// `Decayed` after applying [`crate::confidence::decay::decayed`] on
217/// recall touch. The (overwhelming-majority) legacy + default bucket
218/// is `CallerProvided`, matching the SQL `DEFAULT` clause.
219///
220/// The discriminator lets recall ranking and the forensic bundle
221/// reason about the trust path of a confidence score without re-running
222/// the derivation. The calibration CLI scans the partial index
223/// `idx_memories_confidence_source` (which excludes `caller_provided`)
224/// to enumerate derived / calibrated / decayed rows cheaply.
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
226#[serde(rename_all = "snake_case")]
227pub enum ConfidenceSource {
228    /// The legacy and default bucket — the caller's value was accepted
229    /// verbatim. Matches the SQL `DEFAULT 'caller_provided'` clause on
230    /// the `confidence_source` column added in schema v39 (sqlite) /
231    /// v38 (postgres).
232    #[default]
233    CallerProvided,
234    /// The Form 5 auto-derive engine (`crate::confidence::derive`)
235    /// computed the value at write time from row signals (atom
236    /// derivation, prior-corroboration count, source age, namespace
237    /// baseline). Opt-in via `AI_MEMORY_AUTO_CONFIDENCE=1`.
238    AutoDerived,
239    /// The calibration sweep (`ai-memory calibrate confidence
240    /// --from-shadow`) replaced the live value with a per-source
241    /// baseline computed from observed shadow-mode samples.
242    Calibrated,
243    /// The freshness-decay updater (`crate::confidence::decay`) wrote
244    /// a decayed copy of the previous value, bumping
245    /// `confidence_decayed_at`. Fires when
246    /// `AI_MEMORY_CONFIDENCE_DECAY=1` or the namespace policy
247    /// `confidence_decay_half_life_days` is set.
248    Decayed,
249    /// v0.7.0 issue #1242 — the curator engine (atomisation
250    /// `LlmCurator`, persona generator) computed the value at row-
251    /// mint time without an explicit caller-supplied number. Atom
252    /// rows inherit `confidence` from their parent memory; persona
253    /// rows pin `confidence = 1.0` per the QW-2 brief. In both
254    /// cases the value is engine-derived, not caller-supplied, and
255    /// must be discoverable to the calibration sweep + the partial
256    /// index `idx_memories_confidence_source` (which excludes
257    /// `caller_provided`). Pre-#1242 these rows mis-labelled
258    /// `confidence_source = CallerProvided`, hiding them from the
259    /// derived-row enumeration and violating the audit-honesty
260    /// invariant.
261    CuratorDerived,
262    /// v0.7.x issue #1591 — the caller OMITTED `confidence` and the
263    /// store surface stamped the compiled [`DEFAULT_CONFIDENCE`]
264    /// fallback. Pre-#1591 these rows mis-labelled
265    /// `confidence_source = 'caller_provided'` — a false provenance
266    /// claim that made an unexamined 1.0 indistinguishable from a
267    /// caller's deliberate full-confidence assertion. The Form-5
268    /// calibration / decay engines treat this bucket exactly like
269    /// `caller_provided` (the value is not engine-derived), but
270    /// auditors and recall ranking can now discount the compiled
271    /// fallback honestly.
272    Default,
273}
274
275impl ConfidenceSource {
276    /// Column-wire string (matches the SQL `DEFAULT 'caller_provided'`
277    /// value and the four documented discriminator values).
278    #[must_use]
279    pub fn as_str(&self) -> &'static str {
280        match self {
281            Self::CallerProvided => "caller_provided",
282            Self::AutoDerived => "auto_derived",
283            Self::Calibrated => "calibrated",
284            Self::Decayed => "decayed",
285            Self::CuratorDerived => "curator_derived",
286            Self::Default => "default",
287        }
288    }
289
290    /// Parse the column-wire string. Returns `None` on unrecognised
291    /// values so callers can fall back to `CallerProvided` (forward-
292    /// compat with future variants that land in a newer DB on an
293    /// older binary).
294    #[must_use]
295    pub fn from_str(s: &str) -> Option<Self> {
296        match s {
297            "caller_provided" => Some(Self::CallerProvided),
298            "auto_derived" => Some(Self::AutoDerived),
299            "calibrated" => Some(Self::Calibrated),
300            "decayed" => Some(Self::Decayed),
301            "curator_derived" => Some(Self::CuratorDerived),
302            "default" => Some(Self::Default),
303            _ => None,
304        }
305    }
306}
307
308impl std::fmt::Display for ConfidenceSource {
309    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310        f.write_str(self.as_str())
311    }
312}
313
314/// v0.7.0 Form 5 (issue #758) — JSON snapshot of the signals that
315/// produced an auto-derived or calibrated confidence value.
316///
317/// Stored on `memories.confidence_signals TEXT NULL` (schema v39
318/// sqlite / v38 postgres) as a JSON-encoded envelope. NULL on legacy
319/// rows and on rows whose `confidence_source = 'caller_provided'`.
320/// Also written verbatim into the `confidence_shadow_observations.signals`
321/// column per recall when shadow mode is enabled.
322///
323/// An auditor can reconstruct the derivation after the fact by
324/// inspecting this snapshot — the recall ranker and the forensic
325/// bundle preserve it across reads, so a downstream review never
326/// needs to re-query the substrate at the then-current state.
327#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
328pub struct ConfidenceSignals {
329    /// Age (in days) of the source memory at the moment of derivation.
330    /// Drives the `freshness_factor` exponent.
331    pub source_age_days: f64,
332    /// Whether the row is an atom of an existing memory (`atom_of IS
333    /// NOT NULL`). Atom rows inherit higher base confidence because
334    /// their provenance is anchored to a curator-validated parent.
335    pub atom_derivation: bool,
336    /// Count of related memories (via `memory_links`) at the moment of
337    /// derivation. More corroboration → higher confidence; the
338    /// formula uses `log10(1 + count)` to keep the bump sub-linear.
339    pub prior_corroboration_count: i64,
340    /// Pre-computed freshness factor `exp(-age / half_life)` clamped
341    /// to `[0, 1]`. Stored alongside `source_age_days` so a future
342    /// review can verify the half-life used at write time.
343    pub freshness_factor: f64,
344    /// Per-source baseline from the calibration table (median derived
345    /// confidence for the row's `(namespace, source)` pair). `0.5`
346    /// when no calibrated baseline exists yet.
347    pub baseline_per_source: f64,
348}
349
350impl Default for ConfidenceSignals {
351    fn default() -> Self {
352        Self {
353            source_age_days: 0.0,
354            atom_derivation: false,
355            prior_corroboration_count: 0,
356            freshness_factor: 1.0,
357            baseline_per_source: 0.5,
358        }
359    }
360}
361
362/// Memory-lifecycle tier — short (6h TTL) / mid (7d TTL) / long
363/// (permanent). Drives the create-time backstop, the touch-time
364/// sliding window, the auto-promotion at 5 accesses (mid → long),
365/// the GC sweep, and the recall ranker's per-tier bonus.
366///
367/// # Disambiguation (issue #970)
368///
369/// The codebase has three enums whose names end in `Tier`. They are
370/// orthogonal — same descriptive substring, distinct domains:
371///
372/// - [`Tier`] (this enum) — memory-lifecycle TTL bucket.
373/// - [`ConfidenceTier`] — confidence-value bucket (Confirmed /
374///   Likely / Ambiguous) derived from `Memory.confidence` thresholds.
375///   Operator dashboards / human-review queues filter on it.
376/// - [`crate::config::FeatureTier`] — host capability tier
377///   (Keyword / Semantic / Smart / Autonomous) that gates which AI
378///   features the host can fit in RAM.
379///
380/// They do not share variants, do not share wire strings, and are
381/// never substitutable. See `docs/internal/enum-proliferation-audit-970.md`.
382#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
383#[serde(rename_all = "snake_case")]
384pub enum Tier {
385    Short,
386    Mid,
387    Long,
388}
389
390impl Tier {
391    pub fn as_str(&self) -> &'static str {
392        match self {
393            Self::Short => "short",
394            Self::Mid => "mid",
395            Self::Long => "long",
396        }
397    }
398
399    /// Parse a tier wire string into the typed enum.
400    ///
401    /// The string literals in the match arms below are the **canonical
402    /// deserializer** for the `Tier` wire form. They are the one place
403    /// in the codebase where raw `"short"` / `"mid"` / `"long"` literals
404    /// legitimately appear, because this is the boundary where a
405    /// caller-supplied `&str` (HTTP body field, MCP JSON param, CLI
406    /// flag value, TOML config field) gets dispatched into the typed
407    /// enum. They are intentionally byte-equal to
408    /// [`Tier::as_str`]'s outputs so the round-trip is identity.
409    /// Anywhere else that *constructs* a tier wire value MUST route
410    /// through `Tier::<X>.as_str()` instead of restamping a fresh
411    /// literal. See pm-v3.1 PR6 (#1174) for the sweep that pinned this
412    /// invariant.
413    pub fn from_str(s: &str) -> Option<Self> {
414        match s {
415            "short" => Some(Self::Short),
416            "mid" => Some(Self::Mid),
417            "long" => Some(Self::Long),
418            _ => None,
419        }
420    }
421
422    /// Numeric rank for tier comparison: Short=0, Mid=1, Long=2.
423    #[cfg(test)]
424    pub fn rank(&self) -> u8 {
425        match self {
426            Self::Short => 0,
427            Self::Mid => 1,
428            Self::Long => 2,
429        }
430    }
431
432    pub fn default_ttl_secs(&self) -> Option<i64> {
433        match self {
434            Self::Short => Some(6 * crate::SECS_PER_HOUR),
435            Self::Mid => Some(crate::SECS_PER_WEEK),
436            Self::Long => None,
437        }
438    }
439}
440
441impl std::fmt::Display for Tier {
442    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
443        f.write_str(self.as_str())
444    }
445}
446
447#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct Memory {
449    pub id: String,
450    pub tier: Tier,
451    pub namespace: String,
452    pub title: String,
453    pub content: String,
454    pub tags: Vec<String>,
455    pub priority: i32,
456    /// 0.0-1.0 — how certain is this memory
457    pub confidence: f64,
458    /// Who/what created this row. Role-categorical, not vendor-specific.
459    /// Canonical closed set lives in [`crate::validate::VALID_SOURCES`]
460    /// at v0.7.0:
461    ///   `user`, `nhi` ([`crate::validate::DEFAULT_NHI_SOURCE`] — the
462    ///   vendor-neutral substrate default for AI-NHI-minted writes per
463    ///   #1175), `claude` (deprecated; back-compat only, removal in
464    ///   v0.8.x), `hook`, `api`, `cli`, `import`, `consolidation`,
465    ///   `system`, `chaos`, `notify` (S32 inbox replication path).
466    /// Validator surface: [`crate::validate::validate_source`].
467    pub source: String,
468    pub access_count: i64,
469    pub created_at: String,
470    pub updated_at: String,
471    #[serde(skip_serializing_if = "Option::is_none")]
472    pub last_accessed_at: Option<String>,
473    #[serde(skip_serializing_if = "Option::is_none")]
474    pub expires_at: Option<String>,
475    #[serde(default = "default_metadata")]
476    pub metadata: Value,
477    /// v0.7.0 Task 1/8 (recursive learning) — depth in the substrate-native
478    /// reflection recursion tree. `0` for memories minted directly from a
479    /// caller (or any pre-v0.7.0 row), positive for memories synthesised by
480    /// the reflection pass over lower-depth peers. Operators can cap recursion
481    /// depth at write time; readers can filter / sort by it.
482    ///
483    /// `#[serde(default)]` lets pre-v0.7.0 JSON payloads (and older federation
484    /// peers) deserialize cleanly — missing → 0, which matches the SQL
485    /// `DEFAULT 0` on the column added in schema v29 (SQLite) / v31 (Postgres).
486    #[serde(default)]
487    pub reflection_depth: i32,
488    /// L1-1 (v0.7.0) — typed memory-kind discriminator.  Stored in
489    /// `memories.memory_kind TEXT NOT NULL DEFAULT 'observation'` (schema v30).
490    /// `Observation` for every pre-v30 row (SQL default); `Reflection` for
491    /// memories minted by `memory_reflect` or the curator reflection pass.
492    ///
493    /// `#[serde(default)]` ensures round-trips with pre-v30 federation peers
494    /// that don't yet emit the field.
495    #[serde(default)]
496    pub memory_kind: MemoryKind,
497    /// v0.7.0 QW-2 — populated only when `memory_kind == Persona`.
498    /// Identifies the subject of the persona. Stored on the SQL
499    /// column `memories.entity_id TEXT NULL` (schema v36).
500    /// `skip_serializing_if = "Option::is_none"` keeps the absent
501    /// shape on the wire for pre-QW-2 federation peers.
502    #[serde(default, skip_serializing_if = "Option::is_none")]
503    pub entity_id: Option<String>,
504    /// v0.7.0 QW-2 — monotonic per-(entity_id, namespace) version
505    /// counter for the Persona artefact. Populated only when
506    /// `memory_kind == Persona`. Each `PersonaGenerator::generate`
507    /// call writes a new row with `version + 1`; older rows stay
508    /// queryable for audit / rollback.
509    #[serde(default, skip_serializing_if = "Option::is_none")]
510    pub persona_version: Option<i32>,
511    /// v0.7.0 Form 4 (issue #757) — fact-provenance citations array.
512    /// Each entry carries a typed [`Citation`] envelope (uri,
513    /// accessed_at, optional hash, optional span). Stored on the
514    /// `memories.citations` TEXT column (schema v38) as a JSON-encoded
515    /// array — legacy rows default to an empty vector via the SQL
516    /// `DEFAULT '[]'` clause and the serde default below. Validator
517    /// surface lives at `crate::validate::validate_citation`.
518    ///
519    /// **NSA CSI MCP Security mapping.** Part of the Form 4
520    /// fact-provenance triple (`citations` + `source_uri` +
521    /// `source_span`) that addresses NSA concerns (b) Insecure
522    /// context or data serialization + (g) Poor or missing audit
523    /// logs, and contributes to NSA recommendations (c) Validate
524    /// parameters + (f) Filter and monitor output pipelines per the
525    /// National Security Agency Cybersecurity Information document
526    /// on MCP security (U/OO/6030316-26 | PP-26-1834, May 2026
527    /// Version 1.0). Capability inventory anchor:
528    /// `form_4_fact_provenance`. The mapping is described — without
529    /// implying NSA endorsement of ai-memory or AlphaOne LLC — at
530    /// `docs/compliance/nsa-csi-mcp.html` §3.2 / §3.7 / §4.3 / §4.6.
531    #[serde(default)]
532    pub citations: Vec<Citation>,
533    /// v0.7.0 Form 4 (issue #757) — first-class URI-form pointer to
534    /// the cited source body. Distinct from the role-label `source`
535    /// column. Accepted schemes: `uri:` (HTTP URL), `doc:` (substrate
536    /// doc id), `file:` (filesystem path). Validator surface lives at
537    /// `crate::validate::validate_source_uri`. Mapped onto the
538    /// `memories.source_uri` TEXT column (schema v38). NULL on legacy
539    /// rows and on rows that do not yet carry a URI form.
540    #[serde(default, skip_serializing_if = "Option::is_none")]
541    pub source_uri: Option<String>,
542    /// v0.7.0 Form 4 (issue #757) — byte-range into the parent source
543    /// body. Populated by the WT-1-B atomisation writer for each atom
544    /// (atom-grain span fact-provenance) and may be set by callers
545    /// who can pin the offset of a memory inside its referenced
546    /// source. Mapped onto the `memories.source_span` TEXT column
547    /// (schema v38) as a JSON `{start, end}` envelope. Validator
548    /// surface lives at `crate::validate::validate_source_span`.
549    #[serde(default, skip_serializing_if = "Option::is_none")]
550    pub source_span: Option<SourceSpan>,
551    /// v0.7.0 Form 5 (issue #758) — typed discriminator naming the
552    /// provenance of the `confidence` value. Stored on
553    /// `memories.confidence_source TEXT NOT NULL DEFAULT
554    /// 'caller_provided'` (schema v39 sqlite / v38 postgres). Defaults
555    /// to `CallerProvided` for every legacy row and every write that
556    /// arrives with the auto-derive engine disabled.
557    #[serde(default)]
558    pub confidence_source: ConfidenceSource,
559    /// v0.7.0 Form 5 — JSON snapshot of the signals that produced an
560    /// auto-derived or calibrated confidence value. Mapped onto
561    /// `memories.confidence_signals TEXT NULL` (schema v39 sqlite /
562    /// v38 postgres). NULL on legacy rows and on rows whose
563    /// `confidence_source = CallerProvided`.
564    #[serde(default, skip_serializing_if = "Option::is_none")]
565    pub confidence_signals: Option<ConfidenceSignals>,
566    /// v0.7.0 Form 5 — RFC3339 stamp of the last decay computation.
567    /// Mapped onto `memories.confidence_decayed_at TEXT NULL` (schema
568    /// v39 sqlite / v38 postgres). NULL on legacy rows and on rows
569    /// never touched by the decay updater.
570    #[serde(default, skip_serializing_if = "Option::is_none")]
571    pub confidence_decayed_at: Option<String>,
572    /// v0.7.0 Provenance Gap 1 (issue #884, schema v45 sqlite) —
573    /// optimistic-concurrency counter. Bumped on every mutation:
574    /// `storage::update` AND the `(title, namespace)` upsert-merge arm
575    /// of `storage::insert` (#1632). Two callers writing against the
576    /// same `expected_version` race exactly one winner; the loser
577    /// receives a typed `CONFLICT` envelope naming the current stored
578    /// version. The confidence-decay sweep is the only documented
579    /// non-bumping mutator (tests/non_version_bumping_sites_1036.rs).
580    /// Legacy rows land at `version = 1` via the SQL DEFAULT
581    /// clause. `#[serde(default = "default_memory_version")]` keeps
582    /// pre-v45 federation peers / JSON payloads deserialising cleanly.
583    #[serde(default = "default_memory_version")]
584    pub version: i64,
585}
586
587impl Memory {
588    /// Total number of declared `pub <name>: <type>` fields on the
589    /// `Memory` struct at v0.7.0. SSOT for the "26-field struct at
590    /// v0.7.0 (was 15 at v0.6.x)" narrative in CLAUDE.md / README.md /
591    /// ROADMAP.md / release-notes. Adding or removing a field requires
592    /// bumping this const in the same commit, OR the parity test pin
593    /// at `tests/memory_field_count_invariant.rs` fails the build.
594    ///
595    /// Multi-agent literal-sweep reference: scanner B finding F-B1.x
596    /// (Memory shape drift), mirrors the
597    /// `MemoryLinkRelation::COUNT` + `EXPECTED_CLI_SUBCOMMANDS_*`
598    /// drift-blocker pattern landed in commits 960578cfd + 233e8a247.
599    pub const FIELD_COUNT: usize = 26;
600
601    /// v0.7.0 #1466 — the `expires_at` value a fresh store must persist.
602    /// An explicit value the caller supplied wins; otherwise a non-`Long`
603    /// row is stamped with `created_at + Tier::default_ttl_secs()` so it
604    /// is reapable by GC (`expires_at IS NOT NULL AND expires_at < now`).
605    /// `Long` rows have no TTL and stay immortal (returns `None`).
606    ///
607    /// Single SSOT for the tier-default backfill across every store
608    /// backend (SQLite `storage::insert` + the `insert_with_conflict` /
609    /// `insert_if_newer` / `consolidate` siblings, and the Postgres
610    /// `store` path). Before this, those paths bound `expires_at`
611    /// verbatim, so any internal caller that hand-built a `mid`/`short`
612    /// Memory with `expires_at: None` created an immortal row GC could
613    /// never collect. The interval comes from `Tier::default_ttl_secs()`
614    /// — no hardcoded TTL literal — so it can never drift from the
615    /// canonical per-tier TTL. Output mirrors the normal store path
616    /// (`to_rfc3339`) so the string comparison in `gc()` stays
617    /// monotonic; a malformed `created_at` falls back to `now` rather
618    /// than silently dropping the expiry.
619    #[must_use]
620    pub fn effective_expires_at(&self) -> Option<String> {
621        if self.expires_at.is_some() {
622            return self.expires_at.clone();
623        }
624        let ttl = self.tier.default_ttl_secs()?;
625        let base = chrono::DateTime::parse_from_rfc3339(&self.created_at)
626            .map(|dt| dt.with_timezone(&chrono::Utc))
627            .unwrap_or_else(|_| chrono::Utc::now());
628        Some((base + chrono::Duration::seconds(ttl)).to_rfc3339())
629    }
630}
631
632/// Default for [`Memory::version`] on rows that pre-date schema v45
633/// (or JSON payloads from clients that haven't learned about the
634/// column yet). Matches the SQL DEFAULT clause on the column.
635#[must_use]
636pub fn default_memory_version() -> i64 {
637    1
638}
639
640/// v0.7.0 Provenance Gap 5 (issue #888) — typed edit-source
641/// discriminator gating the `storage::update` write-path branch.
642///
643/// * [`EditSource::Human`] (default) — direct in-place mutation, the
644///   v0.6.x / pre-Gap-5 behaviour. Content is overwritten; the row's
645///   `version` is bumped; no archive is created.
646/// * [`EditSource::Llm`] / [`EditSource::Hook`] — append-and-archive.
647///   A NEW memory row is minted carrying the patched content; a
648///   `supersedes` link is written pointing new→old; the OLD row is
649///   archived with `archive_reason = 'superseded'` so callers can
650///   rewind via `memory_archive_list` to read the pre-edit state.
651///
652/// The split exists so caller intent (human-typed correction vs.
653/// curator/LLM rewrite) is preserved in the audit trail. Mem9's
654/// pattern: in-place for human edits, append-and-archive for
655/// programmatic rewrites where the new content semantically replaces
656/// the old.
657#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
658#[serde(rename_all = "snake_case")]
659pub enum EditSource {
660    /// Direct in-place mutation of the existing row. Default.
661    #[default]
662    Human,
663    /// Append-and-archive: mint a NEW row + supersedes link + archive
664    /// the OLD row with `archive_reason='superseded'`.
665    Llm,
666    /// Append-and-archive: same shape as [`EditSource::Llm`] but
667    /// records that a substrate hook triggered the rewrite.
668    Hook,
669    /// v0.7.x issue #1600 — direct in-place mutation performed by an
670    /// AI/NHI agent. Mutation semantics are IDENTICAL to
671    /// [`EditSource::Human`] (does NOT route through
672    /// append-and-archive); the variant exists so the audit trail can
673    /// distinguish a human-typed correction from an agent-initiated
674    /// in-place edit. When `edit_source` is omitted on `memory_update`
675    /// the default is derived from the resolved caller id via
676    /// [`EditSource::default_for_agent_id`].
677    Agent,
678}
679
680impl EditSource {
681    /// #1600 — the closed wire vocabulary, in declaration order. The
682    /// `memory_update` validation error names the valid set from this
683    /// const so the message can never drift from the parser below.
684    pub const ALL: [Self; 4] = [Self::Human, Self::Llm, Self::Hook, Self::Agent];
685
686    /// Column-wire string used in audit log entries + the archive
687    /// row's `archive_reason`-adjacent metadata.
688    #[must_use]
689    pub fn as_str(&self) -> &'static str {
690        match self {
691            Self::Human => "human",
692            Self::Llm => "llm",
693            Self::Hook => "hook",
694            Self::Agent => "agent",
695        }
696    }
697
698    /// Parse the column-wire string. Returns `None` on unrecognised
699    /// values; per #1600 the MCP `memory_update` surface now surfaces
700    /// `None` as a validation ERROR naming [`EditSource::ALL`] instead
701    /// of silently defaulting to [`EditSource::Human`].
702    #[must_use]
703    pub fn from_str(s: &str) -> Option<Self> {
704        match s {
705            "human" => Some(Self::Human),
706            "llm" => Some(Self::Llm),
707            "hook" => Some(Self::Hook),
708            "agent" => Some(Self::Agent),
709            _ => None,
710        }
711    }
712
713    /// #1600 — default edit-source for an UPDATE whose caller omitted
714    /// `edit_source`, derived from the resolved caller agent id: ids
715    /// under [`crate::identity::sentinels::AI_AGENT_ID_PREFIX`]
716    /// (`ai:…`) default to [`EditSource::Agent`]; every other shape
717    /// (`host:…`, `anonymous:…`, bare operator ids) keeps the
718    /// historical [`EditSource::Human`] default.
719    #[must_use]
720    pub fn default_for_agent_id(agent_id: &str) -> Self {
721        if agent_id.starts_with(crate::identity::sentinels::AI_AGENT_ID_PREFIX) {
722            Self::Agent
723        } else {
724            Self::Human
725        }
726    }
727
728    /// `true` when the edit-source semantics call for the
729    /// append-and-archive write path (vs. in-place mutation).
730    #[must_use]
731    pub fn appends_and_archives(&self) -> bool {
732        matches!(self, Self::Llm | Self::Hook)
733    }
734}
735
736/// v0.7.0 Form 4 (issue #757) — fact-provenance citation envelope.
737///
738/// One entry inside `Memory::citations`. The shape mirrors common
739/// scholarly-citation needs while staying substrate-friendly:
740///
741/// * `uri` — URL, `doc:<id>` substrate pointer, or `file:<path>`. The
742///   validator (`crate::validate::validate_citation`) rejects bare
743///   strings; callers must use one of the typed schemes.
744/// * `accessed_at` — RFC3339 timestamp at which the cited source was
745///   read by the agent. Captures the fact-grain "when did this claim
746///   become known to me" datum.
747/// * `hash` — optional SHA-256 of the cited content. Lets a downstream
748///   verifier confirm the source has not drifted since capture.
749/// * `span` — optional byte-range pinning the specific quote inside
750///   the cited body. Composes with `Memory::source_span` for
751///   atom-grain lineage (the parent's span points into the source,
752///   the atom's `source_span` points into the parent's body).
753#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
754pub struct Citation {
755    pub uri: String,
756    pub accessed_at: String,
757    #[serde(default, skip_serializing_if = "Option::is_none")]
758    pub hash: Option<String>,
759    #[serde(default, skip_serializing_if = "Option::is_none")]
760    pub span: Option<SourceSpan>,
761}
762
763/// v0.7.0 Form 4 (issue #757) — byte-range envelope used by
764/// `Memory::source_span` and `Citation::span`.
765///
766/// `start` and `end` are zero-based byte offsets into the parent
767/// body. The half-open convention `[start, end)` matches Rust's
768/// slice semantics, so the cited slice is `body[start..end]`. The
769/// validator (`crate::validate::validate_source_span`) requires
770/// `start < end` and bounds both within `usize::MAX`.
771#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
772pub struct SourceSpan {
773    pub start: usize,
774    pub end: usize,
775}
776
777/// v0.7.0 Gap 4 (issue #887) — derived enum partitioning the
778/// `confidence` real into operator-meaningful buckets so callers
779/// (especially read-side reviewers) can filter by tier instead of
780/// re-deriving thresholds at every site.
781///
782/// Thresholds are stable and load-bearing — operators have wired
783/// dashboards / human-review queues against them and a change here
784/// is a wire-level break. Bumping a threshold is therefore a
785/// schema-bump-class decision, NOT a code-tuning decision.
786///
787/// - [`ConfidenceTier::Confirmed`] — `>= 0.95`. High-confidence
788///   substrate-curated atoms, typically calibrated by the Form 5
789///   pipeline or asserted by a trusted upstream.
790/// - [`ConfidenceTier::Likely`] — `0.7 ..= 0.949…`. Default
791///   caller-provided observations sit here.
792/// - [`ConfidenceTier::Ambiguous`] — `< 0.7`. The human-review
793///   queue: the caller themselves flagged uncertainty (or the
794///   decay updater walked the value down). Operators commonly
795///   filter their review tool against this tier.
796///
797/// Surfaced to MCP callers via the `confidence_calibration.tier_thresholds`
798/// block on `memory_capabilities` (Gap 4 read-path closeout).
799///
800/// # Disambiguation (issue #970)
801///
802/// The codebase has three enums whose names end in `Tier`.
803/// `ConfidenceTier` (this enum) is the **confidence-value bucket**;
804/// it is unrelated to:
805///
806/// - [`Tier`] — memory-lifecycle TTL bucket (Short/Mid/Long).
807/// - [`crate::config::FeatureTier`] — host capability tier
808///   (Keyword/Semantic/Smart/Autonomous).
809///
810/// They do not share variants, wire strings, or call sites. See
811/// `docs/internal/enum-proliferation-audit-970.md`.
812#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
813#[serde(rename_all = "snake_case")]
814pub enum ConfidenceTier {
815    Confirmed,
816    Likely,
817    Ambiguous,
818}
819
820impl ConfidenceTier {
821    /// Inclusive lower bound for [`ConfidenceTier::Confirmed`]. Above
822    /// this is a high-confidence observation / calibration result.
823    pub const CONFIRMED_MIN: f64 = 0.95;
824    /// Inclusive lower bound for [`ConfidenceTier::Likely`]. Below
825    /// this is the human-review tier ([`ConfidenceTier::Ambiguous`]).
826    pub const LIKELY_MIN: f64 = 0.7;
827
828    /// Bucket a raw confidence value. NaN is conservatively mapped
829    /// to [`ConfidenceTier::Ambiguous`] so a corrupt input lands in
830    /// the human-review queue rather than masquerading as confirmed.
831    #[must_use]
832    pub fn from_confidence(c: f64) -> Self {
833        if c.is_nan() {
834            return Self::Ambiguous;
835        }
836        if c >= Self::CONFIRMED_MIN {
837            Self::Confirmed
838        } else if c >= Self::LIKELY_MIN {
839            Self::Likely
840        } else {
841            Self::Ambiguous
842        }
843    }
844
845    /// Wire string for this tier. Matches the serde `rename_all =
846    /// "snake_case"` derive above so the JSON and the unstructured
847    /// helper agree.
848    #[must_use]
849    pub fn as_str(&self) -> &'static str {
850        match self {
851            Self::Confirmed => "confirmed",
852            Self::Likely => "likely",
853            Self::Ambiguous => "ambiguous",
854        }
855    }
856
857    /// Parse a wire string back into the enum. Returns `None` on
858    /// unrecognised input so callers can decide whether to error or
859    /// fall through to "no filter".
860    #[must_use]
861    pub fn parse(s: &str) -> Option<Self> {
862        match s.trim().to_ascii_lowercase().as_str() {
863            "confirmed" => Some(Self::Confirmed),
864            "likely" => Some(Self::Likely),
865            "ambiguous" => Some(Self::Ambiguous),
866            _ => None,
867        }
868    }
869}
870
871impl Memory {
872    /// v0.7.0 Gap 4 (#887) — derived [`ConfidenceTier`] for this
873    /// memory's `confidence` value. Stable mapping; see
874    /// [`ConfidenceTier::from_confidence`] for the thresholds.
875    #[must_use]
876    pub fn confidence_tier(&self) -> ConfidenceTier {
877        ConfidenceTier::from_confidence(self.confidence)
878    }
879}
880
881impl Default for Memory {
882    /// All-zero / empty defaults. Useful as a base for ad-hoc test fixtures
883    /// — `Memory { id: ..., title: ..., ..Default::default() }` — and for
884    /// `#[serde(default)]` deserialisation of partial JSON. Tier defaults to
885    /// `Mid` to match the API-layer default in [`CreateMemory`].
886    fn default() -> Self {
887        Self {
888            id: String::new(),
889            tier: Tier::Mid,
890            namespace: crate::DEFAULT_NAMESPACE.to_string(),
891            title: String::new(),
892            content: String::new(),
893            tags: Vec::new(),
894            priority: 5,
895            confidence: DEFAULT_CONFIDENCE,
896            source: "api".to_string(),
897            access_count: 0,
898            created_at: String::new(),
899            updated_at: String::new(),
900            last_accessed_at: None,
901            expires_at: None,
902            metadata: default_metadata(),
903            reflection_depth: 0,
904            memory_kind: MemoryKind::Observation,
905            entity_id: None,
906            persona_version: None,
907            citations: Vec::new(),
908            source_uri: None,
909            source_span: None,
910            confidence_source: ConfidenceSource::CallerProvided,
911            confidence_signals: None,
912            confidence_decayed_at: None,
913            version: default_memory_version(),
914        }
915    }
916}
917
918#[derive(Debug, Deserialize)]
919pub struct CreateMemory {
920    #[serde(default = "default_tier")]
921    pub tier: Tier,
922    #[serde(default = "default_namespace")]
923    pub namespace: String,
924    pub title: String,
925    pub content: String,
926    #[serde(default)]
927    pub tags: Vec<String>,
928    #[serde(default = "default_priority")]
929    pub priority: i32,
930    /// Confidence 0.0–1.0. `None` (caller omitted the field) resolves
931    /// to [`DEFAULT_CONFIDENCE`] with truthful
932    /// `confidence_source = "default"` provenance (#1591) via
933    /// [`CreateMemory::resolved_confidence`] /
934    /// [`CreateMemory::resolved_confidence_source`].
935    #[serde(default)]
936    pub confidence: Option<f64>,
937    #[serde(default = "default_source")]
938    pub source: String,
939    #[serde(default)]
940    pub expires_at: Option<String>,
941    #[serde(default)]
942    pub ttl_secs: Option<i64>,
943    #[serde(default = "default_metadata")]
944    pub metadata: Value,
945    /// Optional agent identifier. When unset, the server resolves a default
946    /// via `crate::identity` (NHI-hardened precedence chain).
947    #[serde(default)]
948    pub agent_id: Option<String>,
949    /// Optional visibility scope (Task 1.5). One of `VALID_SCOPES`. When
950    /// unset, treated as `private` by the query layer.
951    #[serde(default)]
952    pub scope: Option<String>,
953    /// v0.6.3.1 P2 (G6) — collision policy when (title, namespace) already
954    /// exists. One of `error` | `merge` | `version`. When unset, the
955    /// daemon defaults to `error` for HTTP callers (HTTP is not legacy
956    /// like MCP v1; clients that want the legacy silent-merge contract
957    /// must opt in explicitly).
958    #[serde(default)]
959    pub on_conflict: Option<String>,
960    /// v0.7.0 (issue #519) — when `Some(true)`, run a proactive
961    /// `detect_contradiction` LLM probe against same-namespace memories
962    /// BEFORE returning 201, regardless of `autonomous_hooks`. When
963    /// `Some(false)`, force-disable detection even if `autonomous_hooks`
964    /// is on. When `None`, defer to `autonomous_hooks`.
965    ///
966    /// Surface: the 201 response body grows a `conflicts: [{...}]` array
967    /// listing every same-namespace candidate the LLM flags as
968    /// contradictory. Each entry carries the candidate id, title, and
969    /// (when LLM produces one) a `suggested_merge` content string the
970    /// caller can pass to a follow-up `memory_consolidate`.
971    #[serde(default)]
972    pub detect_conflicts: Option<bool>,
973    /// v0.7.0 (issue #519) — proactive contradiction detection bypass.
974    /// When `true`, the substrate-level `proactive_conflict_check` is
975    /// skipped on this write so a near-duplicate-with-differing-content
976    /// row is inserted anyway. Default `false` preserves the new v0.7.0
977    /// refuse-by-default posture; callers that explicitly want the
978    /// conflicting fact to land alongside the existing one set
979    /// `force=true`.
980    #[serde(default)]
981    pub force: bool,
982    /// v0.7.0 Form 4 (issue #757) — fact-provenance citations
983    /// supplied at write time. Each entry must satisfy
984    /// `validate::validate_citation`. Empty by default.
985    #[serde(default)]
986    pub citations: Vec<Citation>,
987    /// v0.7.0 Form 4 — optional URI-form pointer to the cited source
988    /// body. Must satisfy `validate::validate_source_uri` when set.
989    #[serde(default)]
990    pub source_uri: Option<String>,
991    /// v0.7.0 Form 4 — optional byte-range into the parent source
992    /// body. Must satisfy `validate::validate_source_span` when set.
993    #[serde(default)]
994    pub source_span: Option<SourceSpan>,
995    /// v0.7.x Form 6 (#1385) — Batman-taxonomy memory-kind selector for
996    /// the new row. Accepts any [`MemoryKind`] wire token
997    /// (`observation` | `reflection` | `persona` | `concept` | `entity`
998    /// | `claim` | `relation` | `event` | `conversation` | `decision`).
999    /// Unknown values are silently ignored (treated as omission) for
1000    /// forward-compat with future variants, mirroring the MCP
1001    /// `memory_store` `params["kind"]` contract at
1002    /// `src/mcp/tools/store/validation.rs:207-213`. Absent / unknown
1003    /// → handler defaults to `MemoryKind::Observation`. Stored as
1004    /// `Option<String>` (not `Option<MemoryKind>`) so unknown future
1005    /// tokens deserialise without breaking the request envelope.
1006    ///
1007    /// Pre-#1385 this field did not exist on `CreateMemory`, so HTTP
1008    /// `POST /api/v1/memories` silently dropped the caller's `kind`
1009    /// and every HTTP-created row landed as `Observation`. The Form 6
1010    /// recall `kinds` filter then returned zero rows against HTTP-
1011    /// written data even when the caller had stored `kind: "claim"`
1012    /// (the v3 NHI assessment defect D-v3-3 reproducible against the
1013    /// alice lan-parity postgres-backed daemon).
1014    #[serde(default)]
1015    pub kind: Option<String>,
1016    /// #626 Layer-3 (C7) — detached Ed25519 agent-attestation signature,
1017    /// standard base64, over the `SignableWrite` envelope
1018    /// (`agent_id + namespace + title + kind + created_at +
1019    /// sha256(content)`). When present, `created_at` MUST also be supplied
1020    /// (the signer cannot predict the server clock); a signature that
1021    /// fails to verify against the agent's bound public key is rejected
1022    /// with 403. Absent ⇒ legacy unsigned write unless the operator set
1023    /// `AI_MEMORY_REQUIRE_AGENT_ATTESTATION`, in which case the gate
1024    /// rejects the unsigned store.
1025    #[serde(default)]
1026    pub signature: Option<String>,
1027    /// #626 Layer-3 (C7) — RFC3339 timestamp the caller signed. Required
1028    /// when `signature` is present; the server validates it against the
1029    /// ±300s attestation freshness window and then adopts it verbatim so
1030    /// the verifier re-derives the identical signed envelope.
1031    #[serde(default)]
1032    pub created_at: Option<String>,
1033}
1034
1035/// Compiled default `confidence` stamped when a store surface (MCP
1036/// `memory_store`, HTTP `POST /api/v1/memories`, CLI `ai-memory store`)
1037/// receives no explicit caller value. #1591 — rows minted from this
1038/// fallback carry `confidence_source = `[`ConfidenceSource::Default`]
1039/// instead of falsely claiming `caller_provided`.
1040pub const DEFAULT_CONFIDENCE: f64 = 1.0;
1041
1042impl CreateMemory {
1043    /// #1591 — effective confidence for this request: the caller's
1044    /// explicit value, else the compiled [`DEFAULT_CONFIDENCE`].
1045    #[must_use]
1046    pub fn resolved_confidence(&self) -> f64 {
1047        self.confidence.unwrap_or(DEFAULT_CONFIDENCE)
1048    }
1049
1050    /// #1591 — truthful confidence provenance for this request:
1051    /// [`ConfidenceSource::CallerProvided`] only when the caller
1052    /// actually sent a `confidence` value;
1053    /// [`ConfidenceSource::Default`] when the compiled fallback was
1054    /// stamped.
1055    #[must_use]
1056    pub fn resolved_confidence_source(&self) -> ConfidenceSource {
1057        if self.confidence.is_some() {
1058            ConfidenceSource::CallerProvided
1059        } else {
1060            ConfidenceSource::Default
1061        }
1062    }
1063}
1064
1065fn default_tier() -> Tier {
1066    Tier::Mid
1067}
1068fn default_namespace() -> String {
1069    // #1590 — honour the operator-configured `[storage].default_namespace`
1070    // (seeded process-wide at boot from `AppConfig::resolve_storage`) on
1071    // the HTTP store surface; unconfigured deployments keep the
1072    // historical compiled default.
1073    crate::config::configured_default_namespace()
1074        .unwrap_or_else(|| crate::DEFAULT_NAMESPACE.to_string())
1075}
1076fn default_priority() -> i32 {
1077    5
1078}
1079fn default_source() -> String {
1080    "api".to_string()
1081}
1082
1083#[derive(Debug, Deserialize)]
1084pub struct UpdateMemory {
1085    pub title: Option<String>,
1086    pub content: Option<String>,
1087    pub tier: Option<Tier>,
1088    pub namespace: Option<String>,
1089    pub tags: Option<Vec<String>>,
1090    pub priority: Option<i32>,
1091    pub confidence: Option<f64>,
1092    pub expires_at: Option<String>,
1093    pub metadata: Option<Value>,
1094    /// v0.7.0 Provenance Gap 2 (#906) — opt-in `source_uri` patch.
1095    /// `None` leaves the stored value alone (COALESCE on the SQL
1096    /// layer); `Some("scheme:payload")` rewrites the row's source_uri
1097    /// (doc rename / URI scheme migration / bad-data correction).
1098    /// Validated by `validate::validate_source_uri` before reaching
1099    /// storage.
1100    pub source_uri: Option<String>,
1101    /// v0.7.0 #930 SECURITY-high (Track A P9, 2026-05-20) — optional
1102    /// caller-asserted `agent_id` for body/header parity. When set,
1103    /// MUST match the resolved `X-Agent-Id` header (Full-Measure-A
1104    /// posture). Mismatch → HTTP 403. Pre-fix the sqlite UPDATE path
1105    /// silently accepted ANY body.agent_id (or none) and never gated
1106    /// the writer against the row's recorded owner — enabling
1107    /// cross-tenant write hijack with forged provenance.
1108    #[serde(default)]
1109    pub agent_id: Option<String>,
1110}
1111
1112#[derive(Debug, Deserialize)]
1113pub struct SearchQuery {
1114    /// FTS query string. v0.7.0 Provenance Gap 6 (#889/#891): may be
1115    /// empty when `source_uri` is supplied (reciprocal source-only
1116    /// query). Handler rejects only when BOTH are empty.
1117    #[serde(default)]
1118    pub q: String,
1119    #[serde(default)]
1120    pub namespace: Option<String>,
1121    #[serde(default)]
1122    pub tier: Option<Tier>,
1123    #[serde(default = "default_limit")]
1124    pub limit: Option<usize>,
1125    #[serde(default)]
1126    pub min_priority: Option<i32>,
1127    #[serde(default)]
1128    pub since: Option<String>,
1129    #[serde(default)]
1130    pub until: Option<String>,
1131    #[serde(default)]
1132    pub tags: Option<String>, // comma-separated
1133    /// Filter by `metadata.agent_id` (exact match).
1134    #[serde(default)]
1135    pub agent_id: Option<String>,
1136    /// Task 1.5 visibility: the querying agent's namespace position.
1137    /// When set, results are filtered per `metadata.scope` rules.
1138    #[serde(default)]
1139    pub as_agent: Option<String>,
1140    /// v0.7.0 Provenance Gap 6 (#889) — reciprocal source filter.
1141    /// When `source_uri=X` is supplied, the result set is narrowed
1142    /// to memories whose `source_uri` column equals X verbatim. The
1143    /// partial `idx_memories_source_uri` index (v38) covers the
1144    /// lookup so the query is O(log N).
1145    #[serde(default)]
1146    pub source_uri: Option<String>,
1147    /// #1579 B4 — response format negotiation: `json` (default) |
1148    /// `toon` | `toon_compact`. Reuses the MCP TOON encoder
1149    /// (`crate::toon`); invalid values are rejected with `400`
1150    /// carrying the SSOT message from
1151    /// `crate::toon::invalid_format_msg`.
1152    #[serde(default)]
1153    pub format: Option<String>,
1154}
1155
1156#[allow(clippy::unnecessary_wraps)]
1157fn default_limit() -> Option<usize> {
1158    Some(20)
1159}
1160
1161#[derive(Debug, Deserialize)]
1162pub struct ListQuery {
1163    #[serde(default)]
1164    pub namespace: Option<String>,
1165    #[serde(default)]
1166    pub tier: Option<Tier>,
1167    #[serde(default = "default_limit")]
1168    pub limit: Option<usize>,
1169    #[serde(default)]
1170    pub offset: Option<usize>,
1171    #[serde(default)]
1172    pub min_priority: Option<i32>,
1173    #[serde(default)]
1174    pub since: Option<String>,
1175    #[serde(default)]
1176    pub until: Option<String>,
1177    #[serde(default)]
1178    pub tags: Option<String>,
1179    /// Filter by `metadata.agent_id` (exact match).
1180    #[serde(default)]
1181    pub agent_id: Option<String>,
1182}
1183
1184#[derive(Debug, Deserialize)]
1185pub struct RecallQuery {
1186    pub context: Option<String>,
1187    /// `query` alias for `context` — the cert harness (S79) uses
1188    /// `?query=…`. Both forms route to the same code path; `context`
1189    /// wins when both are supplied.
1190    #[serde(default)]
1191    pub query: Option<String>,
1192    /// `q` alias for `context`/`query` — matches the search-style API
1193    /// surface (`/api/v1/memories?q=…`) so callers can use the same
1194    /// query token field across both endpoints.
1195    #[serde(default)]
1196    pub q: Option<String>,
1197    #[serde(default)]
1198    pub namespace: Option<String>,
1199    #[serde(default = "default_recall_limit")]
1200    pub limit: Option<usize>,
1201    #[serde(default)]
1202    pub tags: Option<String>,
1203    #[serde(default)]
1204    pub since: Option<String>,
1205    #[serde(default)]
1206    pub until: Option<String>,
1207    /// Task 1.5 visibility filtering.
1208    #[serde(default)]
1209    pub as_agent: Option<String>,
1210    /// Task 1.11 — context-budget-aware recall. When set, return the
1211    /// top-scored memories whose cumulative estimated tokens fit within
1212    /// this budget.
1213    #[serde(default)]
1214    pub budget_tokens: Option<usize>,
1215    /// #1622 — salience tokens biasing the recall query embedding,
1216    /// comma-separated (`context_tokens=alpha,beta`), mirroring the
1217    /// `kinds` CSV convention for GET query params.
1218    #[serde(default)]
1219    pub context_tokens: Option<String>,
1220    /// v0.7.0 (issue #518) — when `true`, splice defaults from
1221    /// `[agents.defaults.recall_scope]` in `config.toml` for any
1222    /// filter field not explicitly set on this request. Resolution:
1223    /// explicit args > recall_scope defaults > compiled defaults.
1224    /// Default `false` preserves v0.6.x recall semantics exactly.
1225    #[serde(default)]
1226    pub session_default: Option<bool>,
1227    /// v0.7.0 Form 4 (issue #757) — restrict to memories whose
1228    /// `citations` array is non-empty. Composes with the other
1229    /// filters; default `None` preserves v0.7.0 recall semantics.
1230    #[serde(default)]
1231    pub has_citations: Option<bool>,
1232    /// v0.7.0 Form 4 (issue #757) — restrict to memories whose
1233    /// `source_uri` column begins with this exact prefix.
1234    #[serde(default)]
1235    pub source_uri_prefix: Option<String>,
1236    /// v0.7.x Form 6 (issue #759) — Batman-taxonomy memory-kind
1237    /// filter. Comma-separated string (`kinds=concept,claim`).
1238    /// OR-of-kinds within the param; AND with namespace / tags /
1239    /// time-window / visibility. `None` (default) preserves the
1240    /// pre-Form-6 "no kind filter" semantics. Unknown tokens are
1241    /// silently dropped (forward-compat with future variants).
1242    #[serde(default)]
1243    pub kinds: Option<String>,
1244    /// v0.7.0 (issue #518) — per-session "recently accessed" boost.
1245    /// When set and non-empty, the rerank post-step adds +0.05 to any
1246    /// recall candidate already in this session's ring buffer (cap
1247    /// 50 ids, FIFO eviction); the recall hit set is appended to the
1248    /// ring so subsequent recalls in the same session reuse the new
1249    /// context. `None`/empty preserves pre-#518 recall semantics
1250    /// exactly.
1251    #[serde(default)]
1252    pub session_id: Option<String>,
1253    /// v0.7.0 #1098 — WT-1-E include atomised sources alongside atoms.
1254    /// HTTP parity with the MCP `RecallRequest`. Pre-#1098 this field
1255    /// was hard-coded to `None` in `RecallRequest::from_http_query`.
1256    #[serde(default)]
1257    pub include_archived: Option<bool>,
1258    /// v0.7.0 #1098 — Gap 4 (#887) confidence-tier filter. HTTP
1259    /// parity with the MCP `RecallRequest`.
1260    #[serde(default)]
1261    pub confidence_tier: Option<String>,
1262    /// v0.7.0 #1098 — Gap 7 (#890) per-row provenance decoration.
1263    /// HTTP parity with the MCP `RecallRequest`.
1264    #[serde(default)]
1265    pub verbose_provenance: Option<bool>,
1266    /// v0.7.0 #1098 — response format selector (e.g. `toon_compact`).
1267    /// HTTP parity with the MCP `RecallRequest`.
1268    #[serde(default)]
1269    pub format: Option<String>,
1270}
1271
1272#[allow(clippy::unnecessary_wraps)]
1273fn default_recall_limit() -> Option<usize> {
1274    Some(10)
1275}
1276
1277#[derive(Debug, Deserialize)]
1278pub struct RecallBody {
1279    /// Recall context. Accepts either `context` (canonical), `query`
1280    /// (cert harness alias used by S79), or `q` (matches the
1281    /// search-style API surface). At least one must be present and
1282    /// non-empty.
1283    #[serde(default)]
1284    pub context: Option<String>,
1285    #[serde(default)]
1286    pub query: Option<String>,
1287    #[serde(default)]
1288    pub q: Option<String>,
1289    #[serde(default)]
1290    pub namespace: Option<String>,
1291    #[serde(default = "default_recall_limit")]
1292    pub limit: Option<usize>,
1293    #[serde(default)]
1294    pub tags: Option<String>,
1295    #[serde(default)]
1296    pub since: Option<String>,
1297    #[serde(default)]
1298    pub until: Option<String>,
1299    /// Task 1.5 visibility filtering.
1300    #[serde(default)]
1301    pub as_agent: Option<String>,
1302    /// Task 1.11 — context-budget-aware recall.
1303    #[serde(default)]
1304    pub budget_tokens: Option<usize>,
1305    /// #1622 — salience tokens biasing the recall query embedding
1306    /// (70/30 blend). Pre-#1622 this field was unreachable from HTTP
1307    /// (hard-coded `None` in `from_http_body`) while MCP + CLI honored
1308    /// it — the same class #1098 fixed for four other fields.
1309    #[serde(default)]
1310    pub context_tokens: Option<Vec<String>>,
1311    /// v0.7.0 (issue #518) — when `true`, splice defaults from
1312    /// `[agents.defaults.recall_scope]` in `config.toml` for any
1313    /// filter field not explicitly set on this request body.
1314    /// Resolution: explicit args > recall_scope defaults > compiled
1315    /// defaults. Default `false` preserves v0.6.x recall semantics.
1316    #[serde(default)]
1317    pub session_default: Option<bool>,
1318    /// v0.7.0 Form 4 (issue #757) — restrict to memories whose
1319    /// `citations` array is non-empty. Composes with the other
1320    /// filters.
1321    #[serde(default)]
1322    pub has_citations: Option<bool>,
1323    /// v0.7.0 Form 4 (issue #757) — restrict to memories whose
1324    /// `source_uri` column begins with this exact prefix.
1325    #[serde(default)]
1326    pub source_uri_prefix: Option<String>,
1327    /// v0.7.x Form 6 (issue #759) — Batman-taxonomy memory-kind
1328    /// filter. Accepts either a JSON array of strings
1329    /// (`{"kinds": ["concept", "claim"]}`) or a comma-separated
1330    /// string (`{"kinds": "concept,claim"}`). OR-of-kinds within
1331    /// the param; AND with the other filters.
1332    #[serde(default)]
1333    pub kinds: Option<serde_json::Value>,
1334    /// v0.7.0 (issue #518) — per-session recency boost. See the
1335    /// matching field on [`RecallQuery`].
1336    #[serde(default)]
1337    pub session_id: Option<String>,
1338    /// v0.7.0 #1098 — WT-1-E include atomised sources alongside
1339    /// atoms. HTTP parity with the MCP `RecallRequest`.
1340    #[serde(default)]
1341    pub include_archived: Option<bool>,
1342    /// v0.7.0 #1098 — Gap 4 (#887) confidence-tier filter. HTTP
1343    /// parity with the MCP `RecallRequest`.
1344    #[serde(default)]
1345    pub confidence_tier: Option<String>,
1346    /// v0.7.0 #1098 — Gap 7 (#890) per-row provenance decoration.
1347    /// HTTP parity with the MCP `RecallRequest`.
1348    #[serde(default)]
1349    pub verbose_provenance: Option<bool>,
1350    /// v0.7.0 #1098 — response format selector (e.g. `toon_compact`).
1351    /// HTTP parity with the MCP `RecallRequest`.
1352    #[serde(default)]
1353    pub format: Option<String>,
1354}
1355
1356impl RecallBody {
1357    /// Resolve the recall query string from `context`, `query`, or `q`.
1358    /// Returns the trimmed value, or an empty string when all three are
1359    /// absent — the caller is expected to reject empty.
1360    #[must_use]
1361    pub fn resolved_query(&self) -> String {
1362        self.context
1363            .as_deref()
1364            .or(self.query.as_deref())
1365            .or(self.q.as_deref())
1366            .unwrap_or("")
1367            .trim()
1368            .to_string()
1369    }
1370
1371    /// v0.7.x Form 6 — parse the optional `kinds` JSON field.
1372    /// Accepts a JSON array of strings or a single comma-separated
1373    /// string. Treats `"all"` as "no filter" (returns `None`).
1374    /// Drops unknown tokens silently.
1375    ///
1376    /// Cluster E audit COR-4 (issue #767): mirrors
1377    /// [`MemoryKind::parse_csv`] semantics — an explicit array of
1378    /// only-unknown tokens (e.g. `["reflektion"]`) returns
1379    /// `Some(vec![])` (intentional zero-match filter), distinct from
1380    /// the absent / empty / `"all"` cases which return `None`
1381    /// (no filter declared).
1382    #[must_use]
1383    pub fn resolved_kinds(&self) -> Option<Vec<MemoryKind>> {
1384        let raw = self.kinds.as_ref()?;
1385        if let Some(s) = raw.as_str() {
1386            if s.trim().eq_ignore_ascii_case("all") {
1387                return None;
1388            }
1389            return MemoryKind::parse_csv(s);
1390        }
1391        if let Some(arr) = raw.as_array() {
1392            // Empty JSON array → no filter declared (matches the
1393            // CSV "" case in parse_csv).
1394            if arr.is_empty() {
1395                return None;
1396            }
1397            let mut out: Vec<MemoryKind> = Vec::new();
1398            for v in arr {
1399                if let Some(name) = v.as_str()
1400                    && let Some(k) = MemoryKind::from_str(name.trim())
1401                    && !out.contains(&k)
1402                {
1403                    out.push(k);
1404                }
1405            }
1406            // Non-empty array (even if every entry was unknown)
1407            // returns Some(out); collapsing to None would silently
1408            // invert a typo'd filter into "match all" (COR-4 bug).
1409            Some(out)
1410        } else {
1411            None
1412        }
1413    }
1414}
1415
1416impl RecallQuery {
1417    /// v0.7.x Form 6 — parse the optional `kinds` query string.
1418    /// Comma-separated. `"all"` (case-insensitive) is treated as "no
1419    /// filter" (returns `None`). Drops unknown tokens silently.
1420    #[must_use]
1421    pub fn resolved_kinds(&self) -> Option<Vec<MemoryKind>> {
1422        let s = self.kinds.as_deref()?;
1423        if s.trim().eq_ignore_ascii_case("all") {
1424            return None;
1425        }
1426        MemoryKind::parse_csv(s)
1427    }
1428}
1429
1430#[derive(Debug, Deserialize)]
1431pub struct ForgetQuery {
1432    #[serde(default)]
1433    pub namespace: Option<String>,
1434    #[serde(default)]
1435    pub pattern: Option<String>, // FTS pattern
1436    #[serde(default)]
1437    pub tier: Option<Tier>,
1438}
1439
1440/// v0.6.3.1 (P3): per-request observability for the recall pipeline.
1441///
1442/// Surfaces *which* recall path actually ran, *which* reranker was active,
1443/// the candidate pool sizes coming out of FTS and HNSW (before fusion), and
1444/// the blend weight applied to the semantic component. Always present in
1445/// `memory_recall` responses; older clients ignore unknown fields per the
1446/// JSON-RPC convention.
1447///
1448/// Closes G2/G8/G11 from the v0.6.3 audit by making every silent-degrade
1449/// path observable at request time. The capabilities surface (P1) reports
1450/// the same state at startup; this struct is the per-call mirror.
1451#[derive(Debug, Clone, Serialize)]
1452pub struct RecallMeta {
1453    /// Which recall path executed.
1454    /// - `"hybrid"` — embedder + FTS, blended (G11 happy path).
1455    /// - `"keyword_only"` — embedder unavailable or query-embed failed,
1456    ///   keyword-only recall served (G11 silent-degrade now visible).
1457    pub recall_mode: String,
1458    /// Which reranker scored the final ordering.
1459    /// - `"neural"` — BERT cross-encoder (autonomous tier, model loaded).
1460    /// - `"lexical"` — operator opted for the lexical variant, or the
1461    ///   tier never asked for a neural cross-encoder.
1462    /// - `"degraded_lexical"` — v0.7.0 R3-S2 — a configured neural
1463    ///   cross-encoder failed to initialise or errored mid-flight and
1464    ///   the runtime fell back. Distinct from `"lexical"` so clients
1465    ///   can detect the silent downgrade *in band* (previously this
1466    ///   was only a `tracing::warn!` event, which the G8 closure
1467    ///   claim overstated as "fail loud").
1468    /// - `"none"` — reranking disabled at this tier.
1469    pub reranker_used: String,
1470    /// Candidate-pool sizes coming out of each retrieval stage *before*
1471    /// fusion. Useful for spotting empty-FTS or empty-HNSW degradations.
1472    pub candidate_counts: CandidateCounts,
1473    /// Semantic blend weight applied during fusion. `0.0` for
1474    /// `keyword_only` mode; otherwise the average semantic weight across
1475    /// the returned candidates (varies 0.50→0.15 with content length).
1476    pub blend_weight: f64,
1477}
1478
1479/// v0.6.3.1 (P3): retrieval-stage candidate counts feeding `RecallMeta`.
1480#[derive(Debug, Clone, Serialize)]
1481pub struct CandidateCounts {
1482    /// Number of candidates retrieved by FTS5 keyword scoring.
1483    pub fts: usize,
1484    /// Number of candidates retrieved by HNSW (or linear-scan fallback)
1485    /// semantic search. `0` in keyword-only mode.
1486    pub hnsw: usize,
1487}
1488
1489/// v0.6.3.1 (P3): internal telemetry returned alongside recall results.
1490///
1491/// Plumbed from `db::recall_hybrid_with_telemetry` /
1492/// `db::recall_with_telemetry` up to `mcp::handle_recall`, which uses it
1493/// to populate `RecallMeta`. Not serialized — `RecallMeta` is the public
1494/// shape.
1495#[derive(Debug, Clone, Default)]
1496pub struct RecallTelemetry {
1497    /// Candidates returned by the FTS5 stage before fusion.
1498    pub fts_candidates: usize,
1499    /// Candidates returned by the HNSW (or linear-scan fallback) stage
1500    /// before fusion. `0` for keyword-only recall.
1501    pub hnsw_candidates: usize,
1502    /// Average semantic blend weight applied across the returned set.
1503    /// `0.0` for keyword-only recall.
1504    pub blend_weight_avg: f64,
1505    /// v0.7.0 H7 — count of stored embeddings whose dimensionality
1506    /// disagreed with the active embedder model during this recall, so
1507    /// their semantic signal was forced to `0.0` and excluded from the
1508    /// ranking. `0` in steady state; non-zero means the embedder model
1509    /// changed and the affected rows need re-embedding. The recall path
1510    /// also emits one aggregated `warn!` per query when this is non-zero.
1511    pub embedding_dim_mismatch: usize,
1512}
1513
1514#[derive(Debug, Serialize)]
1515pub struct Stats {
1516    pub total: usize,
1517    pub by_tier: Vec<TierCount>,
1518    pub by_namespace: Vec<NamespaceCount>,
1519    pub expiring_soon: usize,
1520    pub links_count: usize,
1521    pub db_size_bytes: u64,
1522    /// v0.6.3.1 P2 (G4) — count of rows whose stored `embedding_dim`
1523    /// disagrees with the BLOB length (or whose column is missing while
1524    /// a BLOB exists). 0 on a fresh database; non-zero indicates legacy
1525    /// rows the operator should re-embed. Consumed by the P7 doctor.
1526    #[serde(default)]
1527    pub dim_violations: u64,
1528    /// v0.6.3.1 (P3, G2): cumulative HNSW oldest-eviction count since this
1529    /// process started. Non-zero indicates the in-memory vector index has
1530    /// hit its `MAX_ENTRIES` cap and silently dropped older embeddings —
1531    /// recall quality may have degraded for evicted ids. Process-local
1532    /// (not persisted) because the index itself is process-local.
1533    #[serde(default)]
1534    pub index_evictions_total: u64,
1535}
1536
1537#[derive(Debug, Serialize)]
1538pub struct TierCount {
1539    pub tier: String,
1540    pub count: usize,
1541}
1542
1543#[derive(Debug, Serialize)]
1544pub struct NamespaceCount {
1545    pub namespace: String,
1546    pub count: usize,
1547}
1548
1549// -----------------------------------------------------------------
1550// L0.7-2 Tier A — memory.rs unit coverage
1551// Covers serde defaults (default_tier/default_namespace/etc.), Tier
1552// ↔ string round-trips, Memory::default, Tier::default_ttl_secs,
1553// RecallBody::resolved_query precedence.
1554// -----------------------------------------------------------------
1555#[cfg(test)]
1556mod tests {
1557    use super::*;
1558
1559    #[test]
1560    fn tier_round_trips_strings() {
1561        for (s, v) in [
1562            ("short", Tier::Short),
1563            ("mid", Tier::Mid),
1564            ("long", Tier::Long),
1565        ] {
1566            assert_eq!(Tier::from_str(s), Some(v.clone()));
1567            assert_eq!(v.as_str(), s);
1568            assert_eq!(format!("{v}"), s);
1569        }
1570    }
1571
1572    #[test]
1573    fn tier_from_str_returns_none_for_unknown() {
1574        assert_eq!(Tier::from_str("unknown"), None);
1575        assert_eq!(Tier::from_str(""), None);
1576        assert_eq!(Tier::from_str("SHORT"), None); // case-sensitive
1577    }
1578
1579    #[test]
1580    fn tier_default_ttl_secs_short_is_six_hours() {
1581        assert_eq!(
1582            Tier::Short.default_ttl_secs(),
1583            Some(6 * crate::SECS_PER_HOUR)
1584        );
1585    }
1586
1587    #[test]
1588    fn tier_default_ttl_secs_mid_is_seven_days() {
1589        assert_eq!(Tier::Mid.default_ttl_secs(), Some(crate::SECS_PER_WEEK));
1590    }
1591
1592    #[test]
1593    fn tier_default_ttl_secs_long_is_none() {
1594        assert_eq!(Tier::Long.default_ttl_secs(), None);
1595    }
1596
1597    #[test]
1598    fn tier_rank_orders_short_mid_long() {
1599        assert!(Tier::Short.rank() < Tier::Mid.rank());
1600        assert!(Tier::Mid.rank() < Tier::Long.rank());
1601    }
1602
1603    // #1466 — `effective_expires_at` is the single SSOT backfill used by
1604    // every store path. These pin the immortal-row regression: a non-Long
1605    // memory with `expires_at: None` must come back stamped at
1606    // `created_at + Tier::default_ttl_secs()`, Long stays None, and an
1607    // explicit value is preserved verbatim.
1608
1609    #[test]
1610    fn effective_expires_at_backfills_mid_at_created_plus_one_week() {
1611        let mut m = Memory::default();
1612        m.tier = Tier::Mid;
1613        m.created_at = "2026-01-01T00:00:00+00:00".to_string();
1614        m.expires_at = None;
1615        let got = m.effective_expires_at().expect("mid must backfill");
1616        let parsed = chrono::DateTime::parse_from_rfc3339(&got).unwrap();
1617        let base = chrono::DateTime::parse_from_rfc3339(&m.created_at).unwrap();
1618        assert_eq!(
1619            (parsed - base).num_seconds(),
1620            crate::SECS_PER_WEEK,
1621            "mid backfill must equal created_at + SECS_PER_WEEK"
1622        );
1623    }
1624
1625    #[test]
1626    fn effective_expires_at_backfills_short_at_created_plus_six_hours() {
1627        let mut m = Memory::default();
1628        m.tier = Tier::Short;
1629        m.created_at = "2026-01-01T00:00:00+00:00".to_string();
1630        m.expires_at = None;
1631        let got = m.effective_expires_at().expect("short must backfill");
1632        let parsed = chrono::DateTime::parse_from_rfc3339(&got).unwrap();
1633        let base = chrono::DateTime::parse_from_rfc3339(&m.created_at).unwrap();
1634        assert_eq!(
1635            (parsed - base).num_seconds(),
1636            6 * crate::SECS_PER_HOUR,
1637            "short backfill must equal created_at + 6h"
1638        );
1639    }
1640
1641    #[test]
1642    fn effective_expires_at_long_stays_none() {
1643        let mut m = Memory::default();
1644        m.tier = Tier::Long;
1645        m.created_at = "2026-01-01T00:00:00+00:00".to_string();
1646        m.expires_at = None;
1647        assert_eq!(
1648            m.effective_expires_at(),
1649            None,
1650            "long has no TTL — must stay immortal"
1651        );
1652    }
1653
1654    #[test]
1655    fn effective_expires_at_preserves_explicit_value() {
1656        let explicit = "2027-06-15T12:00:00+00:00".to_string();
1657        for tier in [Tier::Short, Tier::Mid, Tier::Long] {
1658            let mut m = Memory::default();
1659            m.tier = tier;
1660            m.created_at = "2026-01-01T00:00:00+00:00".to_string();
1661            m.expires_at = Some(explicit.clone());
1662            assert_eq!(
1663                m.effective_expires_at(),
1664                Some(explicit.clone()),
1665                "an explicit expiry must win over the tier default"
1666            );
1667        }
1668    }
1669
1670    #[test]
1671    fn effective_expires_at_output_is_rfc3339_for_lexical_gc_compare() {
1672        // gc() compares `expires_at < now` as rfc3339 STRINGS, so the
1673        // backfill must emit the same `...THH:MM:SS+00:00` shape
1674        // `Utc::now().to_rfc3339()` produces — never a space-separated
1675        // SQLite datetime() form (which would sort wrong).
1676        let mut m = Memory::default();
1677        m.tier = Tier::Mid;
1678        m.created_at = "2026-01-01T00:00:00+00:00".to_string();
1679        m.expires_at = None;
1680        let got = m.effective_expires_at().unwrap();
1681        assert!(got.contains('T'), "must be ISO 'T'-separated: {got}");
1682        assert!(!got.contains(' '), "must not contain a space: {got}");
1683        assert!(
1684            chrono::DateTime::parse_from_rfc3339(&got).is_ok(),
1685            "must round-trip through rfc3339 parse: {got}"
1686        );
1687    }
1688
1689    #[test]
1690    fn tier_serializes_to_snake_case() {
1691        let v = serde_json::to_value(Tier::Short).unwrap();
1692        assert_eq!(v, serde_json::Value::String("short".to_string()));
1693        let v = serde_json::to_value(Tier::Mid).unwrap();
1694        assert_eq!(v, serde_json::Value::String("mid".to_string()));
1695        let v = serde_json::to_value(Tier::Long).unwrap();
1696        assert_eq!(v, serde_json::Value::String("long".to_string()));
1697    }
1698
1699    #[test]
1700    fn memory_default_uses_mid_tier_and_global_namespace() {
1701        let m = Memory::default();
1702        assert_eq!(m.tier, Tier::Mid);
1703        assert_eq!(m.namespace, "global");
1704        assert_eq!(m.priority, 5);
1705        assert!((m.confidence - 1.0).abs() < f64::EPSILON);
1706        assert_eq!(m.source, "api");
1707        assert_eq!(m.access_count, 0);
1708        assert_eq!(m.reflection_depth, 0);
1709        assert!(m.last_accessed_at.is_none());
1710        assert!(m.expires_at.is_none());
1711    }
1712
1713    #[test]
1714    fn memory_round_trips_through_serde_with_reflection_depth() {
1715        let mut m = Memory::default();
1716        m.id = "mem-1".to_string();
1717        m.title = "test".to_string();
1718        m.content = "body".to_string();
1719        m.created_at = "2026-01-01T00:00:00Z".to_string();
1720        m.updated_at = "2026-01-01T00:00:00Z".to_string();
1721        m.reflection_depth = 3;
1722        let s = serde_json::to_string(&m).unwrap();
1723        let back: Memory = serde_json::from_str(&s).unwrap();
1724        assert_eq!(back.id, "mem-1");
1725        assert_eq!(back.reflection_depth, 3);
1726    }
1727
1728    #[test]
1729    fn memory_deserialises_pre_v070_payload_without_reflection_depth() {
1730        // Pre-v0.7.0 payloads have no reflection_depth field. serde
1731        // default must populate it as 0.
1732        let json = serde_json::json!({
1733            "id": "old-mem",
1734            "tier": Tier::Mid.as_str(),
1735            "namespace": "ns",
1736            "title": "t",
1737            "content": "c",
1738            "tags": [],
1739            "priority": 5,
1740            "confidence": 1.0,
1741            "source": "api",
1742            "access_count": 0,
1743            "created_at": "2024-01-01T00:00:00Z",
1744            "updated_at": "2024-01-01T00:00:00Z",
1745            "metadata": {},
1746        });
1747        let m: Memory = serde_json::from_value(json).unwrap();
1748        assert_eq!(m.reflection_depth, 0);
1749    }
1750
1751    fn cm_minimal() -> serde_json::Value {
1752        serde_json::json!({
1753            "title": "t",
1754            "content": "c",
1755        })
1756    }
1757
1758    #[test]
1759    fn create_memory_defaults_tier_to_mid() {
1760        // Lines 175-177: default_tier returns Tier::Mid via #[serde(default)].
1761        let cm: CreateMemory = serde_json::from_value(cm_minimal()).unwrap();
1762        assert_eq!(cm.tier, Tier::Mid);
1763    }
1764
1765    #[test]
1766    fn create_memory_defaults_namespace_to_global() {
1767        // #1590 — the serde default now consults the process-wide
1768        // operator-configured default namespace; hold the test gate so
1769        // a concurrently-running #1590 seeding test can't bleed into
1770        // this unconfigured-deployment assertion.
1771        let _gate = crate::config::lock_configured_default_namespace_for_test();
1772        crate::config::set_configured_default_namespace(None);
1773        let cm: CreateMemory = serde_json::from_value(cm_minimal()).unwrap();
1774        assert_eq!(cm.namespace, "global");
1775    }
1776
1777    /// #1590 regression — with an operator-configured
1778    /// `[storage].default_namespace` seeded at boot, an HTTP
1779    /// `CreateMemory` body that omits `namespace` lands in the
1780    /// configured namespace instead of the compiled `"global"`.
1781    /// An explicit body `namespace` still wins.
1782    #[test]
1783    fn create_memory_namespace_default_honours_configured_1590() {
1784        let _gate = crate::config::lock_configured_default_namespace_for_test();
1785        crate::config::set_configured_default_namespace(Some("alphaone".to_string()));
1786        let cm: CreateMemory = serde_json::from_value(cm_minimal()).unwrap();
1787        assert_eq!(cm.namespace, "alphaone", "#1590: configured default wins");
1788        let mut v = cm_minimal();
1789        v["namespace"] = serde_json::json!("explicit-ns");
1790        let cm: CreateMemory = serde_json::from_value(v).unwrap();
1791        assert_eq!(cm.namespace, "explicit-ns", "explicit body value wins");
1792        crate::config::set_configured_default_namespace(None);
1793    }
1794
1795    #[test]
1796    fn create_memory_defaults_priority_to_5() {
1797        // Lines 181-183.
1798        let cm: CreateMemory = serde_json::from_value(cm_minimal()).unwrap();
1799        assert_eq!(cm.priority, 5);
1800    }
1801
1802    #[test]
1803    fn create_memory_defaults_confidence_to_one() {
1804        // #1591 — the field is now `Option<f64>` so omission is
1805        // observable; the RESOLVED value still defaults to the
1806        // compiled DEFAULT_CONFIDENCE (1.0) with truthful
1807        // `confidence_source = "default"` provenance.
1808        let cm: CreateMemory = serde_json::from_value(cm_minimal()).unwrap();
1809        assert_eq!(cm.confidence, None, "omitted confidence must be None");
1810        assert!((cm.resolved_confidence() - DEFAULT_CONFIDENCE).abs() < f64::EPSILON);
1811        assert_eq!(
1812            cm.resolved_confidence_source(),
1813            ConfidenceSource::Default,
1814            "#1591: omitted confidence must stamp source=default"
1815        );
1816    }
1817
1818    /// #1591 regression — an EXPLICIT caller `confidence` keeps the
1819    /// historical `caller_provided` provenance.
1820    #[test]
1821    fn create_memory_explicit_confidence_is_caller_provided_1591() {
1822        let mut v = cm_minimal();
1823        v["confidence"] = serde_json::json!(0.8);
1824        let cm: CreateMemory = serde_json::from_value(v).unwrap();
1825        assert_eq!(cm.confidence, Some(0.8));
1826        assert!((cm.resolved_confidence() - 0.8).abs() < f64::EPSILON);
1827        assert_eq!(
1828            cm.resolved_confidence_source(),
1829            ConfidenceSource::CallerProvided
1830        );
1831    }
1832
1833    #[test]
1834    fn create_memory_defaults_source_to_api() {
1835        // Lines 187-189.
1836        let cm: CreateMemory = serde_json::from_value(cm_minimal()).unwrap();
1837        assert_eq!(cm.source, "api");
1838    }
1839
1840    #[test]
1841    fn create_memory_defaults_metadata_to_empty_object() {
1842        let cm: CreateMemory = serde_json::from_value(cm_minimal()).unwrap();
1843        assert_eq!(cm.metadata, serde_json::json!({}));
1844    }
1845
1846    #[test]
1847    fn recall_body_resolved_query_prefers_context() {
1848        let body: RecallBody = serde_json::from_value(serde_json::json!({
1849            "context": "c-value",
1850            "query": "q-value",
1851            "q": "qq-value",
1852        }))
1853        .unwrap();
1854        assert_eq!(body.resolved_query(), "c-value");
1855    }
1856
1857    #[test]
1858    fn recall_body_resolved_query_falls_back_to_query_then_q() {
1859        let body: RecallBody =
1860            serde_json::from_value(serde_json::json!({"query": "q-value", "q": "qq"})).unwrap();
1861        assert_eq!(body.resolved_query(), "q-value");
1862        let body: RecallBody = serde_json::from_value(serde_json::json!({"q": "qq"})).unwrap();
1863        assert_eq!(body.resolved_query(), "qq");
1864    }
1865
1866    #[test]
1867    fn recall_body_resolved_query_empty_when_all_absent() {
1868        let body: RecallBody = serde_json::from_value(serde_json::json!({})).unwrap();
1869        assert_eq!(body.resolved_query(), "");
1870    }
1871
1872    #[test]
1873    fn recall_body_resolved_query_trims_whitespace() {
1874        let body: RecallBody =
1875            serde_json::from_value(serde_json::json!({"context": "  spaced  "})).unwrap();
1876        assert_eq!(body.resolved_query(), "spaced");
1877    }
1878
1879    #[test]
1880    fn search_query_defaults_limit_to_20() {
1881        // default_limit() returns Some(20)
1882        let q: SearchQuery = serde_json::from_value(serde_json::json!({"q": "x"})).unwrap();
1883        assert_eq!(q.limit, Some(20));
1884    }
1885
1886    #[test]
1887    fn recall_query_defaults_limit_to_10() {
1888        // default_recall_limit() returns Some(10)
1889        let q: RecallQuery = serde_json::from_value(serde_json::json!({})).unwrap();
1890        assert_eq!(q.limit, Some(10));
1891    }
1892
1893    #[test]
1894    fn list_query_defaults_limit_to_20() {
1895        let q: ListQuery = serde_json::from_value(serde_json::json!({})).unwrap();
1896        assert_eq!(q.limit, Some(20));
1897    }
1898
1899    // -----------------------------------------------------------------
1900    // v0.7-polish coverage recovery (issue #767) — Forms 4/5/6 surface.
1901    // Covers the new MemoryKind variants, ConfidenceSource enum, the
1902    // Form 4 Citation / SourceSpan structs, and the v0.7.0 Memory
1903    // serde round-trip with every new field populated.
1904    // -----------------------------------------------------------------
1905
1906    #[test]
1907    fn memory_kind_round_trips_every_variant_string() {
1908        for (s, v) in [
1909            ("observation", MemoryKind::Observation),
1910            ("reflection", MemoryKind::Reflection),
1911            ("persona", MemoryKind::Persona),
1912            ("concept", MemoryKind::Concept),
1913            ("entity", MemoryKind::Entity),
1914            ("claim", MemoryKind::Claim),
1915            ("relation", MemoryKind::Relation),
1916            ("event", MemoryKind::Event),
1917            ("conversation", MemoryKind::Conversation),
1918            ("decision", MemoryKind::Decision),
1919        ] {
1920            assert_eq!(MemoryKind::from_str(s), Some(v));
1921            assert_eq!(v.as_str(), s);
1922            assert_eq!(format!("{v}"), s);
1923        }
1924    }
1925
1926    #[test]
1927    fn memory_kind_from_str_returns_none_for_unknown() {
1928        assert_eq!(MemoryKind::from_str("unknown"), None);
1929        assert_eq!(MemoryKind::from_str(""), None);
1930        assert_eq!(MemoryKind::from_str("OBSERVATION"), None); // case-sensitive
1931    }
1932
1933    #[test]
1934    fn memory_kind_all_enumerates_in_declaration_order() {
1935        let all = MemoryKind::all();
1936        assert_eq!(all.len(), 10);
1937        assert_eq!(all[0], MemoryKind::Observation);
1938        assert_eq!(all[1], MemoryKind::Reflection);
1939        assert_eq!(all[2], MemoryKind::Persona);
1940        assert_eq!(all[9], MemoryKind::Decision);
1941    }
1942
1943    #[test]
1944    fn memory_kind_default_is_observation() {
1945        let k: MemoryKind = MemoryKind::default();
1946        assert_eq!(k, MemoryKind::Observation);
1947    }
1948
1949    #[test]
1950    fn memory_kind_parse_csv_empty_string_returns_none() {
1951        // Whitespace-only / empty → "no filter declared" → None.
1952        assert_eq!(MemoryKind::parse_csv(""), None);
1953        assert_eq!(MemoryKind::parse_csv("   "), None);
1954        assert_eq!(MemoryKind::parse_csv(",,, "), None);
1955    }
1956
1957    #[test]
1958    fn memory_kind_parse_csv_all_unknown_returns_empty_vec() {
1959        // Non-empty input with only-unknown tokens → "intentional zero
1960        // filter" → Some(vec![]). Distinct from None per COR-4.
1961        let parsed = MemoryKind::parse_csv("reflektion,observetion");
1962        assert_eq!(parsed, Some(Vec::new()));
1963    }
1964
1965    #[test]
1966    fn memory_kind_parse_csv_mixed_known_and_unknown_drops_unknown() {
1967        let parsed = MemoryKind::parse_csv("reflection,bogus,concept");
1968        assert_eq!(
1969            parsed,
1970            Some(vec![MemoryKind::Reflection, MemoryKind::Concept])
1971        );
1972    }
1973
1974    #[test]
1975    fn memory_kind_parse_csv_dedups_repeated_tokens() {
1976        let parsed = MemoryKind::parse_csv("claim,claim,event,claim");
1977        assert_eq!(parsed, Some(vec![MemoryKind::Claim, MemoryKind::Event]));
1978    }
1979
1980    #[test]
1981    fn memory_kind_parse_csv_trims_whitespace() {
1982        let parsed = MemoryKind::parse_csv("  concept ,  entity ");
1983        assert_eq!(parsed, Some(vec![MemoryKind::Concept, MemoryKind::Entity]));
1984    }
1985
1986    #[test]
1987    fn memory_kind_serialises_to_snake_case() {
1988        let v = serde_json::to_value(MemoryKind::Conversation).unwrap();
1989        assert_eq!(v, serde_json::Value::String("conversation".to_string()));
1990    }
1991
1992    #[test]
1993    fn confidence_source_round_trips_every_variant_string() {
1994        for (s, v) in [
1995            ("caller_provided", ConfidenceSource::CallerProvided),
1996            ("auto_derived", ConfidenceSource::AutoDerived),
1997            ("calibrated", ConfidenceSource::Calibrated),
1998            ("decayed", ConfidenceSource::Decayed),
1999            // v0.7.0 issue #1242 — curator-engine output bucket
2000            // (atom rows + persona rows). Distinct from
2001            // `auto_derived` (which is the Form 5 engine's
2002            // signal-based derivation).
2003            ("curator_derived", ConfidenceSource::CuratorDerived),
2004            // v0.7.x issue #1591 — caller omitted `confidence`; the
2005            // compiled DEFAULT_CONFIDENCE fallback was stamped.
2006            ("default", ConfidenceSource::Default),
2007        ] {
2008            assert_eq!(ConfidenceSource::from_str(s), Some(v));
2009            assert_eq!(v.as_str(), s);
2010            assert_eq!(format!("{v}"), s);
2011        }
2012    }
2013
2014    /// #1600 regression — `EditSource` wire vocabulary round-trips
2015    /// every variant (incl. the new `agent`), `ALL` covers the closed
2016    /// set, and `agent` keeps Human's in-place mutation semantics
2017    /// (does NOT route append-and-archive).
2018    #[test]
2019    fn edit_source_agent_variant_wire_and_semantics_1600() {
2020        for v in EditSource::ALL {
2021            assert_eq!(
2022                EditSource::from_str(v.as_str()),
2023                Some(v),
2024                "EditSource wire string must round-trip"
2025            );
2026        }
2027        assert_eq!(EditSource::from_str("agent"), Some(EditSource::Agent));
2028        assert_eq!(EditSource::Agent.as_str(), "agent");
2029        assert!(
2030            !EditSource::Agent.appends_and_archives(),
2031            "#1600: Agent mutates in place exactly like Human"
2032        );
2033        assert!(EditSource::Llm.appends_and_archives());
2034        assert!(EditSource::Hook.appends_and_archives());
2035        // serde wire compat: snake_case rename matches as_str.
2036        assert_eq!(
2037            serde_json::to_value(EditSource::Agent).unwrap(),
2038            serde_json::Value::String("agent".to_string())
2039        );
2040        assert_eq!(EditSource::from_str("robot"), None, "unknown stays None");
2041    }
2042
2043    /// #1600 regression — omitted `edit_source` derives from the
2044    /// resolved caller id: `ai:`-prefixed NHI ids default to `Agent`,
2045    /// every other shape keeps the historical `Human` default.
2046    #[test]
2047    fn edit_source_default_for_agent_id_matrix_1600() {
2048        assert_eq!(
2049            EditSource::default_for_agent_id("ai:claude-code@host:pid-1"),
2050            EditSource::Agent
2051        );
2052        assert_eq!(
2053            EditSource::default_for_agent_id("host:box:pid-2-abcd1234"),
2054            EditSource::Human
2055        );
2056        assert_eq!(
2057            EditSource::default_for_agent_id("anonymous:pid-3-ffff0000"),
2058            EditSource::Human
2059        );
2060        assert_eq!(EditSource::default_for_agent_id("alice"), EditSource::Human);
2061    }
2062
2063    #[test]
2064    fn confidence_source_from_str_returns_none_for_unknown() {
2065        assert_eq!(ConfidenceSource::from_str("unknown"), None);
2066        assert_eq!(ConfidenceSource::from_str(""), None);
2067    }
2068
2069    #[test]
2070    fn confidence_source_default_is_caller_provided() {
2071        let v: ConfidenceSource = ConfidenceSource::default();
2072        assert_eq!(v, ConfidenceSource::CallerProvided);
2073    }
2074
2075    #[test]
2076    fn confidence_source_serialises_to_snake_case() {
2077        let v = serde_json::to_value(ConfidenceSource::AutoDerived).unwrap();
2078        assert_eq!(v, serde_json::Value::String("auto_derived".to_string()));
2079    }
2080
2081    #[test]
2082    fn confidence_signals_default_has_expected_values() {
2083        let s = ConfidenceSignals::default();
2084        assert!((s.source_age_days - 0.0).abs() < f64::EPSILON);
2085        assert!(!s.atom_derivation);
2086        assert_eq!(s.prior_corroboration_count, 0);
2087        assert!((s.freshness_factor - 1.0).abs() < f64::EPSILON);
2088        assert!((s.baseline_per_source - 0.5).abs() < f64::EPSILON);
2089    }
2090
2091    #[test]
2092    fn confidence_signals_round_trips_through_serde() {
2093        let s = ConfidenceSignals {
2094            source_age_days: 12.5,
2095            atom_derivation: true,
2096            prior_corroboration_count: 3,
2097            freshness_factor: 0.75,
2098            baseline_per_source: 0.62,
2099        };
2100        let v = serde_json::to_value(&s).unwrap();
2101        let back: ConfidenceSignals = serde_json::from_value(v).unwrap();
2102        assert_eq!(back, s);
2103    }
2104
2105    #[test]
2106    fn source_span_round_trips_through_serde() {
2107        let span = SourceSpan { start: 12, end: 34 };
2108        let v = serde_json::to_value(span).unwrap();
2109        let back: SourceSpan = serde_json::from_value(v.clone()).unwrap();
2110        assert_eq!(back, span);
2111        // JSON shape: {"start": 12, "end": 34}.
2112        assert_eq!(v["start"], 12);
2113        assert_eq!(v["end"], 34);
2114    }
2115
2116    #[test]
2117    fn citation_round_trips_through_serde_with_optional_fields_unset() {
2118        let c = Citation {
2119            uri: "doc:abc123".to_string(),
2120            accessed_at: "2026-01-01T00:00:00Z".to_string(),
2121            hash: None,
2122            span: None,
2123        };
2124        let s = serde_json::to_string(&c).unwrap();
2125        // skip_serializing_if drops the None fields entirely.
2126        assert!(!s.contains("hash"));
2127        assert!(!s.contains("span"));
2128        let back: Citation = serde_json::from_str(&s).unwrap();
2129        assert_eq!(back, c);
2130    }
2131
2132    #[test]
2133    fn citation_round_trips_with_hash_and_span_set() {
2134        let c = Citation {
2135            uri: "uri:https://example.com/paper".to_string(),
2136            accessed_at: "2026-02-03T04:05:06Z".to_string(),
2137            hash: Some("a".repeat(64)),
2138            span: Some(SourceSpan { start: 0, end: 100 }),
2139        };
2140        let v = serde_json::to_value(&c).unwrap();
2141        let back: Citation = serde_json::from_value(v).unwrap();
2142        assert_eq!(back, c);
2143    }
2144
2145    #[test]
2146    fn memory_default_populates_form4_and_form5_defaults() {
2147        let m = Memory::default();
2148        assert!(m.citations.is_empty());
2149        assert!(m.source_uri.is_none());
2150        assert!(m.source_span.is_none());
2151        assert_eq!(m.confidence_source, ConfidenceSource::CallerProvided);
2152        assert!(m.confidence_signals.is_none());
2153        assert!(m.confidence_decayed_at.is_none());
2154        assert_eq!(m.memory_kind, MemoryKind::Observation);
2155        assert!(m.entity_id.is_none());
2156        assert!(m.persona_version.is_none());
2157    }
2158
2159    #[test]
2160    fn memory_round_trips_with_all_v070_form_fields_populated() {
2161        let mut m = Memory::default();
2162        m.id = "mem-form".to_string();
2163        m.title = "fact-bearer".to_string();
2164        m.content = "the build broke at 14:32".to_string();
2165        m.created_at = "2026-05-01T00:00:00Z".to_string();
2166        m.updated_at = "2026-05-01T00:00:00Z".to_string();
2167        m.memory_kind = MemoryKind::Claim;
2168        m.entity_id = Some("entity-xyz".to_string());
2169        m.persona_version = Some(7);
2170        m.citations = vec![Citation {
2171            uri: "doc:src-1".to_string(),
2172            accessed_at: "2026-05-01T00:00:00Z".to_string(),
2173            hash: None,
2174            span: None,
2175        }];
2176        m.source_uri = Some("uri:https://example.com".to_string());
2177        m.source_span = Some(SourceSpan { start: 5, end: 10 });
2178        m.confidence_source = ConfidenceSource::Calibrated;
2179        m.confidence_signals = Some(ConfidenceSignals::default());
2180        m.confidence_decayed_at = Some("2026-04-01T00:00:00Z".to_string());
2181
2182        let s = serde_json::to_string(&m).unwrap();
2183        let back: Memory = serde_json::from_str(&s).unwrap();
2184        assert_eq!(back.id, m.id);
2185        assert_eq!(back.memory_kind, MemoryKind::Claim);
2186        assert_eq!(back.entity_id.as_deref(), Some("entity-xyz"));
2187        assert_eq!(back.persona_version, Some(7));
2188        assert_eq!(back.citations.len(), 1);
2189        assert_eq!(back.citations[0].uri, "doc:src-1");
2190        assert_eq!(back.source_uri.as_deref(), Some("uri:https://example.com"));
2191        assert_eq!(back.source_span, Some(SourceSpan { start: 5, end: 10 }));
2192        assert_eq!(back.confidence_source, ConfidenceSource::Calibrated);
2193        assert!(back.confidence_signals.is_some());
2194        assert_eq!(
2195            back.confidence_decayed_at.as_deref(),
2196            Some("2026-04-01T00:00:00Z")
2197        );
2198    }
2199
2200    #[test]
2201    fn memory_deserialises_pre_form4_payload_without_form4_fields() {
2202        // A pre-Form-4 payload omits citations / source_uri / source_span /
2203        // confidence_source / confidence_signals / confidence_decayed_at.
2204        // serde defaults must populate them.
2205        let json = serde_json::json!({
2206            "id": "old-mem",
2207            "tier": Tier::Long.as_str(),
2208            "namespace": "ns",
2209            "title": "t",
2210            "content": "c",
2211            "tags": [],
2212            "priority": 5,
2213            "confidence": 1.0,
2214            "source": "api",
2215            "access_count": 0,
2216            "created_at": "2024-01-01T00:00:00Z",
2217            "updated_at": "2024-01-01T00:00:00Z",
2218            "metadata": {},
2219        });
2220        let m: Memory = serde_json::from_value(json).unwrap();
2221        assert!(m.citations.is_empty());
2222        assert!(m.source_uri.is_none());
2223        assert!(m.source_span.is_none());
2224        assert_eq!(m.confidence_source, ConfidenceSource::CallerProvided);
2225        assert!(m.confidence_signals.is_none());
2226        assert!(m.confidence_decayed_at.is_none());
2227        assert!(m.entity_id.is_none());
2228        assert!(m.persona_version.is_none());
2229        assert_eq!(m.memory_kind, MemoryKind::Observation);
2230    }
2231
2232    #[test]
2233    fn recall_body_resolved_kinds_handles_all_keyword() {
2234        let body: RecallBody = serde_json::from_value(serde_json::json!({
2235            "kinds": "ALL",
2236        }))
2237        .unwrap();
2238        assert_eq!(body.resolved_kinds(), None);
2239    }
2240
2241    #[test]
2242    fn recall_body_resolved_kinds_csv_parses_known_tokens() {
2243        let body: RecallBody = serde_json::from_value(serde_json::json!({
2244            "kinds": "concept,claim",
2245        }))
2246        .unwrap();
2247        let kinds = body.resolved_kinds().unwrap();
2248        assert!(kinds.contains(&MemoryKind::Concept));
2249        assert!(kinds.contains(&MemoryKind::Claim));
2250    }
2251
2252    #[test]
2253    fn recall_body_resolved_kinds_array_parses_known_tokens() {
2254        let body: RecallBody = serde_json::from_value(serde_json::json!({
2255            "kinds": ["event", "entity", "bogus", "entity"],
2256        }))
2257        .unwrap();
2258        let kinds = body.resolved_kinds().unwrap();
2259        // Deduped + unknown dropped.
2260        assert_eq!(kinds, vec![MemoryKind::Event, MemoryKind::Entity]);
2261    }
2262
2263    #[test]
2264    fn recall_body_resolved_kinds_empty_array_returns_none() {
2265        let body: RecallBody = serde_json::from_value(serde_json::json!({
2266            "kinds": [],
2267        }))
2268        .unwrap();
2269        assert_eq!(body.resolved_kinds(), None);
2270    }
2271
2272    #[test]
2273    fn recall_body_resolved_kinds_only_unknown_array_returns_empty_vec() {
2274        // COR-4 distinction: explicit array with only unknowns returns
2275        // Some(vec![]) (intentional zero-match) — not None.
2276        let body: RecallBody = serde_json::from_value(serde_json::json!({
2277            "kinds": ["reflektion"],
2278        }))
2279        .unwrap();
2280        assert_eq!(body.resolved_kinds(), Some(Vec::new()));
2281    }
2282
2283    #[test]
2284    fn recall_body_resolved_kinds_absent_returns_none() {
2285        let body: RecallBody = serde_json::from_value(serde_json::json!({})).unwrap();
2286        assert_eq!(body.resolved_kinds(), None);
2287    }
2288
2289    #[test]
2290    fn recall_body_resolved_kinds_non_string_non_array_returns_none() {
2291        // A number, object, bool etc. is neither string nor array → None.
2292        let body: RecallBody = serde_json::from_value(serde_json::json!({
2293            "kinds": 42,
2294        }))
2295        .unwrap();
2296        assert_eq!(body.resolved_kinds(), None);
2297    }
2298
2299    #[test]
2300    fn recall_query_resolved_kinds_handles_all_keyword() {
2301        let q: RecallQuery = serde_json::from_value(serde_json::json!({
2302            "kinds": "all",
2303        }))
2304        .unwrap();
2305        assert_eq!(q.resolved_kinds(), None);
2306    }
2307
2308    #[test]
2309    fn recall_query_resolved_kinds_parses_csv() {
2310        let q: RecallQuery = serde_json::from_value(serde_json::json!({
2311            "kinds": "decision,relation",
2312        }))
2313        .unwrap();
2314        let kinds = q.resolved_kinds().unwrap();
2315        assert!(kinds.contains(&MemoryKind::Decision));
2316        assert!(kinds.contains(&MemoryKind::Relation));
2317    }
2318
2319    #[test]
2320    fn recall_query_resolved_kinds_absent_returns_none() {
2321        let q: RecallQuery = serde_json::from_value(serde_json::json!({})).unwrap();
2322        assert_eq!(q.resolved_kinds(), None);
2323    }
2324
2325    #[test]
2326    fn create_memory_accepts_form4_fields_when_present() {
2327        let cm: CreateMemory = serde_json::from_value(serde_json::json!({
2328            "title": "t",
2329            "content": "c",
2330            "citations": [{
2331                "uri": "doc:abc",
2332                "accessed_at": "2026-01-01T00:00:00Z",
2333            }],
2334            "source_uri": "uri:https://example.com",
2335            "source_span": {"start": 0, "end": 5},
2336        }))
2337        .unwrap();
2338        assert_eq!(cm.citations.len(), 1);
2339        assert_eq!(cm.source_uri.as_deref(), Some("uri:https://example.com"));
2340        assert_eq!(cm.source_span, Some(SourceSpan { start: 0, end: 5 }));
2341    }
2342
2343    // ─────────────────────────────────────────────────────────────────────
2344    // #1385 — CreateMemory now honours caller-supplied `kind`. Pre-fix
2345    // the field did not exist on the struct, so HTTP `POST
2346    // /api/v1/memories` silently dropped it and every HTTP-created row
2347    // landed as `Observation`. That made the Form 6 recall `kinds`
2348    // filter useless against the HTTP write surface (a v3 NHI
2349    // assessment defect; live alice repro returned 0 rows for
2350    // kinds=["claim","decision"] against rows the caller had stored
2351    // with those exact kind tokens).
2352    // ─────────────────────────────────────────────────────────────────────
2353
2354    #[test]
2355    fn create_memory_kind_field_deserialises_known_tokens() {
2356        for token in [
2357            "observation",
2358            "reflection",
2359            "persona",
2360            "concept",
2361            "entity",
2362            "claim",
2363            "relation",
2364            "event",
2365            "conversation",
2366            "decision",
2367        ] {
2368            let cm: CreateMemory = serde_json::from_value(serde_json::json!({
2369                "title": "t",
2370                "content": "c",
2371                "kind": token,
2372            }))
2373            .unwrap();
2374            assert_eq!(
2375                cm.kind.as_deref(),
2376                Some(token),
2377                "kind={token} must round-trip on the wire"
2378            );
2379            // And the handler parses it back into the typed enum on
2380            // assembly. Mirror the exact pattern the handler uses.
2381            let parsed = cm.kind.as_deref().and_then(MemoryKind::from_str);
2382            assert_eq!(
2383                parsed.map(|k| k.as_str()),
2384                Some(token),
2385                "kind={token} must parse back into MemoryKind",
2386            );
2387        }
2388    }
2389
2390    #[test]
2391    fn create_memory_kind_field_absent_defaults_to_none() {
2392        let cm: CreateMemory = serde_json::from_value(serde_json::json!({
2393            "title": "t",
2394            "content": "c",
2395        }))
2396        .unwrap();
2397        assert_eq!(cm.kind, None);
2398        // Handler-side: absent → falls through to `Observation`.
2399        let resolved = cm
2400            .kind
2401            .as_deref()
2402            .and_then(MemoryKind::from_str)
2403            .unwrap_or_default();
2404        assert_eq!(resolved, MemoryKind::Observation);
2405    }
2406
2407    #[test]
2408    fn create_memory_kind_field_unknown_token_silently_falls_through_to_observation() {
2409        // Matches MCP `memory_store` forward-compat posture
2410        // (`src/mcp/tools/store/validation.rs:207-213`): an unknown
2411        // kind token is treated as omission so a newer-client variant
2412        // landing on an older daemon still writes, just without the
2413        // typed discriminator. Distinct from the COR-4 invariant on
2414        // recall `kinds` filters where an explicit zero-match filter
2415        // must NOT collapse into "match all".
2416        let cm: CreateMemory = serde_json::from_value(serde_json::json!({
2417            "title": "t",
2418            "content": "c",
2419            "kind": "future_variant_v100",
2420        }))
2421        .unwrap();
2422        assert_eq!(cm.kind.as_deref(), Some("future_variant_v100"));
2423        let resolved = cm
2424            .kind
2425            .as_deref()
2426            .and_then(MemoryKind::from_str)
2427            .unwrap_or_default();
2428        assert_eq!(
2429            resolved,
2430            MemoryKind::Observation,
2431            "unknown kind token must silently fall through to Observation \
2432             for forward-compat with future-variant clients",
2433        );
2434    }
2435}