Skip to main content

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}