Skip to main content

meerkat_mobkit/memory/
records.rs

1//! Durable agent-memory record model.
2//!
3//! Implements docs/design/agent-memory-architecture.md §7.1 (record model),
4//! §7.2 (scopes — all realm-keyed) and the deterministic halves of §10.2
5//! (trust-tier transition lattice as pure functions). Everything here is
6//! structure, not judgment: types, caps, ordering, hashing. LLM stages write
7//! into this model exclusively through validated staged mutations
8//! (`crate::memory::staged`).
9
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12
13/// Stable, content-independent record identifier.
14pub type MemoryId = String;
15
16/// Identifier for a pending mob/operator-scope proposal (§7.3 `propose`).
17pub type ProposalId = String;
18
19/// Byte caps (§7.1). Deterministic write-time guards, enforced by the staged
20/// validator and the bundled store.
21pub const MAX_RECORD_TITLE_BYTES: usize = 200;
22pub const MAX_RECORD_DESCRIPTION_BYTES: usize = 400;
23pub const MAX_RECORD_BODY_BYTES: usize = 64 * 1024;
24
25/// Memory scope (§7.2). **Every scope is realm-keyed**: realms are the
26/// platform's isolation boundary, and memory is state. `Operator` is part of
27/// the P0 schema (no migration later) but populates only from P4.
28#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
29#[serde(tag = "scope", rename_all = "snake_case")]
30pub enum MemoryScope {
31    Identity { realm: String, identity: String },
32    Mob { realm: String, mob: String },
33    Operator { realm: String, operator: String },
34    Realm { realm: String },
35}
36
37impl MemoryScope {
38    pub fn realm(&self) -> &str {
39        match self {
40            Self::Identity { realm, .. }
41            | Self::Mob { realm, .. }
42            | Self::Operator { realm, .. }
43            | Self::Realm { realm } => realm,
44        }
45    }
46
47    /// Stable discriminant used for storage columns and audit rows.
48    pub fn kind_str(&self) -> &'static str {
49        match self {
50            Self::Identity { .. } => "identity",
51            Self::Mob { .. } => "mob",
52            Self::Operator { .. } => "operator",
53            Self::Realm { .. } => "realm",
54        }
55    }
56
57    /// The non-realm scope key (`""` for realm scope).
58    pub fn key(&self) -> &str {
59        match self {
60            Self::Identity { identity, .. } => identity,
61            Self::Mob { mob, .. } => mob,
62            Self::Operator { operator, .. } => operator,
63            Self::Realm { .. } => "",
64        }
65    }
66}
67
68/// Closed record taxonomy (§7.1). `OpenLoop` is the prospective-memory kind:
69/// unfinished intentions with an explicit resolution condition.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum MemoryKind {
73    Preference,
74    Fact,
75    Gotcha,
76    Procedure,
77    Relationship,
78    OpenLoop,
79    Reference,
80}
81
82impl MemoryKind {
83    pub fn as_str(&self) -> &'static str {
84        match self {
85            Self::Preference => "preference",
86            Self::Fact => "fact",
87            Self::Gotcha => "gotcha",
88            Self::Procedure => "procedure",
89            Self::Relationship => "relationship",
90            Self::OpenLoop => "open_loop",
91            Self::Reference => "reference",
92        }
93    }
94
95    pub fn parse(value: &str) -> Option<Self> {
96        match value {
97            "preference" => Some(Self::Preference),
98            "fact" => Some(Self::Fact),
99            "gotcha" => Some(Self::Gotcha),
100            "procedure" => Some(Self::Procedure),
101            "relationship" => Some(Self::Relationship),
102            "open_loop" => Some(Self::OpenLoop),
103            "reference" => Some(Self::Reference),
104            _ => None,
105        }
106    }
107}
108
109/// Trust tier (§7.1, §10.2). Variant order is authority order:
110/// `Untrusted < AgentObserved < AgentVerified < Application < Operator`,
111/// so `derive(Ord)` gives the lattice's comparison for free.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114pub enum TrustTier {
115    Untrusted,
116    AgentObserved,
117    AgentVerified,
118    Application,
119    Operator,
120}
121
122impl TrustTier {
123    pub fn as_str(&self) -> &'static str {
124        match self {
125            Self::Untrusted => "untrusted",
126            Self::AgentObserved => "agent_observed",
127            Self::AgentVerified => "agent_verified",
128            Self::Application => "application",
129            Self::Operator => "operator",
130        }
131    }
132
133    pub fn parse(value: &str) -> Option<Self> {
134        match value {
135            "untrusted" => Some(Self::Untrusted),
136            "agent_observed" => Some(Self::AgentObserved),
137            "agent_verified" => Some(Self::AgentVerified),
138            "application" => Some(Self::Application),
139            "operator" => Some(Self::Operator),
140            _ => None,
141        }
142    }
143
144    /// §10.2: `Operator` and `Application` tiers are assignable only by
145    /// non-LLM principals through direct (non-staged) surfaces — never via
146    /// any `StagedMutationBatch`.
147    pub fn assignable_via_staged_batch(&self) -> bool {
148        !matches!(self, Self::Operator | Self::Application)
149    }
150
151    /// §10.2: all LLM-authored writes enter at `AgentObserved` or below.
152    pub fn llm_write_ceiling() -> Self {
153        Self::AgentObserved
154    }
155
156    /// §10.2 transitive-provenance ceiling: a record whose evidence or
157    /// supersede/derivation chain reaches `Untrusted`/quarantined provenance
158    /// is capped at `AgentObserved` forever.
159    pub fn capped_for_tainted_provenance(self) -> Self {
160        self.min(Self::AgentObserved)
161    }
162}
163
164/// Record lifecycle status (§7.1). Superseded records stay retrievable with
165/// provenance; only `Active` records are injected or recalled.
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(tag = "status", rename_all = "snake_case")]
168pub enum RecordStatus {
169    Active,
170    Superseded { by: MemoryId },
171    Quarantined { reason: String },
172    Tombstoned,
173}
174
175impl RecordStatus {
176    pub fn kind_str(&self) -> &'static str {
177        match self {
178            Self::Active => "active",
179            Self::Superseded { .. } => "superseded",
180            Self::Quarantined { .. } => "quarantined",
181            Self::Tombstoned => "tombstoned",
182        }
183    }
184}
185
186/// Who authored a record (§7.1).
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(tag = "author", rename_all = "snake_case")]
189pub enum MemoryAuthor {
190    Operator,
191    Application,
192    Agent { identity: String },
193    Steward { run_id: String },
194    Distiller { run_id: String },
195}
196
197impl MemoryAuthor {
198    /// §10.2 splits the lattice on this: LLM authors are tier-ceilinged.
199    pub fn is_llm(&self) -> bool {
200        matches!(
201            self,
202            Self::Agent { .. } | Self::Steward { .. } | Self::Distiller { .. }
203        )
204    }
205}
206
207/// Provenance pointer into immutable session evidence (§7.1).
208///
209/// `revision` is the content-addressed transcript revision that was head at
210/// capture time. It is `Option` until the Hygienist (transcript revisions,
211/// P4) lands — `None` means "head at capture time; revision pinning not yet
212/// available", not "unknown provenance".
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214pub struct EvidenceRef {
215    pub session_id: String,
216    /// Continuity generation — fresh-start (`reset`) boundaries are
217    /// first-class; session→generation is unrecoverable after reset without
218    /// this.
219    pub generation: u64,
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub revision: Option<String>,
222    /// Message range within the pinned revision.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub range: Option<(u64, u64)>,
225}
226
227/// Calibration profile reference (§11). All strings for now: the calibration
228/// harness (P1) defines the artifact family these point into.
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230pub struct CalibrationRef {
231    pub stage: String,
232    pub bundle: String,
233    pub version: String,
234    pub model: String,
235}
236
237/// Agent-cited evidence of verification — a CLAIM, not a tier (§8.2). The
238/// tier upgrade to `AgentVerified` is a steward-only staged operation.
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240pub struct VerificationClaim {
241    /// What was checked, in the author's words.
242    pub checked: String,
243    #[serde(default)]
244    pub evidence: Vec<EvidenceRef>,
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248pub struct MemoryProvenance {
249    #[serde(default)]
250    pub evidence: Vec<EvidenceRef>,
251    pub author: MemoryAuthor,
252    /// §7.1 models this as required; it is `Option` until calibration
253    /// profiles exist (P1) — imports and pre-calibration writes carry `None`.
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub profile: Option<CalibrationRef>,
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub verification: Option<VerificationClaim>,
258}
259
260/// Usage ledger counters (§9.2). Deterministic side only; judged-useful
261/// verdicts come from the steward's usage audit (P3). Ambient injection and
262/// explicit recall are counted distinctly: the steward's usage audit treats
263/// "pushed and ignored" very differently from "pulled on purpose".
264#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
265pub struct UsageStats {
266    #[serde(default)]
267    pub injected_count: u64,
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub last_injected_at_ms: Option<u64>,
270    #[serde(default)]
271    pub explicit_recall_count: u64,
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub last_recalled_at_ms: Option<u64>,
274    #[serde(default)]
275    pub judged_useful_count: u64,
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub last_useful_at_ms: Option<u64>,
278}
279
280/// Mechanical usage events (§9.2). `JudgedUseful` is reserved for the
281/// steward's audit verdicts.
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
283#[serde(rename_all = "snake_case")]
284pub enum UsageEvent {
285    Injected,
286    ExplicitRecall,
287    JudgedUseful,
288}
289
290/// Which injection surface delivered a record into context (§9.1 table).
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
292#[serde(rename_all = "snake_case")]
293pub enum InjectionSurface {
294    /// Build-time assembly (`customize_build` → system prompt).
295    Build,
296    /// Ambient per-turn injection (opt-in `budgeted` mode).
297    Turn,
298}
299
300impl InjectionSurface {
301    pub fn as_str(&self) -> &'static str {
302        match self {
303            Self::Build => "build",
304            Self::Turn => "turn",
305        }
306    }
307
308    pub fn parse(value: &str) -> Option<Self> {
309        match value {
310            "build" => Some(Self::Build),
311            "turn" => Some(Self::Turn),
312            _ => None,
313        }
314    }
315}
316
317/// One injection-ledger row (§9.2): which record entered whose context,
318/// through which surface, when. Telemetry, not record mutation — rows are
319/// plain appends, never staged. The session key is `None` for build-time
320/// assembly, where the session does not exist yet.
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
322pub struct InjectionLogEntry {
323    pub record_id: MemoryId,
324    pub identity: String,
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub session_key: Option<String>,
327    pub surface: InjectionSurface,
328    pub at_ms: u64,
329}
330
331/// The full record model (§7.1).
332///
333/// Two additions beyond the §7.1 field list, both deliberate:
334/// - `tags`: wire-compat with the markdown-era `AgentMemoryRecord`
335///   projection (recall keeps tags); a legacy retrieval surface, not part of
336///   the hub-compatible core.
337/// - `derived_from`: consolidation lineage. Without it the §10.2 transitive
338///   ceiling cannot see a merge — "laundering by consolidation is a
339///   validator reject" requires the merge edge to be recorded.
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341pub struct MemoryRecord {
342    pub id: MemoryId,
343    pub scope: MemoryScope,
344    pub kind: MemoryKind,
345    pub title: String,
346    /// Written FOR the Selector; this line is the retrieval contract.
347    #[serde(default)]
348    pub description: String,
349    pub body: String,
350    #[serde(default)]
351    pub tags: Vec<String>,
352    pub provenance: MemoryProvenance,
353    pub trust: TrustTier,
354    pub status: RecordStatus,
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub supersedes: Option<MemoryId>,
357    #[serde(default)]
358    pub derived_from: Vec<MemoryId>,
359    /// Steward-maintained recall ordering (§8.3); superseding records
360    /// inherit the prior record's rank until the next dream.
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub working_set_rank: Option<u32>,
363    pub created_at_ms: u64,
364    pub updated_at_ms: u64,
365    #[serde(default)]
366    pub usage: UsageStats,
367}
368
369/// Payload for creating or superseding a record (§7.3 `NewRecord`).
370/// Authorship comes from the surrounding batch/call context, never from the
371/// payload itself.
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
373pub struct NewMemoryRecord {
374    pub kind: MemoryKind,
375    pub title: String,
376    #[serde(default)]
377    pub description: String,
378    pub body: String,
379    #[serde(default)]
380    pub tags: Vec<String>,
381    #[serde(default)]
382    pub evidence: Vec<EvidenceRef>,
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub verification: Option<VerificationClaim>,
385}
386
387/// Manifest row (§7.3): id+kind+title+description+age+rank. Deliberately
388/// body-free — the manifest is an index, never a dump.
389#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
390pub struct RecordMeta {
391    pub id: MemoryId,
392    pub kind: MemoryKind,
393    pub title: String,
394    #[serde(default)]
395    pub description: String,
396    pub age_days: u64,
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub rank: Option<u32>,
399}
400
401/// Manifest tiers (§8.3). `WorkingSet(k)` = top-K ranked ∪ recent/unranked
402/// slice (newest-first), union capped at `2*k`; `Full` = every active
403/// record's metadata.
404#[derive(Debug, Clone, Copy, PartialEq, Eq)]
405pub enum ManifestTier {
406    WorkingSet(usize),
407    Full,
408}
409
410/// Exact content hash used by the write-time dedup guard and the
411/// tombstone-recreation check. Length-prefixed so `("ab","c")` and
412/// `("a","bc")` differ. Uses sha2, which the crate already depends on.
413pub fn content_hash(title: &str, body: &str) -> String {
414    let mut hasher = Sha256::new();
415    hasher.update((title.len() as u64).to_le_bytes());
416    hasher.update(title.as_bytes());
417    hasher.update(body.as_bytes());
418    let digest = hasher.finalize();
419    let mut out = String::with_capacity(64);
420    for byte in digest {
421        out.push_str(&format!("{byte:02x}"));
422    }
423    out
424}
425
426/// Deterministic field caps (§7.1). Description may be empty (the Recorder
427/// that writes selector-facing descriptions lands in P1).
428pub fn validate_record_fields(title: &str, description: &str, body: &str) -> Result<(), String> {
429    if title.trim().is_empty() {
430        return Err("title must not be empty".to_string());
431    }
432    if title.len() > MAX_RECORD_TITLE_BYTES {
433        return Err(format!(
434            "title must be at most {MAX_RECORD_TITLE_BYTES} bytes"
435        ));
436    }
437    if description.len() > MAX_RECORD_DESCRIPTION_BYTES {
438        return Err(format!(
439            "description must be at most {MAX_RECORD_DESCRIPTION_BYTES} bytes"
440        ));
441    }
442    if body.trim().is_empty() {
443        return Err("body must not be empty".to_string());
444    }
445    if body.len() > MAX_RECORD_BODY_BYTES {
446        return Err(format!(
447            "body must be at most {MAX_RECORD_BODY_BYTES} bytes"
448        ));
449    }
450    Ok(())
451}
452
453/// Age in whole days, saturating (clock skew must not underflow).
454pub fn age_days(updated_at_ms: u64, now_ms: u64) -> u64 {
455    now_ms.saturating_sub(updated_at_ms) / (24 * 60 * 60 * 1000)
456}
457
458#[cfg(test)]
459#[allow(clippy::expect_used)]
460mod tests {
461    use super::*;
462
463    #[test]
464    fn trust_tier_order_matches_lattice_authority() {
465        assert!(TrustTier::Untrusted < TrustTier::AgentObserved);
466        assert!(TrustTier::AgentObserved < TrustTier::AgentVerified);
467        assert!(TrustTier::AgentVerified < TrustTier::Application);
468        assert!(TrustTier::Application < TrustTier::Operator);
469    }
470
471    #[test]
472    fn operator_and_application_tiers_never_staged_assignable() {
473        assert!(!TrustTier::Operator.assignable_via_staged_batch());
474        assert!(!TrustTier::Application.assignable_via_staged_batch());
475        assert!(TrustTier::AgentVerified.assignable_via_staged_batch());
476        assert!(TrustTier::AgentObserved.assignable_via_staged_batch());
477        assert!(TrustTier::Untrusted.assignable_via_staged_batch());
478    }
479
480    #[test]
481    fn tainted_provenance_caps_at_agent_observed() {
482        assert_eq!(
483            TrustTier::AgentVerified.capped_for_tainted_provenance(),
484            TrustTier::AgentObserved
485        );
486        assert_eq!(
487            TrustTier::Operator.capped_for_tainted_provenance(),
488            TrustTier::AgentObserved
489        );
490        assert_eq!(
491            TrustTier::Untrusted.capped_for_tainted_provenance(),
492            TrustTier::Untrusted
493        );
494    }
495
496    #[test]
497    fn content_hash_is_stable_and_boundary_safe() {
498        assert_eq!(content_hash("a", "b"), content_hash("a", "b"));
499        assert_ne!(content_hash("ab", "c"), content_hash("a", "bc"));
500        assert_eq!(content_hash("t", "b").len(), 64);
501    }
502
503    #[test]
504    fn record_serde_round_trips() {
505        let record = MemoryRecord {
506            id: "mem-1".to_string(),
507            scope: MemoryScope::Identity {
508                realm: "family".to_string(),
509                identity: "identity:luka".to_string(),
510            },
511            kind: MemoryKind::OpenLoop,
512            title: "Try the staging DB".to_string(),
513            description: "When smoke tests need a database".to_string(),
514            body: "Next time try the staging DB first. Resolved when tried.".to_string(),
515            tags: vec!["staging".to_string()],
516            provenance: MemoryProvenance {
517                evidence: vec![EvidenceRef {
518                    session_id: "sess-1".to_string(),
519                    generation: 2,
520                    revision: None,
521                    range: Some((3, 9)),
522                }],
523                author: MemoryAuthor::Agent {
524                    identity: "identity:luka".to_string(),
525                },
526                profile: None,
527                verification: None,
528            },
529            trust: TrustTier::AgentObserved,
530            status: RecordStatus::Superseded {
531                by: "mem-2".to_string(),
532            },
533            supersedes: None,
534            derived_from: Vec::new(),
535            working_set_rank: Some(4),
536            created_at_ms: 10,
537            updated_at_ms: 20,
538            usage: UsageStats::default(),
539        };
540        let json = serde_json::to_string(&record).expect("serialize");
541        let back: MemoryRecord = serde_json::from_str(&json).expect("deserialize");
542        assert_eq!(back, record);
543    }
544
545    #[test]
546    fn field_caps_reject_oversized_and_empty() {
547        assert!(validate_record_fields("t", "", "b").is_ok());
548        assert!(validate_record_fields("", "", "b").is_err());
549        assert!(validate_record_fields("t", "", " ").is_err());
550        assert!(validate_record_fields(&"t".repeat(201), "", "b").is_err());
551        assert!(validate_record_fields("t", &"d".repeat(401), "b").is_err());
552        assert!(validate_record_fields("t", "", &"b".repeat(64 * 1024 + 1)).is_err());
553    }
554}