1use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12
13pub type MemoryId = String;
15
16pub type ProposalId = String;
18
19pub 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#[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 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 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#[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#[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 pub fn assignable_via_staged_batch(&self) -> bool {
148 !matches!(self, Self::Operator | Self::Application)
149 }
150
151 pub fn llm_write_ceiling() -> Self {
153 Self::AgentObserved
154 }
155
156 pub fn capped_for_tainted_provenance(self) -> Self {
160 self.min(Self::AgentObserved)
161 }
162}
163
164#[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#[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 pub fn is_llm(&self) -> bool {
200 matches!(
201 self,
202 Self::Agent { .. } | Self::Steward { .. } | Self::Distiller { .. }
203 )
204 }
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214pub struct EvidenceRef {
215 pub session_id: String,
216 pub generation: u64,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub revision: Option<String>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub range: Option<(u64, u64)>,
225}
226
227#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240pub struct VerificationClaim {
241 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 #[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
292#[serde(rename_all = "snake_case")]
293pub enum InjectionSurface {
294 Build,
296 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#[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#[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 #[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 #[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
405pub enum ManifestTier {
406 WorkingSet(usize),
407 Full,
408}
409
410pub 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
426pub 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
453pub 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}