ai_memory/models/namespace.rs
1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7/// Canonical `collective` scope spelling — referenced from `all_strs`,
8/// `from_str`, and `as_str` (#1558 batch 6).
9const COLLECTIVE: &str = "collective";
10/// `auto_atomise_mode` value for [`AutoAtomiseMode::Synchronous`] — shared
11/// with the CLI namespace policy template (#1558 batch 6).
12pub(crate) const AUTO_ATOMISE_SYNCHRONOUS: &str = "synchronous";
13
14/// Closed set of visibility scopes stamped into `metadata.scope` (Task 1.5).
15/// Controls which agents can see a memory via hierarchical namespace matching.
16/// Memories without a `scope` field are treated as `private` by the query layer.
17///
18/// **Migration in progress:** new call sites should construct
19/// [`MemoryScope`] directly and serialise via [`MemoryScope::as_str`].
20/// The const stays as the canonical string-validation surface for back-compat;
21/// the enum's [`MemoryScope::all_strs`] returns this exact slice so the two
22/// SSOTs stay in lockstep, pinned by
23/// `tests/memory_scope_count_invariant.rs`.
24pub const VALID_SCOPES: &[&str] = &["private", "team", "unit", "org", "collective"];
25
26/// v0.7.0 multi-agent literal-sweep (scanner B, finding F-B2.x) — typed
27/// closed-set discriminator for `memory.metadata.scope` (Task 1.5).
28/// Paired with the [`VALID_SCOPES`] string allowlist + the validator
29/// at `crate::validate::validate_scope`; the parity test
30/// `tests/memory_scope_count_invariant.rs` asserts both stay in
31/// lockstep.
32///
33/// `#[serde(rename_all = "snake_case")]` keeps the wire shape and the
34/// existing `metadata.scope` JSON column byte-identical to what every
35/// v0.6.x / v0.7.x writer already emits (`"private"`, `"team"`, etc.).
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
37#[serde(rename_all = "snake_case")]
38pub enum MemoryScope {
39 /// Memory is visible only to its owning agent within its own
40 /// namespace. The default for unmarked rows (per the query layer).
41 Private,
42 /// Memory is visible to every agent whose namespace falls within
43 /// the same team subtree. Subtree matching honours
44 /// `MAX_NAMESPACE_DEPTH`.
45 Team,
46 /// Memory is visible to every agent within the same unit subtree.
47 Unit,
48 /// Memory is visible to every agent within the same org subtree.
49 Org,
50 /// Memory is visible to every authenticated caller, regardless of
51 /// namespace.
52 Collective,
53}
54
55impl MemoryScope {
56 /// Total number of `MemoryScope` variants. SSOT for the
57 /// "5 visibility scopes at v0.7.0" narrative across docs.
58 /// Adding a new variant requires bumping this const, the
59 /// `VALID_SCOPES` slice, the [`Self::as_str`] / [`Self::from_str`]
60 /// match arms, and the visibility-policy dispatch in
61 /// `src/storage/mod.rs::is_visible`.
62 pub const COUNT: usize = 5;
63
64 /// Canonical enumeration in declaration order
65 /// (`private`, `team`, `unit`, `org`, `collective`). Use this
66 /// anywhere external code would otherwise hand-roll the list —
67 /// federation handshake, capability advertisement, parity tests.
68 #[must_use]
69 pub const fn all() -> &'static [Self; Self::COUNT] {
70 &[
71 Self::Private,
72 Self::Team,
73 Self::Unit,
74 Self::Org,
75 Self::Collective,
76 ]
77 }
78
79 /// String enumeration matching [`VALID_SCOPES`] byte-for-byte.
80 /// Parity-test-asserted against the `VALID_SCOPES` const.
81 #[must_use]
82 pub const fn all_strs() -> &'static [&'static str; Self::COUNT] {
83 &["private", "team", "unit", "org", COLLECTIVE]
84 }
85
86 /// Parse the string form stored in `metadata.scope`.
87 ///
88 /// Returns `None` for unknown values so callers can decide whether
89 /// to default to [`Self::Private`] (the query-layer convention for
90 /// unmarked rows) or surface a typed error.
91 #[must_use]
92 pub fn from_str(s: &str) -> Option<Self> {
93 match s {
94 "private" => Some(Self::Private),
95 "team" => Some(Self::Team),
96 "unit" => Some(Self::Unit),
97 "org" => Some(Self::Org),
98 COLLECTIVE => Some(Self::Collective),
99 _ => None,
100 }
101 }
102
103 /// Canonical wire string for this variant. Mirrors the `serde`
104 /// rename_all + the literals every existing call site already
105 /// writes to `metadata.scope`.
106 #[must_use]
107 pub const fn as_str(&self) -> &'static str {
108 match self {
109 Self::Private => "private",
110 Self::Team => "team",
111 Self::Unit => "unit",
112 Self::Org => "org",
113 Self::Collective => COLLECTIVE,
114 }
115 }
116}
117
118impl std::fmt::Display for MemoryScope {
119 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 f.write_str(self.as_str())
121 }
122}
123
124impl Default for MemoryScope {
125 fn default() -> Self {
126 Self::Private
127 }
128}
129
130impl std::str::FromStr for MemoryScope {
131 type Err = String;
132
133 fn from_str(s: &str) -> Result<Self, Self::Err> {
134 Self::from_str(s).ok_or_else(|| {
135 format!(
136 "invalid memory scope '{s}' (expected one of: {})",
137 VALID_SCOPES.join(", ")
138 )
139 })
140 }
141}
142
143/// Closed set of agent types. Extend carefully — values are persisted.
144pub const VALID_AGENT_TYPES: &[&str] = &[
145 "ai:claude-opus-4.6",
146 "ai:claude-opus-4.7",
147 "ai:codex-5.4",
148 "ai:grok-4.2",
149 "human",
150 "system",
151];
152
153/// Maximum number of path segments in a hierarchical namespace (Task 1.4).
154/// `alphaone/engineering/platform/team/squad/pod/role/agent` = 8 levels.
155pub const MAX_NAMESPACE_DEPTH: usize = 8;
156
157/// Number of `/`-delimited segments in a namespace path.
158///
159/// Flat namespaces (`"global"`, `"ai-memory"`) return `1`. An empty string
160/// returns `0`.
161///
162/// # Examples
163/// ```
164/// # use ai_memory::models::namespace_depth;
165/// assert_eq!(namespace_depth("global"), 1);
166/// assert_eq!(namespace_depth("alphaone/engineering"), 2);
167/// assert_eq!(namespace_depth("alphaone/engineering/platform"), 3);
168/// ```
169#[must_use]
170pub fn namespace_depth(ns: &str) -> usize {
171 if ns.is_empty() {
172 return 0;
173 }
174 ns.split('/').filter(|s| !s.is_empty()).count()
175}
176
177/// Parent of a hierarchical namespace, or `None` for flat / empty inputs.
178///
179/// Part of the Task 1.4 hierarchical-namespace API. Consumed by Tasks 1.5
180/// (visibility rules), 1.6 (N-level inheritance), 1.7 (vertical promotion),
181/// and 1.12 (hierarchy-aware recall).
182#[allow(dead_code)]
183///
184/// Parent of `"a/b/c"` is `"a/b"`. Parent of `"flat"` is `None` (a flat
185/// namespace has no parent). Parent of `""` is `None`.
186///
187/// # Examples
188/// ```
189/// # use ai_memory::models::namespace_parent;
190/// assert_eq!(namespace_parent("alphaone/engineering/platform"), Some("alphaone/engineering".to_string()));
191/// assert_eq!(namespace_parent("alphaone"), None);
192/// assert_eq!(namespace_parent(""), None);
193/// ```
194#[must_use]
195pub fn namespace_parent(ns: &str) -> Option<String> {
196 ns.rsplit_once('/').map(|(parent, _)| parent.to_string())
197}
198
199/// Ancestors of a namespace, ordered most-specific-first (including the
200/// namespace itself as the first element).
201///
202/// Part of the Task 1.4 hierarchical-namespace API. Consumed by Tasks 1.6
203/// (N-level rule inheritance) and 1.12 (hierarchy-aware recall scoring).
204#[allow(dead_code)]
205///
206/// For `"a/b/c"` returns `["a/b/c", "a/b", "a"]`. For a flat namespace
207/// returns a single-element vec containing the namespace. For an empty
208/// input returns an empty vec.
209///
210/// # Examples
211/// ```
212/// # use ai_memory::models::namespace_ancestors;
213/// assert_eq!(
214/// namespace_ancestors("alphaone/engineering/platform"),
215/// vec!["alphaone/engineering/platform", "alphaone/engineering", "alphaone"]
216/// );
217/// assert_eq!(namespace_ancestors("global"), vec!["global"]);
218/// assert!(namespace_ancestors("").is_empty());
219/// ```
220#[must_use]
221pub fn namespace_ancestors(ns: &str) -> Vec<String> {
222 if ns.is_empty() {
223 return Vec::new();
224 }
225 let mut out = Vec::with_capacity(namespace_depth(ns));
226 let mut current = ns.to_string();
227 loop {
228 out.push(current.clone());
229 match namespace_parent(¤t) {
230 Some(p) if !p.is_empty() => current = p,
231 _ => break,
232 }
233 }
234 out
235}
236
237/// The outcome of a governance check. Callers MAY execute on `Allow`,
238/// MUST reject on `Deny`, and SHOULD queue + return the `pending_id` on
239/// `Pending`.
240///
241/// `Deny` carries a typed [`crate::governance::GovernanceRefusal`] (issue
242/// #963 Phase 2). `Display` on the refusal produces the canonical wire
243/// shape `"<action> denied by governance: <reason>"`; the typed fields
244/// (`denied_level`, `namespace`, `owner`, `agent_id`) expose structured
245/// info that handlers can surface in HTTP / MCP / CLI responses. Pre-#963
246/// the variant was `Deny(String)` and only the human-readable wire
247/// message survived; callers needing the typed shape route through the
248/// envelope.
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub enum GovernanceDecision {
251 /// Allowed; proceed with the action.
252 Allow,
253 /// Denied; the typed refusal envelope carries the wire message + the
254 /// structured policy context that produced the refusal.
255 Deny(crate::governance::GovernanceRefusal),
256 /// Queued for approval; the caller receives the new `pending_id`.
257 Pending(String),
258}
259
260/// Actions that governance gates. Used as the `action_type` column value in
261/// `pending_actions` and as the discriminator for enforcement calls.
262///
263/// # Disambiguation (issue #970)
264///
265/// `GovernedAction` is the **approval-queue discriminator** —
266/// `pending_actions.action_type` and `enforce_governance` consult it
267/// to decide which substrate action is being approved. It is
268/// related-but-distinct from [`crate::governance::Op`], which is the
269/// **K9 permission-rule op discriminator**:
270///
271/// - `GovernedAction` wire strings: `"store"`, `"delete"`,
272/// `"promote"`, `"reflect"` (4 variants — the substrate actions
273/// that can be queued for approval).
274/// - `Op` wire strings: `"memory_store"`, `"memory_link"`,
275/// `"memory_delete"`, `"memory_archive"`, `"memory_consolidate"`,
276/// `"memory_replay"` (6 variants — every K9-gated tool, including
277/// ones that can never be queued for approval like
278/// `memory_replay`).
279///
280/// They look like the same vocabulary; they aren't. Consolidating
281/// would require breaking one of the two on-wire string sets. See
282/// `docs/internal/enum-proliferation-audit-970.md` for the full
283/// audit.
284#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
285#[serde(rename_all = "snake_case")]
286pub enum GovernedAction {
287 Store,
288 Delete,
289 Promote,
290 /// v0.7.0 L1-8: `memory_reflect` approval gate. Queued when
291 /// `GovernancePolicy::require_approval_above_depth` is set and the
292 /// proposed reflection depth exceeds the threshold.
293 Reflect,
294}
295
296impl GovernedAction {
297 #[must_use]
298 pub fn as_str(self) -> &'static str {
299 match self {
300 Self::Store => "store",
301 Self::Delete => "delete",
302 Self::Promote => "promote",
303 Self::Reflect => "reflect",
304 }
305 }
306}
307
308/// A single approval vote recorded on a consensus-gated pending action (Task 1.10).
309#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
310pub struct Approval {
311 pub agent_id: String,
312 pub approved_at: String,
313}
314
315/// Row returned by `db::list_pending_actions`.
316#[derive(Debug, Clone, Serialize, Deserialize)]
317pub struct PendingAction {
318 pub id: String,
319 pub action_type: String,
320 pub memory_id: Option<String>,
321 pub namespace: String,
322 pub payload: Value,
323 pub requested_by: String,
324 pub requested_at: String,
325 pub status: String,
326 #[serde(skip_serializing_if = "Option::is_none")]
327 pub decided_by: Option<String>,
328 #[serde(skip_serializing_if = "Option::is_none")]
329 pub decided_at: Option<String>,
330 /// Task 1.10: consensus vote log. Empty for Human/Agent paths.
331 #[serde(default)]
332 pub approvals: Vec<Approval>,
333}
334
335/// v0.6.2 (S34): a pending-action decision (approve / reject) the originating
336/// node wants propagated to peers so callers on any peer see consistent state
337/// (approve/reject on node-2 → decision must reach node-1 etc.).
338///
339/// Shipped as an additive `sync_push.pending_decisions` field. Peers apply
340/// via `db::decide_pending_action`; already-decided rows are a no-op.
341#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct PendingDecision {
343 pub id: String,
344 pub approved: bool,
345 pub decider: String,
346}
347
348/// v0.6.2 (S35): a namespace-standard metadata row the originating node wants
349/// propagated to peers. `set_namespace_standard` writes to `namespace_meta`
350/// locally; without federation, a peer sees the standard memory (fanned out
351/// via `broadcast_store_quorum`) but not the `(namespace, standard_id,
352/// parent_namespace)` tuple, so inheritance-chain walks on the peer fall
353/// back to `auto_detect_parent` and can miss an explicit parent link.
354///
355/// Shipped as an additive `sync_push.namespace_meta` field. Peers apply
356/// via `db::set_namespace_standard(conn, namespace, standard_id,
357/// parent_namespace.as_deref())`.
358#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct NamespaceMetaEntry {
360 pub namespace: String,
361 pub standard_id: String,
362 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub parent_namespace: Option<String>,
364 #[serde(default)]
365 pub updated_at: String,
366}
367
368/// Who is permitted to perform a governed action.
369///
370/// Stored inside a namespace standard's `metadata.governance` and consulted
371/// by Task 1.9 (enforcement) + Task 1.10 (approver types). Task 1.8 only
372/// defines the shape + validation — no runtime enforcement yet.
373#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
374#[serde(rename_all = "snake_case")]
375pub enum GovernanceLevel {
376 /// Any caller may perform the action (no gate).
377 Any,
378 /// Caller must be a registered agent (see Task 1.3 `_agents` namespace).
379 Registered,
380 /// Only the memory's original `metadata.agent_id` owner may perform the action.
381 Owner,
382 /// Action requires explicit approval by an `ApproverType` (handled in 1.9 + 1.10).
383 Approve,
384}
385
386impl GovernanceLevel {
387 /// Human-readable tag used by logs and error messages.
388 /// Consumed by Task 1.9 enforcement path.
389 #[allow(dead_code)]
390 #[must_use]
391 pub fn as_str(&self) -> &'static str {
392 match self {
393 Self::Any => "any",
394 Self::Registered => "registered",
395 Self::Owner => "owner",
396 Self::Approve => "approve",
397 }
398 }
399}
400
401/// Who approves actions gated by [`GovernanceLevel::Approve`].
402///
403/// Serialized representation (externally-tagged, `snake_case`):
404///
405/// - [`Self::Human`] → `"human"`
406/// - [`Self::Agent`] → `{"agent": "alice"}`
407/// - [`Self::Consensus`] → `{"consensus": 3}`
408#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
409#[serde(rename_all = "snake_case")]
410pub enum ApproverType {
411 /// Human approval required (interactive or out-of-band).
412 Human,
413 /// Specific registered agent must approve, identified by `agent_id`.
414 Agent(String),
415 /// Consensus of N approvers (any mix of human/agent registrations).
416 Consensus(u32),
417}
418
419impl ApproverType {
420 /// Discriminator tag for logs / telemetry.
421 /// Consumed by Task 1.10 approver-types path.
422 #[allow(dead_code)]
423 #[must_use]
424 pub fn kind(&self) -> &'static str {
425 match self {
426 Self::Human => "human",
427 Self::Agent(_) => "agent",
428 Self::Consensus(_) => "consensus",
429 }
430 }
431}
432
433/// Governance policy attached to a namespace's standard memory
434/// (stored in `metadata.governance`).
435///
436/// Default policy when a standard has no `metadata.governance`:
437/// `{ write: Any, promote: Any, delete: Owner, approver: Human, inherit: true }`.
438///
439/// v0.6.2 (S34 defensive): `promote`, `delete`, and `approver` carry
440/// `#[serde(default)]` so partial-policy payloads (a common shape for
441/// operator CLIs / test harnesses that only care about `write`) round-trip
442/// instead of 400-ing out on missing fields. `write` remains required —
443/// it's the core knob a policy is attempting to set.
444///
445/// v0.6.3.1 (P4, audit G1): `inherit` controls whether parent-namespace
446/// policies bubble up. Default `true` matches the architecture page T2
447/// promise of "Hierarchical policy inheritance (default at `org/`,
448/// overridable at `org/team/`)". Setting `inherit: false` on a child
449/// stops the leaf-first walk in `resolve_governance_policy`, providing
450/// an explicit opt-out path for scoped overrides (e.g. an audit
451/// sandbox under a fully-governed parent).
452///
453/// # #880 / #793 PR-3 — decomposition (2026-05-18)
454///
455/// Pre-#880 the struct carried 20 flat fields. Adding any new field
456/// forced a 50-site struct-literal cascade across `src/` + `tests/`
457/// (the surface this issue closes). Post-#880 the same 20 fields are
458/// grouped into 7 per-concern sub-structs and re-attached to the
459/// parent via `#[serde(flatten)]`. The composite still carries every
460/// field, so the wire-format / TOML / `metadata.governance` JSON
461/// shape is unchanged (pinned by
462/// `tests/governance_policy_wire_compat.rs`). Each existing field
463/// is still reachable via the new `policy.core.write`,
464/// `policy.atomisation.auto_atomise`, etc. paths, and every
465/// `effective_*` accessor on the parent struct delegates to the
466/// matching sub-struct so the rest of the codebase that calls
467/// `policy.effective_max_reflection_depth()` is unchanged.
468///
469/// Adding a new policy knob now means:
470/// 1. Pick the right sub-struct under [`CorePolicy`] /
471/// [`AtomisationPolicy`] / etc.
472/// 2. Add the field (with `#[serde(default, skip_serializing_if = "Option::is_none")]`).
473/// 3. Add the field to the sub-struct's `Default` impl.
474///
475/// No literal-site cascade. The `..Default::default()` pattern used
476/// at every construction site picks up the new field automatically.
477#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
478pub struct GovernancePolicy {
479 /// Access-control + inheritance + reflection-depth — the
480 /// load-bearing K9/K10 governance knobs. See [`CorePolicy`].
481 #[serde(flatten)]
482 pub core: CorePolicy,
483 /// WT-1-D + Form 2 atomisation knobs. See [`AtomisationPolicy`].
484 #[serde(flatten)]
485 pub atomisation: AtomisationPolicy,
486 /// Form 1 synthesis curator knobs + legacy per-pair opt-in. See
487 /// [`SynthesisPolicy`].
488 #[serde(flatten)]
489 pub synthesis: SynthesisPolicy,
490 /// Form 3 multistep-ingest prompt sizing knobs. See
491 /// [`MultistepPolicy`].
492 #[serde(flatten)]
493 pub multistep: MultistepPolicy,
494 /// Form 6 memory-kind auto-classifier knobs. See
495 /// [`KindClassificationPolicy`].
496 #[serde(flatten)]
497 pub kind_class: KindClassificationPolicy,
498 /// QW-2 persona auto-regeneration cadence + file-backed export
499 /// knobs. See [`PersonaPolicy`].
500 #[serde(flatten)]
501 pub persona: PersonaPolicy,
502 /// QW-1 reflection-export knob. See [`ExportPolicy`].
503 #[serde(flatten)]
504 pub export: ExportPolicy,
505}
506
507/// #880 — access-control + inheritance + reflection-depth sub-struct
508/// of [`GovernancePolicy`]. Every field is flattened back into the
509/// parent on the wire so `metadata.governance` JSON / TOML configs
510/// remain byte-identical to the pre-#880 flat layout.
511#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
512pub struct CorePolicy {
513 pub write: GovernanceLevel,
514 #[serde(default = "default_promote_level")]
515 pub promote: GovernanceLevel,
516 #[serde(default = "default_delete_level")]
517 pub delete: GovernanceLevel,
518 #[serde(default = "default_approver")]
519 pub approver: ApproverType,
520 /// v0.6.3.1 (P4, G1): when `true` (default), missing policy at a
521 /// child namespace falls through to the parent in the chain. When
522 /// `false`, the walk stops at this level — child operations are
523 /// gated by THIS policy and parents are not consulted. Backfilled
524 /// to `true` on existing rows by migration `0012_governance_inherit`
525 /// to preserve the architecturally-promised semantics.
526 #[serde(default = "default_inherit")]
527 pub inherit: bool,
528 /// v0.7.0 recursive-learning Task 2/8 (issue #655): per-namespace
529 /// substrate-side cap on `Memory::reflection_depth` at the
530 /// `memory_reflect` MCP write path (enforcement lands in Task 5/8).
531 /// `None` → no override, fall back to the compiled default exposed
532 /// by [`GovernancePolicy::effective_max_reflection_depth`].
533 /// `Some(0)` is the disable-all-reflections sentinel (see accessor
534 /// doc-comment). Persisted inside the existing namespace standard's
535 /// `metadata.governance` JSON blob; no SQL schema migration is
536 /// required because the column is already a `TEXT`/`JSONB`
537 /// payload on both SQLite and Postgres. Pre-v0.7.0 rows that
538 /// omit this key deserialize as `None` via `#[serde(default)]`,
539 /// and `skip_serializing_if` keeps the absent shape on the wire
540 /// for fresh policies — matching how `NamespaceMetaEntry::parent_namespace`
541 /// stays absent on the wire to keep replication / federation
542 /// payloads byte-identical for legacy peers.
543 #[serde(default, skip_serializing_if = "Option::is_none")]
544 pub max_reflection_depth: Option<u32>,
545}
546
547impl Default for CorePolicy {
548 fn default() -> Self {
549 Self {
550 write: GovernanceLevel::Any,
551 promote: default_promote_level(),
552 delete: default_delete_level(),
553 approver: default_approver(),
554 inherit: default_inherit(),
555 max_reflection_depth: None,
556 }
557 }
558}
559/// #880 — QW-1 reflection-export sub-struct of [`GovernancePolicy`].
560/// Single-field cluster preserved as its own sub-struct so future
561/// reflection-side knobs (e.g. a v0.8 retention sweep) land here
562/// without churning literal sites.
563#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
564pub struct ExportPolicy {
565 /// v0.7.0 QW-1 — when `Some(true)`, the `post_reflect` substrate
566 /// hook deferred-spawns a filesystem write of the reflection
567 /// markdown to `~/.ai-memory/reflections/<namespace>/<id>.md` so
568 /// operators can `cat` the reflection chain without learning SQL.
569 /// Inherits via the same leaf-first ancestor walk as every other
570 /// field on this struct (G1 governance). `None` / `Some(false)`
571 /// keeps the substrate quiet — the canonical reflection is the
572 /// SQL row, never the file. `skip_serializing_if = "Option::is_none"`
573 /// keeps the absent shape on the wire for pre-QW-1 federation
574 /// peers (no payload-byte drift, no replication regressions).
575 #[serde(default, skip_serializing_if = "Option::is_none")]
576 pub auto_export_reflections_to_filesystem: Option<bool>,
577}
578
579/// #880 — WT-1-D + Form 2 atomisation sub-struct of
580/// [`GovernancePolicy`]. Groups the five atomisation knobs so a new
581/// Form 2 / Cluster-F knob lands on this struct without cascading
582/// through every literal site.
583#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
584pub struct AtomisationPolicy {
585 /// v0.7.0 WT-1-D — when `Some(true)`, the `pre_store` substrate
586 /// hook (`AutoAtomisationHook`) deferred-enqueues a curator pass
587 /// on the stored memory if its body exceeds
588 /// `auto_atomise_threshold_cl100k`. Inherits leaf-first via the
589 /// namespace chain (same walk as every other field). `None` /
590 /// `Some(false)` keeps the substrate quiet; the operator opts in
591 /// per-namespace by setting this to `Some(true)` on the namespace
592 /// standard's `metadata.governance` blob. `skip_serializing_if`
593 /// keeps absent-on-wire for pre-WT-1-D federation peers (zero
594 /// replication drift).
595 #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub auto_atomise: Option<bool>,
597 /// v0.7.0 WT-1-D — cl100k_base token threshold over which a
598 /// `memory_store` triggers the auto-atomisation curator pass.
599 /// `None` defers to the compiled default (500). Resolved via the
600 /// same leaf-first inheritance walk; a child `None` inherits the
601 /// nearest ancestor's explicit `Some(n)`.
602 #[serde(default, skip_serializing_if = "Option::is_none")]
603 pub auto_atomise_threshold_cl100k: Option<u32>,
604 /// v0.7.0 WT-1-D — per-atom token budget passed to the curator
605 /// when the auto-atomisation hook fires. `None` defers to the
606 /// compiled default (200, matching `AtomiserConfig::default_max_atom_tokens`).
607 /// Resolved via the same leaf-first inheritance walk.
608 #[serde(default, skip_serializing_if = "Option::is_none")]
609 pub auto_atomise_max_atom_tokens: Option<u32>,
610 /// v0.7.0 Cluster-F (issue #767, PERF-5) — per-namespace override
611 /// for the curator retry budget used by the
612 /// **Synchronous** `pre_store` auto-atomise path. `None` defers to
613 /// the compiled default `AtomiserConfig::sync_curator_max_retries`
614 /// (1 — chosen to keep the operator's `memory_store` latency
615 /// envelope tight; the deferred path keeps the full 3-retry
616 /// budget because it runs on a detached worker thread).
617 ///
618 /// Operators who need higher resilience on a specific
619 /// Synchronous-mode namespace (at the cost of a longer
620 /// worst-case envelope) raise this explicitly. Resolved via the
621 /// same leaf-first inheritance walk as every other field on this
622 /// struct.
623 #[serde(default, skip_serializing_if = "Option::is_none")]
624 pub auto_atomise_max_retries: Option<u32>,
625 /// v0.7.x Form 2 (Batman framework) — atomisation execution mode.
626 ///
627 /// - `None` / `Some(Off)` → no atomisation occurs (overrides any
628 /// `auto_atomise` flag).
629 /// - `Some(Deferred)` → legacy WT-1-D behaviour: curator runs on a
630 /// detached worker thread AFTER `memory_store` returns. Source
631 /// is embedded as one blob before the curator round-trip lands.
632 /// - `Some(Synchronous)` → Form 2 alignment: SKIP source embedding,
633 /// run the curator synchronously inside `memory_store`, atoms get
634 /// their normal embed-on-insert path, source is archived with
635 /// `atomised_into > 0` BEFORE the response returns.
636 ///
637 /// Backward compatibility: when this field is absent and
638 /// `auto_atomise = Some(true)` is set, the resolver implicitly maps
639 /// to `Some(Deferred)` so v0.7.0 pre-Form-2 deployments keep their
640 /// existing behaviour. See
641 /// [`GovernancePolicy::effective_auto_atomise_mode`].
642 #[serde(default, skip_serializing_if = "Option::is_none")]
643 pub auto_atomise_mode: Option<AutoAtomiseMode>,
644}
645
646/// #880 — QW-2 persona auto-regeneration + file-backed export
647/// sub-struct of [`GovernancePolicy`].
648#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
649pub struct PersonaPolicy {
650 /// v0.7.0 QW-2 — auto-regenerate the Persona artefact for an
651 /// entity every N writes to a same-entity Reflection memory.
652 /// `None` (default) disables the cadence — operators trigger
653 /// regeneration explicitly via `memory_persona_generate` or
654 /// `ai-memory persona <entity_id> --regenerate`. Inherits via
655 /// the same leaf-first ancestor walk as every other field on
656 /// this struct (G1 governance). `skip_serializing_if` keeps
657 /// the absent shape on the wire for pre-QW-2 federation peers.
658 #[serde(default, skip_serializing_if = "Option::is_none")]
659 pub auto_persona_trigger_every_n_memories: Option<u32>,
660 /// v0.7.0 QW-2 companion to
661 /// `auto_export_reflections_to_filesystem` — when `Some(true)`,
662 /// the substrate writes generated Personas to
663 /// `~/.ai-memory/personas/<namespace>/<entity_id>.md` so
664 /// operators can `cat` the persona without learning SQL. The
665 /// canonical persona is the SQL row; the file is a derived
666 /// artefact. `None` / `Some(false)` keeps the substrate quiet.
667 #[serde(default, skip_serializing_if = "Option::is_none")]
668 pub auto_export_personas_to_filesystem: Option<bool>,
669}
670
671/// #880 — Form 1 synthesis curator + legacy per-pair classifier
672/// sub-struct of [`GovernancePolicy`].
673#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
674pub struct SynthesisPolicy {
675 /// v0.7.x Form 1 (Batman framework) — opt-IN to the legacy per-pair
676 /// yes/no contradiction classifier on the store path. Default
677 /// (`None` / `Some(false)`) routes through the new single-batch
678 /// action-emitting synthesiser. Operators who depend on the old
679 /// metadata-only `confirmed_contradictions` behaviour set this to
680 /// `Some(true)` per-namespace.
681 #[serde(default, skip_serializing_if = "Option::is_none")]
682 pub legacy_per_pair_classifier: Option<bool>,
683 /// v0.7.0 Cluster-B (issue #767) — per-namespace knob controlling
684 /// what happens when the Form 1 synthesis curator call fails (LLM
685 /// down, malformed JSON, validation failure, etc.).
686 ///
687 /// * `None` / `Some(FallThrough)` (default) — preserve the v0.7.0
688 /// pre-cluster-B behaviour: log a warning, swallow the error,
689 /// continue with the legacy dedup-merge / insert path. Backward
690 /// compatible.
691 /// * `Some(BlockWrite)` — refuse the write with a typed error so
692 /// the caller knows the synthesis layer failed and the substrate
693 /// did not silently fall through to a different code path. Use
694 /// on namespaces where the synthesis verdict is operationally
695 /// load-bearing (e.g. a fact-base where duplicate writes are
696 /// not tolerable).
697 ///
698 /// Synthesis is a QUALITY gate, not a SECURITY gate — the K9 / K10
699 /// governance pipeline remains the security surface even under
700 /// `BlockWrite`. This knob simply lets operators choose whether a
701 /// curator outage degrades silently or surfaces loudly.
702 #[serde(default, skip_serializing_if = "Option::is_none")]
703 pub synthesis_failure_mode: Option<SynthesisFailureMode>,
704 /// v0.7.0 Cluster-B (issue #767, SEC-1) — per-namespace cap on the
705 /// number of `delete` verdicts a single synthesis batch may apply
706 /// without an explicit K10 approval flow.
707 ///
708 /// Default `None` resolves to **1**, matching the principle of
709 /// least authority: a single LLM round-trip should not be able to
710 /// purge many candidates from the namespace in a silent batch. A
711 /// verdict exceeding the cap is refused at the substrate boundary;
712 /// the audit-honest event `synthesis.refused_unbounded_delete`
713 /// fires at WARN level.
714 ///
715 /// Operators who need a higher cap (e.g. a corpus where mass
716 /// dedupe is a normal substrate task) raise this explicitly. The
717 /// security pipeline (K9 per-delete recheck) still runs regardless.
718 #[serde(default, skip_serializing_if = "Option::is_none")]
719 pub synthesis_max_deletes_per_call: Option<u32>,
720 /// v0.7.0 Cluster-B (issue #767, PERF-7) — per-candidate cap on
721 /// the number of characters of `content` inlined into the
722 /// synthesis prompt. A huge candidate (e.g. a 50KB note) otherwise
723 /// inflates the prompt unboundedly and inflates LLM cost.
724 ///
725 /// Default `None` resolves to **1500** characters (~400 tokens at
726 /// the cl100k average). The truncation only affects what the LLM
727 /// sees; the stored row is untouched. A truncation event records
728 /// the byte budget in the `synthesis_prompt_size_chars` telemetry
729 /// counter so operators can observe whether the cap matters in
730 /// production.
731 #[serde(default, skip_serializing_if = "Option::is_none")]
732 pub synthesis_max_candidate_chars: Option<u32>,
733}
734
735/// #880 — Form 6 memory-kind auto-classifier sub-struct of
736/// [`GovernancePolicy`].
737#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
738pub struct KindClassificationPolicy {
739 /// v0.7.x Form 6 (issue #759) — auto-classify a stored memory's
740 /// `MemoryKind` from its content via the substrate-side
741 /// `pre_store::auto_classify_kind` hook. One of:
742 /// * `Off` (default) — keeps the substrate quiet; the
743 /// caller-supplied kind (or the SQL `DEFAULT 'observation'`)
744 /// stands.
745 /// * `RegexOnly` — deterministic regex heuristics (e.g.
746 /// "is_a" → Concept; "happened on" → Event;
747 /// "X says:" → Conversation). No LLM round-trip; tens of
748 /// microseconds per call.
749 /// * `RegexThenLlm` — regex first; if low-confidence (no
750 /// heuristic fired or multiple fired with conflict), fall
751 /// through to a single-shot LLM classifier. Opt-in only;
752 /// the substrate never spawns an LLM round-trip on a
753 /// namespace whose policy is `Off`.
754 /// Caller-supplied `memory_kind` always wins — the hook only
755 /// fills in `Observation` (the default) when no kind was set.
756 #[serde(default, skip_serializing_if = "Option::is_none")]
757 pub auto_classify_kind: Option<MemoryKindAutoClassify>,
758}
759
760/// #880 — Form 3 multistep-ingest prompt sizing sub-struct of
761/// [`GovernancePolicy`].
762#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
763pub struct MultistepPolicy {
764 /// v0.7.0 Cluster v0.7-polish (issue #782, PERF-11) — per-namespace
765 /// cap on the number of characters of `content` inlined into a Form
766 /// 3 multistep-ingest LLM-stage prompt. Form 3's deterministic
767 /// helper stages already receive the content by **borrow**, so the
768 /// cap only affects LLM stages where the content is actually
769 /// templated into the prompt body.
770 ///
771 /// Default `None` resolves to **1500** characters (~400 tokens at
772 /// the cl100k average) — the same cap Cluster B settled on for the
773 /// synthesis prompt cap (PERF-7). The two caps are independent
774 /// knobs so operators can tune the synthesis and multistep paths
775 /// separately, but the shared default keeps reasoning about prompt
776 /// budgets straightforward.
777 ///
778 /// The truncation only affects what the LLM sees; the helper
779 /// payloads, helper-stage inputs, and the caller-visible final
780 /// output are untouched.
781 #[serde(default, skip_serializing_if = "Option::is_none")]
782 pub multistep_max_content_chars: Option<u32>,
783}
784
785/// v0.7.x Form 2 — atomisation execution mode. Stored inside
786/// [`GovernancePolicy::auto_atomise_mode`].
787///
788/// The mode interacts with `auto_atomise` (the boolean enable flag)
789/// during resolution:
790///
791/// | `auto_atomise` | `auto_atomise_mode` | Effective behaviour |
792/// |----------------|---------------------|---------------------|
793/// | `None` / `false` | any | Off (no atomisation) |
794/// | `Some(true)` | `None` | Deferred (legacy WT-1-D) |
795/// | `Some(true)` | `Some(Off)` | Off (explicit disable wins) |
796/// | `Some(true)` | `Some(Deferred)` | Deferred (explicit) |
797/// | `Some(true)` | `Some(Synchronous)` | Synchronous (Form 2 path) |
798#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
799#[serde(rename_all = "snake_case")]
800pub enum AutoAtomiseMode {
801 /// No atomisation. Equivalent to `auto_atomise = false`.
802 Off,
803 /// Legacy WT-1-D behaviour: source embedded first, atomiser runs
804 /// on a detached worker thread.
805 Deferred,
806 /// Form 2 alignment: source embed is skipped, atomiser runs
807 /// synchronously inside `memory_store`, source is archived with
808 /// `atomised_into > 0` before the response returns. Atoms get
809 /// their normal embed-on-insert path.
810 Synchronous,
811}
812
813impl AutoAtomiseMode {
814 /// Telemetry label.
815 #[must_use]
816 pub fn as_str(self) -> &'static str {
817 match self {
818 Self::Off => "off",
819 Self::Deferred => "deferred",
820 Self::Synchronous => AUTO_ATOMISE_SYNCHRONOUS,
821 }
822 }
823}
824
825/// v0.7.0 Cluster-B (issue #767) — per-namespace enum for the
826/// Form 1 synthesis-failure policy. See
827/// [`GovernancePolicy::synthesis_failure_mode`].
828#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
829#[serde(rename_all = "snake_case")]
830pub enum SynthesisFailureMode {
831 /// Default — log + swallow + continue with the legacy dedup-merge
832 /// / insert path. Backward-compatible with the v0.7.0 ship.
833 #[default]
834 FallThrough,
835 /// Refuse the write with a typed error so callers observe the
836 /// curator outage instead of inheriting silent fallback behaviour.
837 BlockWrite,
838}
839
840impl SynthesisFailureMode {
841 /// Telemetry label.
842 #[must_use]
843 pub fn as_str(self) -> &'static str {
844 match self {
845 Self::FallThrough => "fall_through",
846 Self::BlockWrite => "block_write",
847 }
848 }
849}
850
851/// v0.7.x Form 6 — namespace-policy enum for the
852/// `pre_store::auto_classify_kind` substrate hook. See
853/// [`GovernancePolicy::auto_classify_kind`].
854#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
855#[serde(rename_all = "snake_case")]
856pub enum MemoryKindAutoClassify {
857 /// Substrate quiet — caller-supplied (or default `Observation`)
858 /// kind stands. The hook is a zero-cost no-op.
859 #[default]
860 Off,
861 /// Deterministic regex-based heuristics only. No LLM round-trip.
862 RegexOnly,
863 /// Regex first; if no heuristic fires (or multiple fire with
864 /// conflicting verdicts), fall through to a single-shot LLM
865 /// classifier. Opt-in only.
866 RegexThenLlm,
867}
868
869fn default_promote_level() -> GovernanceLevel {
870 GovernanceLevel::Any
871}
872
873fn default_delete_level() -> GovernanceLevel {
874 GovernanceLevel::Owner
875}
876
877fn default_approver() -> ApproverType {
878 ApproverType::Human
879}
880
881/// v0.6.3.1 (P4): default for `GovernancePolicy::inherit`. Inheritance
882/// is the documented default — see architecture page T2 and audit G1.
883fn default_inherit() -> bool {
884 true
885}
886
887// #880 — `Default` for `GovernancePolicy` is derived now: each
888// sub-struct's own `Default` returns "no per-namespace override" so
889// every `effective_*` accessor falls through to the compiled-in
890// default. The old hand-written impl is preserved verbatim in
891// `CorePolicy::default()` (write=Any, promote=Any, delete=Owner,
892// approver=Human, inherit=true, max_reflection_depth=None) and the
893// six secondary policy structs `Default::default()` to the
894// all-`None` shape that pre-#880 callers expected.
895
896impl GovernancePolicy {
897 /// Parse a policy out of a `metadata.governance` JSON value. Returns
898 /// `None` when the field is missing/null. Parse errors propagate so
899 /// callers can surface them to the user instead of silently defaulting.
900 pub fn from_metadata(metadata: &Value) -> Option<Result<Self, serde_json::Error>> {
901 let gov = metadata.get(crate::META_KEY_GOVERNANCE)?;
902 if gov.is_null() {
903 return None;
904 }
905 Some(serde_json::from_value(gov.clone()))
906 }
907
908 /// NHI-P4-T19 (v0.7.0 NHI testing): default policy for namespaces
909 /// that have a standard set but no explicit `metadata.governance`.
910 /// Differs from [`Default::default`] (write=Any) by tightening
911 /// `write` to `Owner` — calling `memory_namespace_set_standard`
912 /// implies the operator wants enforcement, not advisory-only.
913 /// Operators who want write=Any must set it explicitly in the
914 /// standard memory's metadata. Tested in
915 /// `db::tests::namespace_set_standard_default_write_is_owner`.
916 #[must_use]
917 pub fn default_for_managed_namespace() -> Self {
918 // #880 — every sub-struct defaults to "no override" so the
919 // bootstrap policy only differs from `Default::default()` by
920 // tightening `core.write` to `Owner`.
921 Self {
922 core: CorePolicy {
923 write: GovernanceLevel::Owner,
924 ..CorePolicy::default()
925 },
926 ..Self::default()
927 }
928 }
929
930 /// v0.7.0 recursive-learning Task 2/8 (issue #655): resolve the
931 /// per-namespace reflection-depth cap. Returns the operator's
932 /// override when present, otherwise the compiled-in default of
933 /// `3`.
934 ///
935 /// **Why 3?** Bounds recursion (reflection-on-reflection-on-…)
936 /// without strangling the legitimate "reflection-on-reflection"
937 /// chains the v0.8.0 Pillar 2.5 curator mode will lean on.
938 /// Operators who want a different global default should change
939 /// the constant in this accessor; per-namespace overrides should
940 /// stay in the JSON metadata blob.
941 ///
942 /// **`Some(0)` disables reflection entirely.** Task 5/8 enforces
943 /// the rule `proposed_reflection_depth >= cap → refuse`, so a
944 /// cap of `0` refuses every reflection (no depth `>= 0` passes
945 /// the comparison). This is the documented kill-switch for a
946 /// namespace that should never accept reflection writes.
947 ///
948 /// Ancestor inheritance is **not** walked here — that's the job
949 /// of `db::resolve_governance_policy` (and the equivalent store
950 /// trait method), which returns the most-specific policy via the
951 /// leaf-first namespace chain walk. Callers at the
952 /// `memory_reflect` MCP write path resolve the policy first,
953 /// then call this accessor on the result.
954 #[must_use]
955 pub fn effective_max_reflection_depth(&self) -> u32 {
956 self.core.max_reflection_depth.unwrap_or(3)
957 }
958
959 /// v0.7.0 QW-1 — resolve the file-backed-export policy. Returns
960 /// `false` (substrate stays SQL-canonical) when the namespace has
961 /// no explicit override. `Some(true)` opts the namespace into the
962 /// deferred filesystem write in the substrate `post_reflect` hook.
963 ///
964 /// Inheritance is **not** walked here — the caller resolves the
965 /// most-specific policy via `resolve_governance_policy` and then
966 /// queries this accessor on the result, mirroring how
967 /// `effective_max_reflection_depth` is consumed.
968 #[must_use]
969 pub fn effective_auto_export_reflections_to_filesystem(&self) -> bool {
970 self.export
971 .auto_export_reflections_to_filesystem
972 .unwrap_or(false)
973 }
974
975 /// v0.7.0 WT-1-D — resolve the auto-atomisation enable flag.
976 /// Returns `false` (substrate stays quiet) when the namespace has
977 /// no explicit override. `Some(true)` opts the namespace into the
978 /// `pre_store` substrate hook's deferred curator-pass enqueue.
979 ///
980 /// Inheritance is **not** walked here — the caller resolves the
981 /// most-specific policy via `resolve_governance_policy` and then
982 /// queries this accessor on the result, mirroring how
983 /// `effective_max_reflection_depth` is consumed.
984 #[must_use]
985 pub fn effective_auto_atomise(&self) -> bool {
986 self.atomisation.auto_atomise.unwrap_or(false)
987 }
988
989 /// v0.7.0 WT-1-D — resolve the cl100k token threshold above which
990 /// the auto-atomisation hook fires. Compiled default is **500**;
991 /// matches the WT-1-D brief (memories ≤ 500 tokens are short
992 /// enough to live as a single observation).
993 #[must_use]
994 pub fn effective_auto_atomise_threshold_cl100k(&self) -> u32 {
995 self.atomisation
996 .auto_atomise_threshold_cl100k
997 .unwrap_or(500)
998 }
999
1000 /// v0.7.0 WT-1-D — resolve the per-atom token budget for the
1001 /// auto-atomisation curator pass. Compiled default is **200**;
1002 /// matches `AtomiserConfig::default_max_atom_tokens` so the
1003 /// hook-driven path produces atoms indistinguishable from
1004 /// CLI/MCP-driven atomisation.
1005 #[must_use]
1006 pub fn effective_auto_atomise_max_atom_tokens(&self) -> u32 {
1007 self.atomisation.auto_atomise_max_atom_tokens.unwrap_or(200)
1008 }
1009
1010 /// v0.7.0 Cluster-F PERF-5 — resolve the Synchronous-mode
1011 /// curator retry budget. Returns `None` when the namespace has
1012 /// no explicit override; the caller threads this through
1013 /// `Atomiser::atomise_sync_with_retries` and falls back to
1014 /// `AtomiserConfig::sync_curator_max_retries` (compiled default 1)
1015 /// when `None`. Documented in `docs/atomisation.md` alongside the
1016 /// Synchronous-mode latency envelope.
1017 #[must_use]
1018 pub fn effective_auto_atomise_max_retries(&self) -> Option<u32> {
1019 self.atomisation.auto_atomise_max_retries
1020 }
1021
1022 /// v0.7.0 QW-2 — resolve the auto-persona regeneration cadence.
1023 /// Returns `None` (cadence disabled) when the namespace has no
1024 /// explicit override; `Some(N)` opts the namespace into deferred
1025 /// persona regeneration every N writes against an entity. The
1026 /// `post_store` hook reads this accessor on the resolved policy
1027 /// after walking the leaf-first ancestor chain.
1028 #[must_use]
1029 pub fn effective_auto_persona_trigger_every_n_memories(&self) -> Option<u32> {
1030 self.persona.auto_persona_trigger_every_n_memories
1031 }
1032
1033 /// v0.7.0 QW-2 — resolve the file-backed-export policy for
1034 /// Persona-kind memories. Returns `false` (substrate stays
1035 /// SQL-canonical) when the namespace has no explicit override.
1036 /// Symmetric with
1037 /// [`Self::effective_auto_export_reflections_to_filesystem`].
1038 #[must_use]
1039 pub fn effective_auto_export_personas_to_filesystem(&self) -> bool {
1040 self.persona
1041 .auto_export_personas_to_filesystem
1042 .unwrap_or(false)
1043 }
1044
1045 /// v0.7.x Form 2 — resolve the atomisation execution mode.
1046 ///
1047 /// Resolution rules (matches the table on
1048 /// [`AutoAtomiseMode`]):
1049 ///
1050 /// 1. `auto_atomise_mode = Some(mode)` wins — operator explicit.
1051 /// 2. Otherwise `auto_atomise = Some(true)` → [`AutoAtomiseMode::Deferred`]
1052 /// (preserves pre-Form-2 deployments verbatim).
1053 /// 3. Otherwise [`AutoAtomiseMode::Off`].
1054 ///
1055 /// Both `Off` returns and an `Off` explicit override short-circuit
1056 /// the `pre_store` hook chain entirely.
1057 #[must_use]
1058 pub fn effective_auto_atomise_mode(&self) -> AutoAtomiseMode {
1059 if let Some(m) = self.atomisation.auto_atomise_mode {
1060 return m;
1061 }
1062 if self.atomisation.auto_atomise.unwrap_or(false) {
1063 AutoAtomiseMode::Deferred
1064 } else {
1065 AutoAtomiseMode::Off
1066 }
1067 }
1068
1069 /// v0.7.x Form 1 — resolve the legacy per-pair classifier opt-in.
1070 /// Returns `false` (default) when absent or `Some(false)`, routing
1071 /// the substrate through the new single-batch action-emitting
1072 /// synthesiser. `Some(true)` keeps the legacy per-pair binary
1073 /// contradiction call (metadata-only outcome) for operators who
1074 /// depend on the v0.6.x behaviour.
1075 #[must_use]
1076 pub fn effective_legacy_per_pair_classifier(&self) -> bool {
1077 self.synthesis.legacy_per_pair_classifier.unwrap_or(false)
1078 }
1079
1080 /// v0.7.0 Cluster-B (issue #767) — resolve the synthesis-failure
1081 /// policy. Default is [`SynthesisFailureMode::FallThrough`] to
1082 /// preserve backward compatibility with the v0.7.0 ship behaviour;
1083 /// operators opt in to [`SynthesisFailureMode::BlockWrite`] per
1084 /// namespace when a curator outage must be surfaced loudly.
1085 #[must_use]
1086 pub fn effective_synthesis_failure_mode(&self) -> SynthesisFailureMode {
1087 self.synthesis.synthesis_failure_mode.unwrap_or_default()
1088 }
1089
1090 /// v0.7.0 Cluster-B (issue #767, SEC-1) — resolve the per-call
1091 /// delete-cap. Compiled default is **1**: a single LLM round-trip
1092 /// must not mass-delete a namespace without an explicit K10
1093 /// approval flow.
1094 #[must_use]
1095 pub fn effective_synthesis_max_deletes_per_call(&self) -> u32 {
1096 self.synthesis.synthesis_max_deletes_per_call.unwrap_or(1)
1097 }
1098
1099 /// v0.7.0 Cluster-B (issue #767, PERF-7) — resolve the
1100 /// per-candidate character cap inlined into the synthesis prompt.
1101 /// Compiled default is **1500** characters (~400 cl100k tokens).
1102 /// Truncation only affects the LLM prompt, not the stored row.
1103 #[must_use]
1104 pub fn effective_synthesis_max_candidate_chars(&self) -> usize {
1105 self.synthesis.synthesis_max_candidate_chars.unwrap_or(1500) as usize
1106 }
1107
1108 /// v0.7.0 polish (issue #782, PERF-11) — resolve the per-stage
1109 /// character cap inlined into a Form 3 multistep-ingest LLM-stage
1110 /// prompt. Compiled default is **1500** characters (~400 cl100k
1111 /// tokens), matching the synthesis cap (PERF-7) so operators have
1112 /// a single reasonable prompt-budget shape to reason about.
1113 /// Truncation only affects the LLM prompt content slot; the
1114 /// helper payloads (which carry their own preview truncation
1115 /// inside the helper) and the caller-visible final output are
1116 /// untouched.
1117 #[must_use]
1118 pub fn effective_multistep_max_content_chars(&self) -> usize {
1119 self.multistep.multistep_max_content_chars.unwrap_or(1500) as usize
1120 }
1121
1122 /// #880 — auto-classify-kind accessor, missing in the pre-#880
1123 /// hand-written impl (callers were reading `policy.kind_class.auto_classify_kind`
1124 /// directly). Now exposed via a typed accessor so the call sites can
1125 /// migrate to the sub-struct path without referencing every field
1126 /// directly.
1127 #[must_use]
1128 pub fn effective_auto_classify_kind(&self) -> MemoryKindAutoClassify {
1129 self.kind_class.auto_classify_kind.unwrap_or_default()
1130 }
1131}
1132
1133/// Namespace reserved for agent registrations (Task 1.3).
1134pub const AGENTS_NAMESPACE: &str = "_agents";
1135
1136/// Canonical title for an agent-registration row in
1137/// [`AGENTS_NAMESPACE`] — `agent:<agent_id>`. Both storage backends
1138/// CONSTRUCT registration rows with this title and the subscription
1139/// path MATCHES on it, so the shape must come from one place (#1558).
1140#[must_use]
1141pub fn agent_registration_title(agent_id: &str) -> String {
1142 format!("agent:{agent_id}")
1143}
1144
1145/// #1539 — body for `PUT /api/v1/agents/{id}/pubkey`.
1146#[derive(Debug, Clone, Deserialize)]
1147pub struct BindAgentPubkeyBody {
1148 /// Base64 (URL-safe, no-pad accepted) 32-byte Ed25519 public key.
1149 pub pubkey_b64: String,
1150}
1151
1152#[derive(Debug, Deserialize)]
1153pub struct RegisterAgentBody {
1154 pub agent_id: String,
1155 pub agent_type: String,
1156 #[serde(default)]
1157 pub capabilities: Option<Vec<String>>,
1158}
1159
1160#[derive(Debug, Serialize)]
1161pub struct AgentRegistration {
1162 pub agent_id: String,
1163 pub agent_type: String,
1164 pub capabilities: Vec<String>,
1165 pub registered_at: String,
1166 pub last_seen_at: String,
1167}
1168
1169// -----------------------------------------------------------------
1170// v0.7-polish coverage recovery (issue #767) — GovernancePolicy
1171// effective_* accessor + default-resolution coverage. Covers the
1172// Form 1/2/4/5/6 + QW-1/QW-2 + Cluster B/F fields and their accessors.
1173// -----------------------------------------------------------------
1174#[cfg(test)]
1175mod tests {
1176 use super::*;
1177 use serde_json::json;
1178
1179 #[test]
1180 fn governance_policy_default_resolves_form_fields_to_none_and_compiled_defaults() {
1181 let p = GovernancePolicy::default();
1182 assert_eq!(p.core.write, GovernanceLevel::Any);
1183 assert_eq!(p.core.promote, GovernanceLevel::Any);
1184 assert_eq!(p.core.delete, GovernanceLevel::Owner);
1185 assert_eq!(p.core.approver, ApproverType::Human);
1186 assert!(p.core.inherit);
1187 // Every Form / Cluster field defaults to None.
1188 assert!(p.core.max_reflection_depth.is_none());
1189 assert!(p.export.auto_export_reflections_to_filesystem.is_none());
1190 assert!(p.atomisation.auto_atomise.is_none());
1191 assert!(p.atomisation.auto_atomise_threshold_cl100k.is_none());
1192 assert!(p.atomisation.auto_atomise_max_atom_tokens.is_none());
1193 assert!(p.atomisation.auto_atomise_max_retries.is_none());
1194 assert!(p.persona.auto_persona_trigger_every_n_memories.is_none());
1195 assert!(p.persona.auto_export_personas_to_filesystem.is_none());
1196 assert!(p.atomisation.auto_atomise_mode.is_none());
1197 assert!(p.synthesis.legacy_per_pair_classifier.is_none());
1198 assert!(p.kind_class.auto_classify_kind.is_none());
1199 assert!(p.synthesis.synthesis_failure_mode.is_none());
1200 assert!(p.synthesis.synthesis_max_deletes_per_call.is_none());
1201 assert!(p.synthesis.synthesis_max_candidate_chars.is_none());
1202 assert!(p.multistep.multistep_max_content_chars.is_none());
1203 }
1204
1205 #[test]
1206 fn default_for_managed_namespace_tightens_write_to_owner() {
1207 let p = GovernancePolicy::default_for_managed_namespace();
1208 assert_eq!(p.core.write, GovernanceLevel::Owner);
1209 assert!(p.core.inherit);
1210 // All Form fields remain None — managed namespaces inherit
1211 // compiled defaults explicitly.
1212 assert!(p.core.max_reflection_depth.is_none());
1213 assert!(p.atomisation.auto_atomise.is_none());
1214 assert!(p.atomisation.auto_atomise_mode.is_none());
1215 assert!(p.synthesis.synthesis_failure_mode.is_none());
1216 assert!(p.multistep.multistep_max_content_chars.is_none());
1217 }
1218
1219 #[test]
1220 fn effective_max_reflection_depth_defaults_to_three_when_none() {
1221 let p = GovernancePolicy::default();
1222 assert_eq!(p.effective_max_reflection_depth(), 3);
1223 }
1224
1225 #[test]
1226 fn effective_max_reflection_depth_returns_override_when_set() {
1227 let mut p = GovernancePolicy::default();
1228 p.core.max_reflection_depth = Some(7);
1229 assert_eq!(p.effective_max_reflection_depth(), 7);
1230 }
1231
1232 #[test]
1233 fn effective_max_reflection_depth_returns_zero_kill_switch() {
1234 let mut p = GovernancePolicy::default();
1235 p.core.max_reflection_depth = Some(0);
1236 assert_eq!(p.effective_max_reflection_depth(), 0);
1237 }
1238
1239 #[test]
1240 fn effective_auto_export_reflections_to_filesystem_defaults_false() {
1241 let p = GovernancePolicy::default();
1242 assert!(!p.effective_auto_export_reflections_to_filesystem());
1243 }
1244
1245 #[test]
1246 fn effective_auto_export_reflections_to_filesystem_returns_override() {
1247 let mut p = GovernancePolicy::default();
1248 p.export.auto_export_reflections_to_filesystem = Some(true);
1249 assert!(p.effective_auto_export_reflections_to_filesystem());
1250 p.export.auto_export_reflections_to_filesystem = Some(false);
1251 assert!(!p.effective_auto_export_reflections_to_filesystem());
1252 }
1253
1254 #[test]
1255 fn effective_auto_atomise_defaults_false() {
1256 let p = GovernancePolicy::default();
1257 assert!(!p.effective_auto_atomise());
1258 }
1259
1260 #[test]
1261 fn effective_auto_atomise_returns_override() {
1262 let mut p = GovernancePolicy::default();
1263 p.atomisation.auto_atomise = Some(true);
1264 assert!(p.effective_auto_atomise());
1265 }
1266
1267 #[test]
1268 fn effective_auto_atomise_threshold_cl100k_defaults_to_500() {
1269 let p = GovernancePolicy::default();
1270 assert_eq!(p.effective_auto_atomise_threshold_cl100k(), 500);
1271 }
1272
1273 #[test]
1274 fn effective_auto_atomise_threshold_cl100k_returns_override() {
1275 let mut p = GovernancePolicy::default();
1276 p.atomisation.auto_atomise_threshold_cl100k = Some(1000);
1277 assert_eq!(p.effective_auto_atomise_threshold_cl100k(), 1000);
1278 }
1279
1280 #[test]
1281 fn effective_auto_atomise_max_atom_tokens_defaults_to_200() {
1282 let p = GovernancePolicy::default();
1283 assert_eq!(p.effective_auto_atomise_max_atom_tokens(), 200);
1284 }
1285
1286 #[test]
1287 fn effective_auto_atomise_max_atom_tokens_returns_override() {
1288 let mut p = GovernancePolicy::default();
1289 p.atomisation.auto_atomise_max_atom_tokens = Some(50);
1290 assert_eq!(p.effective_auto_atomise_max_atom_tokens(), 50);
1291 }
1292
1293 #[test]
1294 fn effective_auto_atomise_max_retries_returns_none_by_default() {
1295 let p = GovernancePolicy::default();
1296 assert_eq!(p.effective_auto_atomise_max_retries(), None);
1297 }
1298
1299 #[test]
1300 fn effective_auto_atomise_max_retries_returns_override() {
1301 let mut p = GovernancePolicy::default();
1302 p.atomisation.auto_atomise_max_retries = Some(3);
1303 assert_eq!(p.effective_auto_atomise_max_retries(), Some(3));
1304 }
1305
1306 #[test]
1307 fn effective_auto_persona_trigger_returns_none_by_default() {
1308 let p = GovernancePolicy::default();
1309 assert_eq!(p.effective_auto_persona_trigger_every_n_memories(), None);
1310 }
1311
1312 #[test]
1313 fn effective_auto_persona_trigger_returns_override() {
1314 let mut p = GovernancePolicy::default();
1315 p.persona.auto_persona_trigger_every_n_memories = Some(5);
1316 assert_eq!(p.effective_auto_persona_trigger_every_n_memories(), Some(5));
1317 }
1318
1319 #[test]
1320 fn effective_auto_export_personas_to_filesystem_defaults_false() {
1321 let p = GovernancePolicy::default();
1322 assert!(!p.effective_auto_export_personas_to_filesystem());
1323 }
1324
1325 #[test]
1326 fn effective_auto_export_personas_to_filesystem_returns_override() {
1327 let mut p = GovernancePolicy::default();
1328 p.persona.auto_export_personas_to_filesystem = Some(true);
1329 assert!(p.effective_auto_export_personas_to_filesystem());
1330 }
1331
1332 #[test]
1333 fn effective_auto_atomise_mode_off_when_disabled() {
1334 let p = GovernancePolicy::default();
1335 assert_eq!(p.effective_auto_atomise_mode(), AutoAtomiseMode::Off);
1336 }
1337
1338 #[test]
1339 fn effective_auto_atomise_mode_explicit_off_wins_over_enabled_flag() {
1340 let mut p = GovernancePolicy::default();
1341 p.atomisation.auto_atomise = Some(true);
1342 p.atomisation.auto_atomise_mode = Some(AutoAtomiseMode::Off);
1343 assert_eq!(p.effective_auto_atomise_mode(), AutoAtomiseMode::Off);
1344 }
1345
1346 #[test]
1347 fn effective_auto_atomise_mode_legacy_flag_implies_deferred() {
1348 let mut p = GovernancePolicy::default();
1349 p.atomisation.auto_atomise = Some(true);
1350 // No explicit mode → implicit Deferred (legacy WT-1-D behaviour).
1351 assert_eq!(p.effective_auto_atomise_mode(), AutoAtomiseMode::Deferred);
1352 }
1353
1354 #[test]
1355 fn effective_auto_atomise_mode_explicit_synchronous() {
1356 let mut p = GovernancePolicy::default();
1357 p.atomisation.auto_atomise = Some(true);
1358 p.atomisation.auto_atomise_mode = Some(AutoAtomiseMode::Synchronous);
1359 assert_eq!(
1360 p.effective_auto_atomise_mode(),
1361 AutoAtomiseMode::Synchronous
1362 );
1363 }
1364
1365 #[test]
1366 fn effective_auto_atomise_mode_explicit_deferred_when_flag_absent() {
1367 let mut p = GovernancePolicy::default();
1368 p.atomisation.auto_atomise_mode = Some(AutoAtomiseMode::Deferred);
1369 // Explicit mode wins regardless of the boolean flag.
1370 assert_eq!(p.effective_auto_atomise_mode(), AutoAtomiseMode::Deferred);
1371 }
1372
1373 #[test]
1374 fn auto_atomise_mode_as_str_labels() {
1375 assert_eq!(AutoAtomiseMode::Off.as_str(), "off");
1376 assert_eq!(AutoAtomiseMode::Deferred.as_str(), "deferred");
1377 assert_eq!(AutoAtomiseMode::Synchronous.as_str(), "synchronous");
1378 }
1379
1380 #[test]
1381 fn effective_legacy_per_pair_classifier_defaults_false() {
1382 let p = GovernancePolicy::default();
1383 assert!(!p.effective_legacy_per_pair_classifier());
1384 }
1385
1386 #[test]
1387 fn effective_legacy_per_pair_classifier_returns_override() {
1388 let mut p = GovernancePolicy::default();
1389 p.synthesis.legacy_per_pair_classifier = Some(true);
1390 assert!(p.effective_legacy_per_pair_classifier());
1391 }
1392
1393 #[test]
1394 fn effective_synthesis_failure_mode_defaults_to_fall_through() {
1395 let p = GovernancePolicy::default();
1396 assert_eq!(
1397 p.effective_synthesis_failure_mode(),
1398 SynthesisFailureMode::FallThrough
1399 );
1400 }
1401
1402 #[test]
1403 fn effective_synthesis_failure_mode_returns_override() {
1404 let mut p = GovernancePolicy::default();
1405 p.synthesis.synthesis_failure_mode = Some(SynthesisFailureMode::BlockWrite);
1406 assert_eq!(
1407 p.effective_synthesis_failure_mode(),
1408 SynthesisFailureMode::BlockWrite
1409 );
1410 }
1411
1412 #[test]
1413 fn synthesis_failure_mode_as_str_labels() {
1414 assert_eq!(SynthesisFailureMode::FallThrough.as_str(), "fall_through");
1415 assert_eq!(SynthesisFailureMode::BlockWrite.as_str(), "block_write");
1416 }
1417
1418 #[test]
1419 fn synthesis_failure_mode_default_is_fall_through() {
1420 let v: SynthesisFailureMode = SynthesisFailureMode::default();
1421 assert_eq!(v, SynthesisFailureMode::FallThrough);
1422 }
1423
1424 #[test]
1425 fn effective_synthesis_max_deletes_per_call_defaults_to_one() {
1426 let p = GovernancePolicy::default();
1427 assert_eq!(p.effective_synthesis_max_deletes_per_call(), 1);
1428 }
1429
1430 #[test]
1431 fn effective_synthesis_max_deletes_per_call_returns_override() {
1432 let mut p = GovernancePolicy::default();
1433 p.synthesis.synthesis_max_deletes_per_call = Some(8);
1434 assert_eq!(p.effective_synthesis_max_deletes_per_call(), 8);
1435 }
1436
1437 #[test]
1438 fn effective_synthesis_max_candidate_chars_defaults_to_1500() {
1439 let p = GovernancePolicy::default();
1440 assert_eq!(p.effective_synthesis_max_candidate_chars(), 1500);
1441 }
1442
1443 #[test]
1444 fn effective_synthesis_max_candidate_chars_returns_override() {
1445 let mut p = GovernancePolicy::default();
1446 p.synthesis.synthesis_max_candidate_chars = Some(2_500);
1447 assert_eq!(p.effective_synthesis_max_candidate_chars(), 2_500);
1448 }
1449
1450 #[test]
1451 fn effective_multistep_max_content_chars_defaults_to_1500() {
1452 let p = GovernancePolicy::default();
1453 assert_eq!(p.effective_multistep_max_content_chars(), 1500);
1454 }
1455
1456 #[test]
1457 fn effective_multistep_max_content_chars_returns_override() {
1458 let mut p = GovernancePolicy::default();
1459 p.multistep.multistep_max_content_chars = Some(3_000);
1460 assert_eq!(p.effective_multistep_max_content_chars(), 3_000);
1461 }
1462
1463 #[test]
1464 fn memory_kind_auto_classify_default_is_off() {
1465 let v: MemoryKindAutoClassify = MemoryKindAutoClassify::default();
1466 assert_eq!(v, MemoryKindAutoClassify::Off);
1467 }
1468
1469 #[test]
1470 fn memory_kind_auto_classify_serde_round_trip() {
1471 for v in [
1472 MemoryKindAutoClassify::Off,
1473 MemoryKindAutoClassify::RegexOnly,
1474 MemoryKindAutoClassify::RegexThenLlm,
1475 ] {
1476 let s = serde_json::to_value(v).unwrap();
1477 let back: MemoryKindAutoClassify = serde_json::from_value(s).unwrap();
1478 assert_eq!(back, v);
1479 }
1480 }
1481
1482 #[test]
1483 fn auto_atomise_mode_serde_round_trip() {
1484 for v in [
1485 AutoAtomiseMode::Off,
1486 AutoAtomiseMode::Deferred,
1487 AutoAtomiseMode::Synchronous,
1488 ] {
1489 let s = serde_json::to_value(v).unwrap();
1490 let back: AutoAtomiseMode = serde_json::from_value(s).unwrap();
1491 assert_eq!(back, v);
1492 }
1493 }
1494
1495 #[test]
1496 fn synthesis_failure_mode_serde_round_trip() {
1497 for v in [
1498 SynthesisFailureMode::FallThrough,
1499 SynthesisFailureMode::BlockWrite,
1500 ] {
1501 let s = serde_json::to_value(v).unwrap();
1502 let back: SynthesisFailureMode = serde_json::from_value(s).unwrap();
1503 assert_eq!(back, v);
1504 }
1505 }
1506
1507 #[test]
1508 fn governance_policy_serde_round_trip_with_all_v070_fields() {
1509 let mut p = GovernancePolicy::default();
1510 p.core.max_reflection_depth = Some(5);
1511 p.atomisation.auto_atomise = Some(true);
1512 p.atomisation.auto_atomise_mode = Some(AutoAtomiseMode::Synchronous);
1513 p.atomisation.auto_atomise_threshold_cl100k = Some(750);
1514 p.atomisation.auto_atomise_max_atom_tokens = Some(150);
1515 p.atomisation.auto_atomise_max_retries = Some(2);
1516 p.persona.auto_persona_trigger_every_n_memories = Some(10);
1517 p.persona.auto_export_personas_to_filesystem = Some(true);
1518 p.export.auto_export_reflections_to_filesystem = Some(true);
1519 p.synthesis.legacy_per_pair_classifier = Some(false);
1520 p.kind_class.auto_classify_kind = Some(MemoryKindAutoClassify::RegexOnly);
1521 p.synthesis.synthesis_failure_mode = Some(SynthesisFailureMode::BlockWrite);
1522 p.synthesis.synthesis_max_deletes_per_call = Some(4);
1523 p.synthesis.synthesis_max_candidate_chars = Some(2_000);
1524 p.multistep.multistep_max_content_chars = Some(3_000);
1525 let v = serde_json::to_value(&p).unwrap();
1526 let back: GovernancePolicy = serde_json::from_value(v).unwrap();
1527 assert_eq!(back.core.max_reflection_depth, Some(5));
1528 assert_eq!(
1529 back.atomisation.auto_atomise_mode,
1530 Some(AutoAtomiseMode::Synchronous)
1531 );
1532 assert_eq!(back.atomisation.auto_atomise_threshold_cl100k, Some(750));
1533 assert_eq!(back.persona.auto_persona_trigger_every_n_memories, Some(10));
1534 assert_eq!(
1535 back.synthesis.synthesis_failure_mode,
1536 Some(SynthesisFailureMode::BlockWrite)
1537 );
1538 assert_eq!(back.synthesis.synthesis_max_deletes_per_call, Some(4));
1539 assert_eq!(back.multistep.multistep_max_content_chars, Some(3_000));
1540 }
1541
1542 #[test]
1543 fn from_metadata_returns_none_when_governance_key_absent() {
1544 let meta = json!({"unrelated": 42});
1545 assert!(GovernancePolicy::from_metadata(&meta).is_none());
1546 }
1547
1548 #[test]
1549 fn from_metadata_returns_none_when_governance_key_is_null() {
1550 let meta = json!({"governance": null});
1551 assert!(GovernancePolicy::from_metadata(&meta).is_none());
1552 }
1553
1554 #[test]
1555 fn from_metadata_parses_governance_blob() {
1556 let meta = json!({
1557 "governance": {
1558 "write": "owner",
1559 "max_reflection_depth": 4,
1560 },
1561 });
1562 let parsed = GovernancePolicy::from_metadata(&meta).unwrap().unwrap();
1563 assert_eq!(parsed.core.write, GovernanceLevel::Owner);
1564 assert_eq!(parsed.core.max_reflection_depth, Some(4));
1565 }
1566
1567 #[test]
1568 fn from_metadata_propagates_parse_error_for_malformed_payload() {
1569 let meta = json!({"governance": {"write": 42}});
1570 let res = GovernancePolicy::from_metadata(&meta).unwrap();
1571 assert!(res.is_err());
1572 }
1573
1574 // ---- MemoryScope coverage tests (FX-F3, 2026-05-31) ---------------------
1575 //
1576 // Closes the 98.73% < 99% per-module coverage floor regression
1577 // introduced by 6f7a00963 (MemoryScope enum + parity test). The
1578 // external parity test at tests/memory_scope_count_invariant.rs
1579 // exercises round-trip + the trait FromStr error path, but those
1580 // run as a separate test binary and are NOT measured by `cargo
1581 // llvm-cov --lib` which the Per-Module Coverage Thresholds
1582 // workflow uses for per-module coverage. Mirroring the tests
1583 // inline into the lib unit suite below brings the new code paths
1584 // into the per-module measurement.
1585
1586 use super::MemoryScope;
1587 use std::str::FromStr;
1588
1589 #[test]
1590 fn memory_scope_inherent_from_str_known_variants() {
1591 assert_eq!(MemoryScope::from_str("private"), Some(MemoryScope::Private));
1592 assert_eq!(MemoryScope::from_str("team"), Some(MemoryScope::Team));
1593 assert_eq!(MemoryScope::from_str("unit"), Some(MemoryScope::Unit));
1594 assert_eq!(MemoryScope::from_str("org"), Some(MemoryScope::Org));
1595 assert_eq!(
1596 MemoryScope::from_str("collective"),
1597 Some(MemoryScope::Collective)
1598 );
1599 }
1600
1601 #[test]
1602 fn memory_scope_inherent_from_str_unknown_returns_none() {
1603 assert_eq!(MemoryScope::from_str("bogus"), None);
1604 assert_eq!(MemoryScope::from_str(""), None);
1605 assert_eq!(MemoryScope::from_str("Private"), None); // case-sensitive
1606 }
1607
1608 #[test]
1609 fn memory_scope_as_str_canonical_strings() {
1610 assert_eq!(MemoryScope::Private.as_str(), "private");
1611 assert_eq!(MemoryScope::Team.as_str(), "team");
1612 assert_eq!(MemoryScope::Unit.as_str(), "unit");
1613 assert_eq!(MemoryScope::Org.as_str(), "org");
1614 assert_eq!(MemoryScope::Collective.as_str(), "collective");
1615 }
1616
1617 #[test]
1618 fn memory_scope_display_matches_as_str() {
1619 // Display impl delegates to as_str; tested explicitly so the
1620 // fmt branch shows up in coverage.
1621 assert_eq!(format!("{}", MemoryScope::Private), "private");
1622 assert_eq!(format!("{}", MemoryScope::Collective), "collective");
1623 }
1624
1625 #[test]
1626 fn memory_scope_default_is_private() {
1627 assert_eq!(MemoryScope::default(), MemoryScope::Private);
1628 }
1629
1630 #[test]
1631 fn memory_scope_fromstr_trait_round_trips_known() {
1632 // The trait FromStr (vs the inherent from_str) wraps the None
1633 // case in a helpful error string. Exercises BOTH the Ok arm
1634 // and the Err arm.
1635 assert_eq!(
1636 <MemoryScope as FromStr>::from_str("team").unwrap(),
1637 MemoryScope::Team
1638 );
1639 }
1640
1641 #[test]
1642 fn memory_scope_fromstr_trait_error_message_lists_valid_scopes() {
1643 let err = <MemoryScope as FromStr>::from_str("unknown_scope")
1644 .expect_err("unknown scope must error");
1645 assert!(
1646 err.contains("'unknown_scope'"),
1647 "error names the input: {err}"
1648 );
1649 // Error message names the canonical set so callers know what's valid.
1650 assert!(err.contains("private"), "error lists private: {err}");
1651 assert!(err.contains("collective"), "error lists collective: {err}");
1652 }
1653
1654 #[test]
1655 fn memory_scope_all_strs_matches_valid_scopes_const() {
1656 // VALID_SCOPES is the byte-canonical string slice; assert
1657 // MemoryScope::all_strs() returns the same sequence in the
1658 // same declaration order.
1659 let enum_strs: &[&str] = MemoryScope::all_strs();
1660 assert_eq!(enum_strs, VALID_SCOPES, "all_strs must match VALID_SCOPES");
1661 }
1662
1663 #[test]
1664 fn memory_scope_all_round_trips_through_serde() {
1665 // Each variant serialises to the snake_case string and
1666 // deserialises back to the same variant. Exercises serde
1667 // rename_all = "snake_case" on both directions.
1668 for variant in MemoryScope::all() {
1669 let json = serde_json::to_string(variant).unwrap();
1670 let parsed: MemoryScope = serde_json::from_str(&json).unwrap();
1671 assert_eq!(parsed, *variant, "serde round-trip for {variant:?}");
1672 }
1673 }
1674}