Skip to main content

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(&current) {
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}