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}