memoir_core/memory.rs
1//! Memory domain types.
2
3use chrono::{DateTime, FixedOffset};
4
5/// Tenant + agent + user partition for a memory.
6///
7/// Memories written under one scope are never returned under another. All
8/// fields must be non-empty; callers that violate this get a runtime error
9/// from the storage layer.
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11pub struct Scope {
12 pub agent_id: String,
13 pub org_id: String,
14 pub user_id: String,
15}
16
17/// Reasons a [`Scope`] fails validation.
18#[derive(Debug, thiserror::Error, PartialEq, Eq)]
19pub enum ScopeError {
20 #[error("scope: agent_id, org_id, and user_id must all be non-empty")]
21 Empty,
22}
23
24impl Scope {
25 /// Returns `Ok(())` when every field is non-empty.
26 ///
27 /// # Errors
28 ///
29 /// Returns [`ScopeError::Empty`] when any of `agent_id`, `org_id`, or
30 /// `user_id` is the empty string.
31 pub fn validate(&self) -> Result<(), ScopeError> {
32 if self.agent_id.is_empty() || self.org_id.is_empty() || self.user_id.is_empty() {
33 return Err(ScopeError::Empty);
34 }
35 Ok(())
36 }
37}
38
39/// Kind of memory written to or read from storage.
40///
41/// The two kinds form memoir's source-and-projection model: episodic rows are
42/// the verbatim record a consumer writes; semantic rows are facts a worker
43/// derives from them. Semantic content is **never hand-written or edited** —
44/// it is always re-derived from its episodic source, so a wrong semantic fact
45/// is corrected by teaching ([`crate::client::Client::feedback`]) or by editing
46/// the source ([`crate::client::Client::edit`]), never by writing the fact
47/// directly. See the crate-root docs' "Correction" section.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, strum::Display, strum::EnumString, strum::AsRefStr)]
49#[strum(serialize_all = "lowercase")]
50pub enum MemoryKind {
51 /// Conversational memory; written by `Client::remember`.
52 Episodic,
53
54 /// Structured fact extracted from episodic memory by an LLM (epic 0006).
55 ///
56 /// Always derived, never authored directly: there is no API to set a
57 /// semantic row's content. Corrections flow through re-derivation.
58 Semantic,
59}
60
61/// Why a memory was retired by the correction model (epic 0011 Track B).
62///
63/// A retired memory is hidden from every read and its vector is evicted, so
64/// it can no longer surface or pollute reprocessing — but the row is kept (it
65/// is the reprocess "don't re-derive this" guard and the accuracy-metric
66/// record). The reason distinguishes an extraction error from a non-error:
67/// only [`Self::Rejected`] counts against extraction accuracy.
68///
69/// Distinct from supersession (the `superseded_by` column + events table),
70/// which models "a newer fact won" — a normal lifecycle event, not a
71/// correction. "Active" means neither superseded nor retired.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, strum::Display, strum::EnumString, strum::AsRefStr)]
73#[strum(serialize_all = "lowercase")]
74pub enum RetirementReason {
75 /// The extraction was wrong; the user corrected it via feedback. This is
76 /// an extraction error — the numerator of the accuracy metric.
77 Rejected,
78
79 /// The episodic source was edited or deleted, so this derived semantic no
80 /// longer reflects it. The model did not err; the source changed.
81 Stale,
82}
83
84/// Optional scope-subset filter for an aggregate read.
85///
86/// Each field narrows the aggregate to memories matching it; an unset field
87/// imposes no constraint. Distinct from [`Scope`], which requires all three
88/// fields — this is a partial filter, so a caller can aggregate org-wide
89/// (`org_id` only), per-agent, or across the whole store (all unset).
90#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
91pub struct StatsFilter {
92 pub agent_id: Option<String>,
93 pub org_id: Option<String>,
94 pub user_id: Option<String>,
95}
96
97/// Extraction-accuracy tally for one `(provider, model)` pair within a slice.
98///
99/// `total` counts every semantic row the pair produced (active or retired, any
100/// reason); `rejected` counts only those retired as [`RetirementReason::Rejected`]
101/// — a wrong extraction the user corrected. Rows retired as
102/// [`RetirementReason::Stale`] (the source changed) and superseded rows (a newer
103/// fact won) are in `total` but never in `rejected`: they are not model errors.
104/// See [`Self::accuracy`] for the derived ratio.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct ExtractionStat {
107 pub provider: String,
108 pub model: String,
109 pub total: u64,
110 pub rejected: u64,
111}
112
113impl ExtractionStat {
114 /// Returns the extraction accuracy as `1 − rejected/total` in `[0.0, 1.0]`.
115 ///
116 /// A pair with zero extractions returns `1.0`: there is nothing to have
117 /// gotten wrong, so the identity value is "no errors."
118 #[must_use]
119 pub fn accuracy(&self) -> f64 {
120 if self.total == 0 {
121 return 1.0;
122 }
123 1.0 - (self.rejected as f64 / self.total as f64)
124 }
125}
126
127/// A memory's confidence as a 0-100 percentage.
128///
129/// A newtype over `i8` whose only constructor clamps into `[0, 100]`, so an
130/// out-of-range value is unrepresentable. This is the single home for the
131/// scale-and-clamp logic: the extraction LLM emits an `f32` (occasionally
132/// `> 1.0`), which [`Confidence::from_unit_scale`] scales by 100 and clamps.
133///
134/// # Examples
135///
136/// ```
137/// use memoir_core::memory::Confidence;
138///
139/// assert_eq!(Confidence::new(73).get(), 73);
140/// assert_eq!(Confidence::new(120).get(), 100); // clamped
141/// assert_eq!(Confidence::from_unit_scale(0.42).get(), 42);
142/// assert_eq!(Confidence::from_unit_scale(1.7).get(), 100); // clamped
143/// ```
144#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
145pub struct Confidence(i8);
146
147impl Confidence {
148 /// Largest valid confidence: fully certain.
149 pub const MAX: Confidence = Confidence(100);
150
151 /// Smallest valid confidence: no certainty.
152 pub const MIN: Confidence = Confidence(0);
153
154 /// Creates a confidence from a percentage, clamping into `[0, 100]`.
155 ///
156 /// Clamping is the defined behavior, not an error: callers (and the
157 /// extraction LLM) occasionally produce out-of-range values, and the
158 /// intent is always "as confident as possible / not at all," never a
159 /// failure. Hence this is infallible.
160 #[must_use]
161 pub fn new(percent: i8) -> Self {
162 Self(percent.clamp(0, 100))
163 }
164
165 /// Creates a confidence from a unit-scale score, scaling ×100 and clamping.
166 ///
167 /// The extraction LLM emits a per-fact score in `[0.0, 1.0]` (but may
168 /// exceed `1.0`). This scales to a percentage and clamps into `[0, 100]`.
169 /// `NaN` maps to [`Confidence::MIN`].
170 #[must_use]
171 pub fn from_unit_scale(score: f32) -> Self {
172 if score.is_nan() {
173 return Self::MIN;
174 }
175 // Round before clamping so e.g. 0.005 -> 1, not 0.
176 let percent = (score * 100.0).round();
177 Self(percent.clamp(0.0, 100.0) as i8)
178 }
179
180 /// Returns the percentage value in `[0, 100]`.
181 #[must_use]
182 pub fn get(self) -> i8 {
183 self.0
184 }
185}
186
187impl Default for Confidence {
188 /// Defaults to fully certain (`100`), matching the `memories.confidence`
189 /// column default — episodic writes are certain by construction.
190 fn default() -> Self {
191 Self::MAX
192 }
193}
194
195impl std::fmt::Display for Confidence {
196 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197 write!(f, "{}", self.0)
198 }
199}
200
201/// Selects which memory kinds a read includes.
202///
203/// Each field gates inclusion of one kind. Default ([`Self::default`]) has
204/// every field `true` — retrieve all kinds. A field set to `false` filters
205/// that kind out. Constructing with all fields `false` is legal and yields an
206/// empty result.
207///
208/// Designed so that adding a new kind later is additive: a new `pub bool`
209/// field with default `true` does not break existing constructors that use
210/// `..Default::default()` or named-field init.
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
212pub struct KindSelector {
213 pub episodic: bool,
214 pub semantic: bool,
215}
216
217impl Default for KindSelector {
218 fn default() -> Self {
219 Self {
220 episodic: true,
221 semantic: true,
222 }
223 }
224}
225
226impl KindSelector {
227 /// Returns the kinds this selector includes, in canonical order.
228 pub fn included_kinds(&self) -> Vec<MemoryKind> {
229 let mut out = Vec::with_capacity(2);
230 if self.episodic {
231 out.push(MemoryKind::Episodic);
232 }
233 if self.semantic {
234 out.push(MemoryKind::Semantic);
235 }
236 out
237 }
238
239 /// Returns `true` when every defined kind is included.
240 pub fn includes_all(&self) -> bool {
241 self.episodic && self.semantic
242 }
243
244 /// Returns `true` when no kind is included.
245 pub fn is_empty(&self) -> bool {
246 !self.episodic && !self.semantic
247 }
248}
249
250/// A stored memory, with optional similarity score from vector search.
251///
252/// Carries three distinct timestamps that should not be confused:
253/// `created_at` (when memoir was told), `updated_at` (last in-place edit),
254/// and `event_at` (when the remembered event actually occurred). The first
255/// two are wall-clock; the third is event-time and may predate `created_at`
256/// by arbitrary amounts.
257///
258/// Soft-deletion via [`SupersessionInfo`] keeps superseded rows in the
259/// store, but [`crate::client::Client::search`] filters them out by
260/// default. They remain reachable via [`crate::client::Client::recall`].
261#[derive(Debug, Clone)]
262pub struct Memory {
263 /// Public id; opaque, stable for the lifetime of the row.
264 pub pid: String,
265
266 /// Tenant + agent + user partition. See [`Scope`].
267 pub scope: Scope,
268
269 /// Raw text of the memory.
270 pub content: String,
271
272 /// Arbitrary JSON attached at write time; round-trips unchanged.
273 pub metadata: serde_json::Value,
274
275 /// Episodic (raw utterance) or semantic (LLM-extracted fact).
276 pub kind: MemoryKind,
277
278 /// Originating episodic pid for semantic rows; `None` for episodic.
279 ///
280 /// Enforced at the database with `ON DELETE CASCADE`: forgetting the
281 /// source automatically removes derived semantic memories.
282 pub source_pid: Option<String>,
283
284 /// Soft-deletion marker; `None` when active.
285 ///
286 /// Populated by contradiction-detection passes or operator action.
287 /// The nested type ties winner pid and decision time together so
288 /// neither can exist without the other.
289 pub supersession: Option<SupersessionInfo>,
290
291 /// Wall-clock time memoir received the utterance.
292 pub created_at: DateTime<FixedOffset>,
293
294 /// Wall-clock time of the row's last in-place mutation.
295 ///
296 /// Auto-bumped by the database trigger on every UPDATE. Equals
297 /// `created_at` for memories never edited via
298 /// [`crate::client::Client::edit`].
299 pub updated_at: DateTime<FixedOffset>,
300
301 /// Event-time of the thing being remembered; `None` when unknown.
302 ///
303 /// Distinct from `created_at`: "the deployment happened Friday" said
304 /// today carries `event_at = Friday`, `created_at = today`. Set by
305 /// consumers via `RememberBuilder::event_at` or by LLM extraction.
306 /// `None` is appropriate when no event-time is meaningful
307 /// (preferences, identity facts).
308 pub event_at: Option<DateTime<FixedOffset>>,
309
310 /// Cosine similarity score; `Some` only on vector-search results.
311 pub score: Option<f32>,
312
313 /// Processing lifecycle state of the row's vector index.
314 ///
315 /// `Pending` immediately after a write (embedding + vector upsert in
316 /// flight), `Indexed` once searchable, `Failed` if embedding errored.
317 /// Mirrors the `memories.qdrant_status` column. Consumers use this as the
318 /// canonical "is this memory fully processed yet" signal.
319 pub status: crate::store::IndexStatus,
320
321 /// How sure memoir is that this memory is true, as a 0-100 percentage.
322 ///
323 /// Episodic memories are `100` by construction — the user said it.
324 /// Semantic memories carry the extraction LLM's scaled per-fact score
325 /// (populated by the extract worker). See [`Confidence`]. Feeds the
326 /// selection blend as a signal (normalized to `[0, 1]`) and the
327 /// `min_confidence` hard filter — see [`crate::client::BlendWeights`].
328 pub confidence: Confidence,
329
330 /// Categorization label, or `None` until the categorize worker runs.
331 ///
332 /// Populated asynchronously by the NLI categorize stage. A `None`
333 /// category is unfiltered, not rejected — absence means "not yet
334 /// classified," not "no category applies." The value set (taxonomy) is
335 /// owned by the categorize worker, so this stays an open `String` here;
336 /// the v1 labels are `preference`, `identity`, `workflow`, `factual`,
337 /// `transient` (see `crate::client::categorize`). Drives the
338 /// category-bonus term of the selection blend ([`crate::client::BlendWeights`])
339 /// and the `category` hard filter on search/query.
340 pub category: Option<String>,
341
342 /// Why this memory was retired, or `None` when active (epic 0011).
343 ///
344 /// Set by the correction model ([`crate::client::Client::reject`] /
345 /// `mark_stale`). A `Some(_)` row is hidden from all reads and its vector
346 /// is evicted; the row is kept for the reprocess guard and the
347 /// extraction-accuracy metric ([`crate::client::Client::extraction_stats`]),
348 /// where only [`RetirementReason::Rejected`] counts as an error. Distinct
349 /// from [`Self::supersession`]. "Active" requires both this and
350 /// `supersession` to be `None`.
351 pub retirement: Option<RetirementReason>,
352}
353
354/// Latest supersession state for a [`Memory`] — winner pid and decision time.
355///
356/// Reflects only the current state. Full supersession history, including
357/// reversals, lives in the `supersession_events` audit table.
358#[derive(Debug, Clone, PartialEq, Eq)]
359pub struct SupersessionInfo {
360 /// Pid of the memory that supersedes this one.
361 pub winner_pid: String,
362
363 /// Wall-clock time the supersession decision was made.
364 pub at: DateTime<FixedOffset>,
365}
366
367/// One supersede or unsupersede decision against a memory.
368///
369/// Mirrors one row of the `supersession_events` audit table. A `winner_pid`
370/// of `None` is an unsupersede — the memory was restored to active.
371///
372/// Returned in chronological order by
373/// [`crate::store::MemoryStore::supersession_history`] and surfaced by
374/// [`crate::client::Client::supersession_history`].
375#[derive(Debug, Clone, PartialEq, Eq)]
376pub struct SupersessionEvent {
377 /// Pid of the memory that took precedence; `None` for an unsupersede event.
378 pub winner_pid: Option<String>,
379
380 /// Wall-clock time the decision was recorded.
381 pub decided_at: DateTime<FixedOffset>,
382}
383
384/// Target of a forget operation: a single memory or a whole scope.
385#[derive(Debug, Clone)]
386pub enum ForgetTarget {
387 /// Forget exactly one memory by its public id.
388 Pid(String),
389
390 /// Forget every memory matching the scope tuple.
391 Scope(Scope),
392}
393
394/// A list of memories and an optional LLM-facing system prompt section.
395///
396/// Returned by [`crate::client::Client::remember`]. Implements [`Display`]
397/// for direct injection into a system prompt and [`Deref`] to `[Memory]`
398/// for iteration.
399///
400/// When `system_prompt` is `Some`, [`Display`] emits the prompt followed by
401/// a bullet list of memory content. When `None`, only the bullet list is
402/// emitted — the caller takes responsibility for instructing the LLM.
403///
404/// [`Display`]: std::fmt::Display
405/// [`Deref`]: std::ops::Deref
406#[derive(Debug, Clone)]
407pub struct Memories {
408 list: Vec<Memory>,
409 system_prompt: Option<String>,
410 graph: crate::graph::GraphContext,
411}
412
413impl Memories {
414 /// Builds a `Memories` from a list and an optional system prompt section.
415 ///
416 /// The graph context starts empty; populate it with
417 /// [`Self::with_graph_context`] when a search opts into enrichment.
418 pub fn new(list: Vec<Memory>, system_prompt: Option<String>) -> Self {
419 Self {
420 list,
421 system_prompt,
422 graph: crate::graph::GraphContext::default(),
423 }
424 }
425
426 /// Attaches the graph neighborhood produced by an enriched search.
427 #[must_use]
428 pub fn with_graph_context(mut self, graph: crate::graph::GraphContext) -> Self {
429 self.graph = graph;
430 self
431 }
432
433 /// Returns the contained memories as a slice.
434 pub fn list(&self) -> &[Memory] {
435 &self.list
436 }
437
438 /// Returns the configured system-prompt section, if any.
439 pub fn system_prompt(&self) -> Option<&str> {
440 self.system_prompt.as_deref()
441 }
442
443 /// Returns the graph neighborhood from an enriched search.
444 ///
445 /// Empty unless the search opted in via `.with_graph()`. This is read-only
446 /// context for the consumer to format as they choose; [`Display`] renders
447 /// only the memories, leaving graph-context injection to the caller.
448 ///
449 /// [`Display`]: std::fmt::Display
450 pub fn graph(&self) -> &crate::graph::GraphContext {
451 &self.graph
452 }
453}
454
455impl std::fmt::Display for Memories {
456 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457 if let Some(prompt) = &self.system_prompt {
458 writeln!(f, "{prompt}")?;
459 }
460 for memory in &self.list {
461 writeln!(f, "- {}", memory.content)?;
462 }
463 Ok(())
464 }
465}
466
467impl std::ops::Deref for Memories {
468 type Target = [Memory];
469
470 fn deref(&self) -> &[Memory] {
471 &self.list
472 }
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478 use chrono::Utc;
479
480 fn fixture(content: &str) -> Memory {
481 let now: DateTime<FixedOffset> = Utc::now().into();
482 Memory {
483 pid: "test".into(),
484 scope: Scope {
485 agent_id: "a".into(),
486 org_id: "o".into(),
487 user_id: "u".into(),
488 },
489 content: content.into(),
490 metadata: serde_json::json!({}),
491 kind: MemoryKind::Episodic,
492 source_pid: None,
493 supersession: None,
494 created_at: now,
495 updated_at: now,
496 event_at: None,
497 score: None,
498 status: crate::store::IndexStatus::Pending,
499 confidence: Confidence::default(),
500 category: None,
501 retirement: None,
502 }
503 }
504
505 #[test]
506 fn should_render_memory_kind_as_lowercase_string() {
507 assert_eq!(MemoryKind::Episodic.as_ref(), "episodic");
508 assert_eq!(MemoryKind::Semantic.as_ref(), "semantic");
509 }
510
511 #[test]
512 fn should_display_memory_kind_matching_as_ref() {
513 assert_eq!(MemoryKind::Episodic.to_string(), "episodic");
514 assert_eq!(MemoryKind::Semantic.to_string(), "semantic");
515 }
516
517 #[test]
518 fn should_render_retirement_reason_as_lowercase_string() {
519 assert_eq!(RetirementReason::Rejected.as_ref(), "rejected");
520 assert_eq!(RetirementReason::Stale.as_ref(), "stale");
521 }
522
523 #[test]
524 fn should_round_trip_retirement_reason_through_str() {
525 use std::str::FromStr as _;
526 assert_eq!(RetirementReason::from_str("rejected").unwrap(), RetirementReason::Rejected);
527 assert_eq!(RetirementReason::from_str("stale").unwrap(), RetirementReason::Stale);
528 assert!(RetirementReason::from_str("superseded").is_err());
529 assert!(RetirementReason::from_str("nonsense").is_err());
530 }
531
532 #[test]
533 fn should_compute_accuracy_as_one_minus_rejected_over_total() {
534 let stat = ExtractionStat {
535 provider: "ollama".to_string(),
536 model: "qwen3:14b".to_string(),
537 total: 100,
538 rejected: 3,
539 };
540 assert!((stat.accuracy() - 0.97).abs() < f64::EPSILON);
541 }
542
543 #[test]
544 fn should_report_perfect_accuracy_when_no_extractions() {
545 let stat = ExtractionStat {
546 provider: String::new(),
547 model: String::new(),
548 total: 0,
549 rejected: 0,
550 };
551 assert_eq!(stat.accuracy(), 1.0, "zero extractions means nothing to get wrong");
552 }
553
554 #[test]
555 fn should_parse_memory_kind_from_str() {
556 use std::str::FromStr as _;
557 assert_eq!(MemoryKind::from_str("episodic").unwrap(), MemoryKind::Episodic);
558 assert_eq!(MemoryKind::from_str("semantic").unwrap(), MemoryKind::Semantic);
559 assert!(MemoryKind::from_str("nonsense").is_err());
560 }
561
562 #[test]
563 fn should_keep_in_range_confidence_unchanged() {
564 assert_eq!(Confidence::new(0).get(), 0);
565 assert_eq!(Confidence::new(73).get(), 73);
566 assert_eq!(Confidence::new(100).get(), 100);
567 }
568
569 #[test]
570 fn should_clamp_out_of_range_confidence() {
571 assert_eq!(Confidence::new(127).get(), 100);
572 assert_eq!(Confidence::new(-1).get(), 0);
573 assert_eq!(Confidence::new(-128).get(), 0);
574 }
575
576 #[test]
577 fn should_scale_unit_confidence_to_percentage() {
578 assert_eq!(Confidence::from_unit_scale(0.0).get(), 0);
579 assert_eq!(Confidence::from_unit_scale(0.42).get(), 42);
580 assert_eq!(Confidence::from_unit_scale(1.0).get(), 100);
581 }
582
583 #[test]
584 fn should_clamp_unit_confidence_above_one() {
585 // The extraction LLM occasionally emits scores > 1.0.
586 assert_eq!(Confidence::from_unit_scale(1.7).get(), 100);
587 assert_eq!(Confidence::from_unit_scale(-0.5).get(), 0);
588 }
589
590 #[test]
591 fn should_map_nan_confidence_to_min() {
592 assert_eq!(Confidence::from_unit_scale(f32::NAN), Confidence::MIN);
593 }
594
595 #[test]
596 fn should_default_confidence_to_max() {
597 assert_eq!(Confidence::default(), Confidence::MAX);
598 assert_eq!(Confidence::default().get(), 100);
599 }
600
601 #[test]
602 fn should_display_memories_with_system_prompt_and_bullets() {
603 let memories = Memories::new(vec![fixture("first"), fixture("second")], Some("Context:".into()));
604
605 assert_eq!(memories.to_string(), "Context:\n- first\n- second\n");
606 }
607
608 #[test]
609 fn should_display_memories_without_system_prompt_as_bullets_only() {
610 let memories = Memories::new(vec![fixture("only")], None);
611
612 assert_eq!(memories.to_string(), "- only\n");
613 }
614
615 #[test]
616 fn should_display_empty_memories_as_empty_string() {
617 let memories = Memories::new(Vec::new(), None);
618 assert_eq!(memories.to_string(), "");
619 }
620
621 #[test]
622 fn should_deref_memories_to_slice() {
623 let memories = Memories::new(vec![fixture("a"), fixture("b")], None);
624 assert_eq!(memories.len(), 2);
625 assert_eq!(memories[0].content, "a");
626 }
627
628 #[test]
629 fn should_default_event_at_to_none_in_fixture() {
630 let memory = fixture("hello");
631 assert!(
632 memory.event_at.is_none(),
633 "fixture default event_at must be None — most memories have no meaningful event-time"
634 );
635 }
636
637 #[test]
638 fn should_reject_scope_with_empty_agent_id() {
639 let scope = Scope {
640 agent_id: "".to_string(),
641 org_id: "o".to_string(),
642 user_id: "u".to_string(),
643 };
644 assert_eq!(scope.validate(), Err(ScopeError::Empty));
645 }
646
647 #[test]
648 fn should_reject_scope_with_empty_org_id() {
649 let scope = Scope {
650 agent_id: "a".to_string(),
651 org_id: "".to_string(),
652 user_id: "u".to_string(),
653 };
654 assert_eq!(scope.validate(), Err(ScopeError::Empty));
655 }
656
657 #[test]
658 fn should_reject_scope_with_empty_user_id() {
659 let scope = Scope {
660 agent_id: "a".to_string(),
661 org_id: "o".to_string(),
662 user_id: "".to_string(),
663 };
664 assert_eq!(scope.validate(), Err(ScopeError::Empty));
665 }
666
667 #[test]
668 fn should_accept_scope_with_all_non_empty_fields() {
669 let scope = Scope {
670 agent_id: "a".to_string(),
671 org_id: "o".to_string(),
672 user_id: "u".to_string(),
673 };
674 assert!(scope.validate().is_ok());
675 }
676}