Skip to main content

ai_memory/
profile.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! v0.6.4-001 — `Profile` resolution for the MCP tool surface.
5//!
6//! A profile is a set of tool *families* (`Family`) that the MCP server
7//! advertises in its `tools/list` response. v0.6.4 collapses the default
8//! surface from 43 tools (full) to 5 (core) so eager-loading harnesses
9//! stop pre-paying ~6,000 input tokens of tool schemas per request. The
10//! 38 tools outside `core` remain reachable via runtime expansion through
11//! `memory_capabilities --include-schema family=<name>` (Track C —
12//! v0.6.4-006), so no functionality is lost; only the eager prefix cost
13//! goes away.
14//!
15//! ## Resolution order
16//!
17//! `CLI flag > AI_MEMORY_PROFILE env > [mcp].profile config > "core"`.
18//!
19//! `clap` natively handles "CLI > env" with `#[arg(env = "...")]`, so
20//! the daemon-runtime side only needs to call
21//! [`AppConfig::effective_profile`] with the resolved CLI/env value
22//! (already merged by clap) plus the config-file value (read by
23//! `serde`).
24//!
25//! ## Profile vocabulary
26//!
27//! - `core` — 7 tools, the new v0.6.4 default (v0.7 B1 added
28//!   `memory_load_family`; v0.7 B2 added `memory_smart_load`).
29//!   Always loaded.
30//! - `graph` — adds the 11 KG/entity/replay/verify/find_paths tools. ~18 tools.
31//! - `admin` — adds lifecycle (6) + governance (8). ~21 tools.
32//! - `power` — adds the 8 LLM-augmented + operator tools (consolidate,
33//!   auto_tag, …, plus the v0.7 K7 subscription-reliability pair).
34//!   ~15 tools.
35//! - `full` — every family. **74 advertised entries at v0.7.0**
36//!   (73 callable "memory tools" + the always-on `memory_capabilities`
37//!   bootstrap; `Profile::full().expected_tool_count()` is the
38//!   canonical assertion).
39//! - `custom` — comma-separated family list (`core,graph,archive` …).
40//!   `core` is implicitly added if missing — there's no profile that
41//!   ships *less than* the 7 core tools at v0.7.0 (the original 5 +
42//!   `memory_load_family` + `memory_smart_load`).
43//!
44//! ## Custom-profile parsing edge cases
45//!
46//! Documented in this RFC + pinned by unit tests:
47//!
48//! - empty string → `Profile::core()` (default)
49//! - `core,core` → dedupe silently
50//! - `core,xyz` → `ProfileParseError::UnknownFamily("xyz")` listing
51//!   every valid family name
52//! - mixed-case (`Core`) → `ProfileParseError::CaseMismatch`. Profiles
53//!   are case-sensitive lowercase. Rejecting mixed case prevents
54//!   `Profile` vs `profile` config-file divergence from creating two
55//!   different surfaces in production.
56//! - whitespace-only token (`core, ,graph`) → silently skipped
57//! - `core,full` → `Profile::full()` (full subsumes everything; not an
58//!   error)
59//! - duplicates across the named-then-custom path (`full,core`) → also
60//!   resolves to full.
61
62use std::str::FromStr;
63
64/// A tool family. Source-anchored at `crate::mcp::registry::tool_definitions()`
65/// 2026-05-05. Counts must sum to 51 (the v0.6.3.1 baseline of 43 +
66/// v0.7.0 I4 `memory_replay` + v0.7 H4 `memory_verify` (both in
67/// `Family::Graph`) + v0.7 B1 `memory_load_family` and v0.7 B2
68/// `memory_smart_load` in `Family::Core` +
69/// v0.7 K7 `memory_subscription_replay` and `memory_subscription_dlq_list`
70/// in `Family::Power` + v0.7 J7 `memory_find_paths` in `Family::Graph` +
71/// v0.7 K8 `memory_quota_status` in `Family::Power`).
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
73pub enum Family {
74    /// store, recall, list, get, search, load_family, smart_load — 7
75    /// (load_family added in v0.7 B1 — always-on family loader that
76    /// returns the top-k recent + high-priority memories whose
77    /// `metadata.family` matches one of the eight enum names;
78    /// smart_load added in v0.7 B2 — intent-routed front door that
79    /// picks the best Family from a free-text intent and forwards to
80    /// `memory_load_family`.)
81    Core,
82    /// update, delete, forget, gc, promote — 5
83    Lifecycle,
84    /// kg_query, kg_timeline, kg_invalidate, link, get_links,
85    /// entity_register, entity_get_by_alias, get_taxonomy, replay,
86    /// verify, find_paths — 11 (replay added in v0.7.0 I4 — joins to the I2
87    /// transcript-link substrate to reconstruct a memory's source
88    /// transcript chain; verify added in v0.7 H4 — re-checks the
89    /// Ed25519 signature on a stored memory_links row.)
90    Graph,
91    /// pending_list/approve/reject, namespace_set/get/clear_standard,
92    /// subscribe, unsubscribe — 8
93    Governance,
94    /// consolidate, detect_contradiction, check_duplicate, auto_tag,
95    /// expand_query, inbox, subscription_replay, subscription_dlq_list,
96    /// quota_status — 9 (v0.7 K7 added the two
97    /// operator/governance subscription-reliability tools — replay
98    /// events from the audit log + inspect the DLQ; v0.7 K8 added
99    /// `memory_quota_status` for the per-agent rate-limit + storage-cap
100    /// substrate.)
101    Power,
102    /// capabilities, agent_register, agent_list, session_start, stats — 5
103    Meta,
104    /// archive_list, archive_purge, archive_restore, archive_stats — 4
105    Archive,
106    /// list_subscriptions, notify — 2
107    Other,
108}
109
110/// Tool names that are loaded in every profile, regardless of which
111/// families it includes. v0.6.4 reserves `memory_capabilities` as the
112/// always-on bootstrap so the runtime-discovery dance works out of the
113/// box on `--profile core`. Per RFC S27 and the v0.6.4-002 acceptance
114/// criteria.
115///
116/// v0.7.x (issue #1174 PR1 — pm-v3.1 MCP tool name sweep): the
117/// literal references the canonical const so this slice cannot drift
118/// from the dispatch table.
119///
120/// DOC-7 (med/low review batch) — semantic note: this is a sentinel
121/// list with `len == 1` at v0.7.0 (only `memory_capabilities`). The
122/// `&[…]` slice shape is intentionally extensible: future profile
123/// work may promote additional bootstrap-class tools (e.g.
124/// `memory_load_family`, `memory_smart_load`) into the always-on set,
125/// at which point [`Profile::core`] would need to subtract them from
126/// the per-family count to avoid double-counting. Today the family
127/// counts treat `memory_load_family` and `memory_smart_load` as core
128/// family members, NOT as always-on; promotion would require updating
129/// `Profile::expected_tool_count` arithmetic accordingly.
130pub const ALWAYS_ON_TOOLS: &[&str] = &[crate::mcp::registry::tool_names::MEMORY_CAPABILITIES];
131
132impl Family {
133    /// Lookup the family that owns a given tool name. Source-anchored
134    /// at `crate::mcp::registry::tool_definitions()` 2026-05-04. Every name listed
135    /// in the v0.6.3.1 baseline is covered; `None` means the tool is
136    /// either unknown to this enumeration or moved out of bounds (which
137    /// should make `tool_definitions_returns_43_tools` red and force a
138    /// reconciliation).
139    #[must_use]
140    pub fn for_tool(name: &str) -> Option<Self> {
141        // v0.7.x (issue #1174 PR1 — pm-v3.1 MCP tool name sweep) — every
142        // match arm references a const from
143        // [`crate::mcp::registry::tool_names`] so the family routing
144        // table and the dispatch table cannot drift in name spelling.
145        use crate::mcp::registry::tool_names as tn;
146        match name {
147            // core (7 — v0.7 B1 added memory_load_family as the always-on
148            // alternative to memory_recall when the agent already knows
149            // which family taxonomy it wants; v0.7 B2 added
150            // memory_smart_load as the intent-routed front door that
151            // picks the best family for the caller).
152            tn::MEMORY_STORE | tn::MEMORY_RECALL | tn::MEMORY_LIST | tn::MEMORY_GET
153            | tn::MEMORY_SEARCH | tn::MEMORY_LOAD_FAMILY | tn::MEMORY_SMART_LOAD => {
154                Some(Self::Core)
155            }
156            // lifecycle (6 — v0.7.0 #1389 L4 added memory_capture_turn, the
157            // host-volunteered idempotent turn-capture substrate primitive).
158            tn::MEMORY_UPDATE | tn::MEMORY_DELETE | tn::MEMORY_FORGET | tn::MEMORY_GC
159            | tn::MEMORY_PROMOTE | tn::MEMORY_CAPTURE_TURN => Some(Self::Lifecycle),
160            // graph (11 — v0.7.0 I4 added memory_replay; v0.7 H4 added memory_verify;
161            // v0.7 J7 added memory_find_paths)
162            tn::MEMORY_KG_QUERY
163            | tn::MEMORY_KG_TIMELINE
164            | tn::MEMORY_KG_INVALIDATE
165            | tn::MEMORY_LINK
166            | tn::MEMORY_GET_LINKS
167            | tn::MEMORY_ENTITY_REGISTER
168            | tn::MEMORY_ENTITY_GET_BY_ALIAS
169            | tn::MEMORY_GET_TAXONOMY
170            | tn::MEMORY_REPLAY
171            | tn::MEMORY_VERIFY
172            | tn::MEMORY_FIND_PATHS => Some(Self::Graph),
173            // governance (8)
174            tn::MEMORY_PENDING_LIST
175            | tn::MEMORY_PENDING_APPROVE
176            | tn::MEMORY_PENDING_REJECT
177            | tn::MEMORY_NAMESPACE_SET_STANDARD
178            | tn::MEMORY_NAMESPACE_GET_STANDARD
179            | tn::MEMORY_NAMESPACE_CLEAR_STANDARD
180            | tn::MEMORY_SUBSCRIBE
181            | tn::MEMORY_UNSUBSCRIBE => Some(Self::Governance),
182            // power (23 — the actual count; v0.7 K7 added the
183            // subscription-reliability pair (`memory_subscription_replay`
184            // + `memory_subscription_dlq_list`); v0.7 K8 added
185            // `memory_quota_status`; v0.7.0 Task 4/8 added
186            // `memory_reflect`; v0.7.0 L2-2/L2-3 added
187            // `memory_reflection_origin` + `memory_dependents_of_invalidated`;
188            // v0.7.0 QW-1 added `memory_export_reflection`; v0.7.0 QW-2
189            // added `memory_persona` + `memory_persona_generate`; v0.7.0
190            // QW-3 follow-up added `memory_offload` + `memory_deref`;
191            // v0.7.0 WT-1-C added `memory_atomise`; v0.7.0 Form 3 added
192            // `memory_ingest_multistep`; v0.7.0 Form 5 added
193            // `memory_calibrate_confidence`; v0.7.0 #691 added
194            // `memory_check_agent_action` + `memory_rule_list`; v0.7.0
195            // #224/#311 added `memory_share`. All operator/governance,
196            // not data-plane. Pinned by `Family::expected_tool_count`
197            // (derived from this slice) and the
198            // `family_tool_names_cover_registry_all` test.
199            tn::MEMORY_CONSOLIDATE
200            | tn::MEMORY_DETECT_CONTRADICTION
201            | tn::MEMORY_CHECK_DUPLICATE
202            | tn::MEMORY_AUTO_TAG
203            | tn::MEMORY_EXPAND_QUERY
204            | tn::MEMORY_INBOX
205            | tn::MEMORY_SUBSCRIPTION_REPLAY
206            | tn::MEMORY_SUBSCRIPTION_DLQ_LIST
207            | tn::MEMORY_QUOTA_STATUS
208            | tn::MEMORY_REFLECT
209            | tn::MEMORY_REFLECTION_ORIGIN
210            // v0.7.0 QW-1 — file-backed reflection chain export.
211            // Operator-facing; substrate returns rendered content,
212            // agent harness owns the disk write.
213            | tn::MEMORY_EXPORT_REFLECTION
214            // v0.7.0 QW-2 — Persona-as-artifact. The read-only
215            // `memory_persona` lookup and the write-side
216            // `memory_persona_generate` regeneration both sit under
217            // Power. Tier-gating in the MCP dispatcher refuses the
218            // write-side surface unless smart+autonomous is enabled.
219            | tn::MEMORY_PERSONA
220            | tn::MEMORY_PERSONA_GENERATE
221            // v0.7.0 Form 5 (issue #758) — calibration sweep over the
222            // shadow-mode observation table. Operator-callable
223            // equivalent of `ai-memory calibrate confidence
224            // --from-shadow`. Lives in Power alongside the other
225            // operator-facing observability tools.
226            | tn::MEMORY_CALIBRATE_CONFIDENCE
227            // v0.7.0 L2-3 (issue #668) — read-side surface for the
228            // reflection invalidation propagation walker. Operator-
229            // facing inspector for the per-reflection dependent set
230            // that gets notified on Reflection→Reflection supersedes.
231            | tn::MEMORY_DEPENDENTS_OF_INVALIDATED
232            // v0.7.0 (issue #691) — substrate-level agent-action rules
233            // engine. Both tools live in Family::Power (governance /
234            // operator-facing, not data-plane). Mutation tools are
235            // explicitly NOT registered over MCP per design revision
236            // 2026-05-13 — operator uses CLI / HTTP with signed key.
237            | tn::MEMORY_CHECK_AGENT_ACTION
238            | tn::MEMORY_RULE_LIST
239            // v0.7.0 QW-3 follow-up — context-offload substrate primitive.
240            // The pair lives in Family::Power so the `power` (and `full`)
241            // profile surfaces them while keeping the keyword-tier
242            // `core` surface unchanged (semantic-tier+ exposure per the
243            // QW-3 brief).
244            | tn::MEMORY_OFFLOAD
245            | tn::MEMORY_DEREF
246            // v0.7.0 WT-1-C — curator-pass atomisation tool. Lives in
247            // the same family/profile group as memory_consolidate and
248            // memory_reflect (semantic+ tier; the keyword tier short-
249            // circuits with a tier-locked advisory envelope).
250            | tn::MEMORY_ATOMISE
251            // v0.7.0 Form 3 (issue #756) — multi-step ingest
252            // orchestrator. Lives at Family::Power alongside the other
253            // LLM-driven write-side tools; tier-gated to smart+ with
254            // the standard tier-locked advisory on keyword.
255            | tn::MEMORY_INGEST_MULTISTEP
256            // v0.7.0 (issues #224 + #311) — Phase 3 Memory Sharing &
257            // Sync RFC pulled forward per operator directive
258            // `28860423-d12c-4959-bc8b-8fa9a94a33d9`. Substrate-level
259            // point-to-point copy into `_shared/<from>→<to>/`.
260            | tn::MEMORY_SHARE => Some(Self::Power),
261            // meta (6 — 5 baseline + v0.7.0 Gap 3 (#886)
262            // memory_recall_observations).
263            tn::MEMORY_CAPABILITIES
264            | tn::MEMORY_AGENT_REGISTER
265            | tn::MEMORY_AGENT_LIST
266            | tn::MEMORY_SESSION_START
267            | tn::MEMORY_STATS
268            | tn::MEMORY_RECALL_OBSERVATIONS => Some(Self::Meta),
269            // archive (4)
270            tn::MEMORY_ARCHIVE_LIST
271            | tn::MEMORY_ARCHIVE_PURGE
272            | tn::MEMORY_ARCHIVE_RESTORE
273            | tn::MEMORY_ARCHIVE_STATS => Some(Self::Archive),
274            // other (9 — 2 baseline + v0.7.0 L1-5 5 skill tools +
275            // v0.7.0 L2-6 memory_skill_promote_from_reflection (#671) +
276            // v0.7.0 L2-7 memory_skill_compositional_context (#672))
277            tn::MEMORY_LIST_SUBSCRIPTIONS
278            | tn::MEMORY_NOTIFY
279            | tn::MEMORY_SKILL_REGISTER
280            | tn::MEMORY_SKILL_LIST
281            | tn::MEMORY_SKILL_GET
282            | tn::MEMORY_SKILL_RESOURCE
283            | tn::MEMORY_SKILL_EXPORT
284            | tn::MEMORY_SKILL_PROMOTE_FROM_REFLECTION
285            | tn::MEMORY_SKILL_COMPOSITIONAL_CONTEXT => Some(Self::Other),
286            _ => None,
287        }
288    }
289
290    /// Lowercase canonical name as used in CLI/env/config.
291    #[must_use]
292    pub const fn name(self) -> &'static str {
293        match self {
294            Self::Core => "core",
295            Self::Lifecycle => "lifecycle",
296            Self::Graph => "graph",
297            Self::Governance => crate::models::field_names::GOVERNANCE,
298            Self::Power => "power",
299            Self::Meta => "meta",
300            Self::Archive => "archive",
301            Self::Other => "other",
302        }
303    }
304
305    /// All eight families in declaration order. Useful for `--profile full`
306    /// and for the `ProfileParseError::UnknownFamily` diagnostic.
307    #[must_use]
308    pub const fn all() -> &'static [Family] {
309        &[
310            Self::Core,
311            Self::Lifecycle,
312            Self::Graph,
313            Self::Governance,
314            Self::Power,
315            Self::Meta,
316            Self::Archive,
317            Self::Other,
318        ]
319    }
320
321    /// Number of MCP tools advertised by this family.
322    ///
323    /// Derived from [`Family::tool_names`] — that slice is the single
324    /// source of truth for both the names AND the count. Adding a tool
325    /// to a family is therefore exactly one edit (append a `tn::*`
326    /// entry to the slice arm); every count that depends on it —
327    /// per-profile expectations, the full-profile total, the registry
328    /// lockstep — recomputes automatically. There are NO hand-maintained
329    /// per-family magic numbers here by design (the historical
330    /// `match self { Core => 7, … }` form drifted whenever a tool landed
331    /// without the matching count bump).
332    #[must_use]
333    pub const fn expected_tool_count(self) -> usize {
334        self.tool_names().len()
335    }
336
337    /// v0.7.0 A2 — tool names belonging to this family. Forward of the
338    /// `Family::for_tool` reverse map; source-anchored at
339    /// `crate::mcp::registry::tool_definitions()` 2026-05-04 (same anchor as
340    /// [`Family::for_tool`] and [`Family::expected_tool_count`]).
341    /// Order is the order each tool appears in
342    /// `tool_definitions_for_profile`'s registration walk, so an
343    /// LLM-facing preview ("the first three tools loaded") aligns with
344    /// the actual `tools/list` output.
345    ///
346    /// This slice is the single source of truth for the family's tool
347    /// set. [`Family::expected_tool_count`] derives its return value
348    /// from `self.tool_names().len()`, and the
349    /// `family_tool_names_cover_registry_all` unit test pins the union
350    /// of all families against the canonical registry set.
351    #[must_use]
352    pub const fn tool_names(self) -> &'static [&'static str] {
353        // v0.7.x (issue #1174 PR1 — pm-v3.1 MCP tool name sweep) — every
354        // entry references a `pub const` from
355        // [`crate::mcp::registry::tool_names`] so the per-family lists,
356        // the dispatch table, and the registry iterator cannot drift
357        // in name spelling.
358        use crate::mcp::registry::tool_names as tn;
359        match self {
360            Self::Core => &[
361                tn::MEMORY_STORE,
362                tn::MEMORY_RECALL,
363                tn::MEMORY_LIST,
364                tn::MEMORY_GET,
365                tn::MEMORY_SEARCH,
366                // v0.7 B1 — always-on alternative to memory_recall when
367                // the agent already knows the Family taxonomy it wants.
368                tn::MEMORY_LOAD_FAMILY,
369                // v0.7 B2 — intent-routed front door. Caller passes a
370                // free-text intent; the handler picks the best family
371                // from the cached descriptors and forwards to
372                // `memory_load_family`.
373                tn::MEMORY_SMART_LOAD,
374            ],
375            Self::Lifecycle => &[
376                tn::MEMORY_UPDATE,
377                tn::MEMORY_DELETE,
378                tn::MEMORY_FORGET,
379                tn::MEMORY_GC,
380                tn::MEMORY_PROMOTE,
381                // v0.7.0 #1389 L4 — host-volunteered idempotent turn
382                // capture (RFC-0001). One memory row + one
383                // transcript_line_dedup row per host turn.
384                tn::MEMORY_CAPTURE_TURN,
385            ],
386            Self::Graph => &[
387                tn::MEMORY_KG_QUERY,
388                tn::MEMORY_KG_TIMELINE,
389                tn::MEMORY_KG_INVALIDATE,
390                tn::MEMORY_LINK,
391                tn::MEMORY_GET_LINKS,
392                tn::MEMORY_ENTITY_REGISTER,
393                tn::MEMORY_ENTITY_GET_BY_ALIAS,
394                tn::MEMORY_GET_TAXONOMY,
395                // v0.7.0 I4 — traverses memory_transcript_links (I2) to
396                // reconstruct the source-transcript chain for a memory.
397                tn::MEMORY_REPLAY,
398                // v0.7 H4 — re-verifies a stored link's Ed25519
399                // signature on demand, returning attest_level.
400                tn::MEMORY_VERIFY,
401                // v0.7 J7 — enumerate up to N paths between two memories
402                // (BFS with cycle detection over memory_links).
403                tn::MEMORY_FIND_PATHS,
404            ],
405            Self::Governance => &[
406                tn::MEMORY_PENDING_LIST,
407                tn::MEMORY_PENDING_APPROVE,
408                tn::MEMORY_PENDING_REJECT,
409                tn::MEMORY_NAMESPACE_SET_STANDARD,
410                tn::MEMORY_NAMESPACE_GET_STANDARD,
411                tn::MEMORY_NAMESPACE_CLEAR_STANDARD,
412                tn::MEMORY_SUBSCRIBE,
413                tn::MEMORY_UNSUBSCRIBE,
414            ],
415            Self::Power => &[
416                tn::MEMORY_CONSOLIDATE,
417                tn::MEMORY_DETECT_CONTRADICTION,
418                tn::MEMORY_CHECK_DUPLICATE,
419                tn::MEMORY_AUTO_TAG,
420                tn::MEMORY_EXPAND_QUERY,
421                tn::MEMORY_INBOX,
422                // v0.7 K7 — operator/governance subscription-reliability
423                // tools. Replay reads back the audit row series for one
424                // subscription since an RFC3339 cursor; dlq_list inspects
425                // payloads that exhausted the [200ms, 1s, 5s] retry ladder.
426                tn::MEMORY_SUBSCRIPTION_REPLAY,
427                tn::MEMORY_SUBSCRIPTION_DLQ_LIST,
428                // v0.7 K8 — per-agent quota status (memories/day, storage
429                // bytes, links/day). Operator-facing inspector for the K8
430                // rate-limit substrate.
431                tn::MEMORY_QUOTA_STATUS,
432                // v0.7.0 Task 4/8 (recursive learning, issue #655) —
433                // substrate-native reflection primitive. Inserts a
434                // reflection memory plus N `reflects_on` provenance
435                // links in a single atomic transaction.
436                tn::MEMORY_REFLECT,
437                // v0.7.0 L2-2 (S6-M1) — cross-peer reflection origin
438                // inspector. Returns peer_origin / signing_agent /
439                // original_depth / local_depth_at_arrival for a row.
440                tn::MEMORY_REFLECTION_ORIGIN,
441                // v0.7.0 L2-3 (issue #668) — invalidation propagation
442                // read-side inspector. Lists dependents flagged by
443                // the walker on Reflection→Reflection supersedes.
444                tn::MEMORY_DEPENDENTS_OF_INVALIDATED,
445                // v0.7.0 (issue #691) — substrate-level agent-action
446                // rules engine. Read-side surface; mutation tools are
447                // NOT registered over MCP (operator uses CLI / HTTP).
448                tn::MEMORY_CHECK_AGENT_ACTION,
449                tn::MEMORY_RULE_LIST,
450                // v0.7.0 QW-1 — file-backed reflection chain export
451                // companion. Renders the markdown / JSON envelope;
452                // does NOT write to disk (agent harness owns disk I/O).
453                tn::MEMORY_EXPORT_REFLECTION,
454                // v0.7.0 QW-3 follow-up — context-offload substrate
455                // primitive (offload + deref). Power-family registration
456                // gives semantic-tier+ exposure per the QW-3 brief; the
457                // handlers themselves live at src/mcp/tools/offload.rs.
458                tn::MEMORY_OFFLOAD,
459                tn::MEMORY_DEREF,
460                // v0.7.0 WT-1-C — curator-pass atomisation tool.
461                // Decomposes a coarse memory into 2-10 atomic
462                // propositions; archives the source. Lives in Power
463                // alongside memory_consolidate / memory_reflect.
464                tn::MEMORY_ATOMISE,
465                // v0.7.0 QW-2 — Persona-as-artifact. Read-only lookup
466                // + smart+ regeneration. Substrate writes the SQL row
467                // (and optionally the filesystem export via namespace
468                // policy); the agent never holds the keypair.
469                tn::MEMORY_PERSONA,
470                tn::MEMORY_PERSONA_GENERATE,
471                // v0.7.0 Form 3 (issue #756) — multi-step ingest
472                // orchestrator. Deterministic helpers + LLM stages
473                // with explicit-trust slots and prompt-cache reuse.
474                tn::MEMORY_INGEST_MULTISTEP,
475                // v0.7.0 Form 5 (issue #758) — calibration sweep over
476                // the shadow-mode observation table. Operator surface
477                // for tuning per-(namespace, source) confidence
478                // baselines.
479                tn::MEMORY_CALIBRATE_CONFIDENCE,
480                // v0.7.0 (issues #224 + #311) — Phase 3 Memory Sharing &
481                // Sync RFC pulled forward per operator directive
482                // `28860423-d12c-4959-bc8b-8fa9a94a33d9`. Substrate-
483                // level point-to-point copy into `_shared/<from>→<to>/`.
484                tn::MEMORY_SHARE,
485            ],
486            Self::Meta => &[
487                tn::MEMORY_CAPABILITIES,
488                tn::MEMORY_AGENT_REGISTER,
489                tn::MEMORY_AGENT_LIST,
490                tn::MEMORY_SESSION_START,
491                tn::MEMORY_STATS,
492                // v0.7.0 Gap 3 (#886) — read-side surface for the
493                // `recall_observations` ledger.
494                tn::MEMORY_RECALL_OBSERVATIONS,
495            ],
496            Self::Archive => &[
497                tn::MEMORY_ARCHIVE_LIST,
498                tn::MEMORY_ARCHIVE_PURGE,
499                tn::MEMORY_ARCHIVE_RESTORE,
500                tn::MEMORY_ARCHIVE_STATS,
501            ],
502            Self::Other => &[
503                tn::MEMORY_LIST_SUBSCRIPTIONS,
504                tn::MEMORY_NOTIFY,
505                // v0.7.0 L1-5 — Agent Skills ingestion substrate (Pillar 1.5).
506                tn::MEMORY_SKILL_REGISTER,
507                tn::MEMORY_SKILL_LIST,
508                tn::MEMORY_SKILL_GET,
509                tn::MEMORY_SKILL_RESOURCE,
510                tn::MEMORY_SKILL_EXPORT,
511                // v0.7.0 L2-6 (issue #671) — closing the recursive-learning loop.
512                tn::MEMORY_SKILL_PROMOTE_FROM_REFLECTION,
513                // v0.7.0 L2-7 (issue #672) — reflection-skill composition.
514                tn::MEMORY_SKILL_COMPOSITIONAL_CONTEXT,
515            ],
516        }
517    }
518}
519
520impl FromStr for Family {
521    type Err = ProfileParseError;
522    fn from_str(s: &str) -> Result<Self, Self::Err> {
523        // Reject mixed case explicitly. Lowercase form below.
524        if s.chars().any(|c| c.is_ascii_uppercase()) {
525            return Err(ProfileParseError::CaseMismatch(s.to_string()));
526        }
527        match s {
528            "core" => Ok(Self::Core),
529            "lifecycle" => Ok(Self::Lifecycle),
530            "graph" => Ok(Self::Graph),
531            crate::models::field_names::GOVERNANCE => Ok(Self::Governance),
532            "power" => Ok(Self::Power),
533            "meta" => Ok(Self::Meta),
534            "archive" => Ok(Self::Archive),
535            "other" => Ok(Self::Other),
536            unknown => Err(ProfileParseError::UnknownFamily(unknown.to_string())),
537        }
538    }
539}
540
541/// A resolved tool profile — the set of families to register on the
542/// MCP server.
543#[derive(Debug, Clone, PartialEq, Eq, Hash)]
544pub struct Profile {
545    families: Vec<Family>,
546}
547
548impl Profile {
549    /// `core` — 7 tools (`store, recall, list, get, search,
550    /// load_family, smart_load`). The new v0.6.4 default; v0.7 B1
551    /// added `memory_load_family` as the always-on family loader and
552    /// v0.7 B2 added `memory_smart_load` as the intent-routed front
553    /// door. Registers exactly the `Core` family.
554    ///
555    /// **Design note (v0.6.4-002 hook):** `memory_capabilities` is
556    /// **always-on** regardless of profile per RFC scenario S27. It is
557    /// NOT in this family list because the registration filter
558    /// (v0.6.4-002) injects it as a bootstrap tool outside the
559    /// profile-driven path. That keeps the "core profile = 7 tools at
560    /// v0.7.0" claim accurate (5 original + memory_load_family +
561    /// memory_smart_load) while still making the runtime-discovery
562    /// dance reachable. Cross-check with
563    /// `Profile::core().expected_tool_count()`.
564    #[must_use]
565    pub fn core() -> Self {
566        Self {
567            families: vec![Family::Core],
568        }
569    }
570
571    /// `graph` — core + graph. 18 tools (v0.7.0 I4 added `memory_replay`;
572    /// v0.7 H4 added `memory_verify`; v0.7 B1 added `memory_load_family`
573    /// to core; v0.7 B2 added `memory_smart_load` to core; v0.7 J7
574    /// added `memory_find_paths`).
575    #[must_use]
576    pub fn graph() -> Self {
577        Self {
578            families: vec![Family::Core, Family::Graph],
579        }
580    }
581
582    /// `admin` — core + lifecycle + governance. 21 tools
583    /// (core 7 + lifecycle 6 + governance 8; v0.7 B1 added
584    /// `memory_load_family` to core; v0.7 B2 added
585    /// `memory_smart_load` to core; #1389 L4 added
586    /// `memory_capture_turn` to lifecycle, 20 → 21).
587    #[must_use]
588    pub fn admin() -> Self {
589        Self {
590            families: vec![Family::Core, Family::Lifecycle, Family::Governance],
591        }
592    }
593
594    /// `power` — core + power. 30 tools (core 7 + power 23; v0.7 B1
595    /// added `memory_load_family` to core; v0.7 B2 added `memory_smart_load`
596    /// to core; v0.7 K7 added the two subscription-reliability tools
597    /// to `Family::Power`).
598    #[must_use]
599    pub fn power() -> Self {
600        Self {
601            families: vec![Family::Core, Family::Power],
602        }
603    }
604
605    /// `full` — every family. The advertised entry count (callable
606    /// "memory tools" + the always-on `memory_capabilities` bootstrap)
607    /// is whatever `Profile::full().expected_tool_count()` returns —
608    /// that accessor, derived from the per-family `tool_names` slices,
609    /// is the canonical SSOT; no literal is restated here.
610    #[must_use]
611    pub fn full() -> Self {
612        Self {
613            families: Family::all().to_vec(),
614        }
615    }
616
617    /// Family list, sorted in declaration order, deduplicated.
618    #[must_use]
619    pub fn families(&self) -> &[Family] {
620        &self.families
621    }
622
623    /// `true` if this profile would register tools from `family`.
624    #[must_use]
625    pub fn includes(&self, family: Family) -> bool {
626        self.families.contains(&family)
627    }
628
629    /// Sum of expected tool counts. v0.6.4-002 will assert that the
630    /// runtime registration matches.
631    #[must_use]
632    pub fn expected_tool_count(&self) -> usize {
633        self.families.iter().map(|f| f.expected_tool_count()).sum()
634    }
635
636    /// `true` if a tool with this name is loaded under this profile.
637    /// Treats every name in [`ALWAYS_ON_TOOLS`] as loaded regardless of
638    /// the family map (per RFC S27 — `memory_capabilities` is the
639    /// bootstrap tool for runtime discovery).
640    #[must_use]
641    pub fn loads(&self, tool_name: &str) -> bool {
642        if ALWAYS_ON_TOOLS.contains(&tool_name) {
643            return true;
644        }
645        Family::for_tool(tool_name).is_some_and(|f| self.includes(f))
646    }
647
648    /// Parse a profile name. Accepts the named profiles plus
649    /// comma-separated family lists. Empty or whitespace-only input
650    /// resolves to [`Profile::core`]. See module docs for full edge-case
651    /// matrix.
652    ///
653    /// # Errors
654    ///
655    /// - [`ProfileParseError::UnknownFamily`] if a comma-separated
656    ///   token is neither a known profile nor a known family.
657    /// - [`ProfileParseError::CaseMismatch`] if any token contains an
658    ///   uppercase letter.
659    pub fn parse(s: &str) -> Result<Self, ProfileParseError> {
660        let trimmed = s.trim();
661        if trimmed.is_empty() {
662            return Ok(Self::core());
663        }
664
665        // Reject mixed case at the whole-string level so `Core` doesn't
666        // sneak past as a family (Family::from_str would also catch it,
667        // but the diagnostic is clearer here).
668        if trimmed.chars().any(|c| c.is_ascii_uppercase()) {
669            return Err(ProfileParseError::CaseMismatch(trimmed.to_string()));
670        }
671
672        // Single named profile?
673        match trimmed {
674            "core" => return Ok(Self::core()),
675            "graph" => return Ok(Self::graph()),
676            "admin" => return Ok(Self::admin()),
677            "power" => return Ok(Self::power()),
678            "full" => return Ok(Self::full()),
679            _ => {}
680        }
681
682        // Comma-separated. Could mix profile names and family names.
683        // `core,graph` registers core+meta (from `core`) plus graph
684        // (from the family). `core,full` is full because full subsumes.
685        let mut families = Vec::with_capacity(8);
686        for raw_token in trimmed.split(',') {
687            let token = raw_token.trim();
688            if token.is_empty() {
689                continue;
690            }
691            // Each token is either a profile or a family.
692            match token {
693                "core" => merge(&mut families, Self::core().families()),
694                "graph" => merge(&mut families, Self::graph().families()),
695                "admin" => merge(&mut families, Self::admin().families()),
696                "power" => merge(&mut families, Self::power().families()),
697                "full" => return Ok(Self::full()),
698                _ => {
699                    let f = Family::from_str(token)?;
700                    if !families.contains(&f) {
701                        families.push(f);
702                    }
703                }
704            }
705        }
706
707        // Every profile implicitly includes `core` — there is no
708        // legitimate use case for a profile smaller than the 5
709        // core tools.
710        if !families.contains(&Family::Core) {
711            families.insert(0, Family::Core);
712        }
713
714        // Sort into declaration order so two equivalent profile
715        // strings (`graph,core` vs `core,graph`) resolve to the same
716        // value.
717        families.sort_unstable();
718        families.dedup();
719
720        Ok(Self { families })
721    }
722}
723
724impl Default for Profile {
725    fn default() -> Self {
726        Self::core()
727    }
728}
729
730fn merge(dst: &mut Vec<Family>, src: &[Family]) {
731    for f in src {
732        if !dst.contains(f) {
733            dst.push(*f);
734        }
735    }
736}
737
738/// Errors produced by [`Profile::parse`] / [`Family::from_str`].
739#[derive(Debug, Clone, PartialEq, Eq)]
740pub enum ProfileParseError {
741    /// A custom-profile token was neither a known profile nor a family.
742    UnknownFamily(String),
743    /// A token contained an uppercase letter. Profile vocabulary is
744    /// case-sensitive lowercase.
745    CaseMismatch(String),
746}
747
748impl std::fmt::Display for ProfileParseError {
749    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
750        match self {
751            Self::UnknownFamily(name) => {
752                let valid: Vec<&str> = Family::all().iter().map(|f| f.name()).collect();
753                let profiles = "core, graph, admin, power, full";
754                write!(
755                    f,
756                    "unknown profile or family '{name}'. \
757                     Valid profiles: {profiles}. \
758                     Valid families: {valid}.",
759                    valid = valid.join(", ")
760                )
761            }
762            Self::CaseMismatch(s) => {
763                write!(
764                    f,
765                    "profile '{s}' contains uppercase letters; \
766                     profile vocabulary is case-sensitive lowercase \
767                     (e.g. 'core', not 'Core')"
768                )
769            }
770        }
771    }
772}
773
774impl std::error::Error for ProfileParseError {}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779
780    // ---------- Family ----------
781
782    #[test]
783    fn family_all_has_eight_entries() {
784        assert_eq!(Family::all().len(), 8);
785    }
786
787    #[test]
788    fn family_tool_names_cover_registry_all() {
789        // Cross-module SSOT invariant (no magic number): the union of
790        // every family's `tool_names` slice must be exactly the
791        // canonical registry set `tool_names::ALL`. Both sides are
792        // hand-maintained name lists in different modules; pinning
793        // their lengths against each other catches a tool added to one
794        // side but not the other. The aggregate `--profile full` count
795        // is whatever this union holds — it is never asserted as a
796        // literal anywhere.
797        let family_total: usize = Family::all().iter().map(|f| f.tool_names().len()).sum();
798        assert_eq!(
799            family_total,
800            crate::mcp::registry::tool_names::ALL.len(),
801            "per-family tool_names slices must cover exactly the registry ALL set; \
802             a tool was added to one side but not the other"
803        );
804    }
805
806    #[test]
807    fn family_from_str_lowercase_canonical() {
808        assert_eq!(Family::from_str("core").unwrap(), Family::Core);
809        assert_eq!(Family::from_str("meta").unwrap(), Family::Meta);
810        assert_eq!(Family::from_str("graph").unwrap(), Family::Graph);
811    }
812
813    #[test]
814    fn family_from_str_rejects_mixed_case() {
815        assert!(matches!(
816            Family::from_str("Core"),
817            Err(ProfileParseError::CaseMismatch(_))
818        ));
819        assert!(matches!(
820            Family::from_str("CORE"),
821            Err(ProfileParseError::CaseMismatch(_))
822        ));
823    }
824
825    #[test]
826    fn family_from_str_unknown_returns_diagnostic() {
827        let err = Family::from_str("xyz").unwrap_err();
828        match err {
829            ProfileParseError::UnknownFamily(s) => assert_eq!(s, "xyz"),
830            _ => panic!("expected UnknownFamily, got {err:?}"),
831        }
832    }
833
834    // ---------- Profile named ----------
835
836    #[test]
837    fn profile_core_loads_only_core_family() {
838        let p = Profile::core();
839        // Core advertises exactly the Core family's tools — derived
840        // from the SSOT slice, never a literal.
841        assert_eq!(p.expected_tool_count(), Family::Core.tool_names().len());
842        assert!(p.includes(Family::Core));
843        // meta is NOT in core's family list — `memory_capabilities`
844        // is bootstrapped separately as always-on per RFC S27. The
845        // other meta tools (agent_register/list/session_start/stats)
846        // are NOT advertised by the core profile.
847        assert!(!p.includes(Family::Meta));
848        assert!(!p.includes(Family::Lifecycle));
849    }
850
851    #[test]
852    fn profile_graph_loads_core_plus_graph() {
853        let p = Profile::graph();
854        // Graph = Core + Graph families; count derived from the SSOT
855        // slices so the v0.7 surface additions can't drift this test.
856        assert_eq!(
857            p.expected_tool_count(),
858            Family::Core.tool_names().len() + Family::Graph.tool_names().len()
859        );
860        assert!(p.includes(Family::Graph));
861    }
862
863    #[test]
864    fn profile_admin_loads_core_lifecycle_governance() {
865        let p = Profile::admin();
866        // admin = Core + Lifecycle + Governance families; count derived
867        // from the SSOT slices. Graph isn't in admin, and the #1389 L4
868        // memory_capture_turn addition to Lifecycle flows through
869        // automatically rather than needing a literal bump here.
870        assert_eq!(
871            p.expected_tool_count(),
872            Family::Core.tool_names().len()
873                + Family::Lifecycle.tool_names().len()
874                + Family::Governance.tool_names().len()
875        );
876    }
877
878    #[test]
879    fn profile_power_loads_core_plus_power() {
880        let p = Profile::power();
881        // Power = Core + Power families; count derived from the SSOT
882        // slices so every Power-family addition flows through here
883        // without a literal bump.
884        assert_eq!(
885            p.expected_tool_count(),
886            Family::Core.tool_names().len() + Family::Power.tool_names().len()
887        );
888    }
889
890    #[test]
891    fn profile_full_matches_registry_all() {
892        let p = Profile::full();
893        // `--profile full` advertises every family. Its count is the
894        // canonical registry set `tool_names::ALL` — anchored on the
895        // SSOT, never a literal. This is the test that the #1389 L4
896        // memory_capture_turn addition flows through automatically.
897        assert_eq!(
898            p.expected_tool_count(),
899            crate::mcp::registry::tool_names::ALL.len()
900        );
901
902        // The `power` profile is Core + Power; same SSOT derivation.
903        assert_eq!(
904            Profile::power().expected_tool_count(),
905            Family::Core.tool_names().len() + Family::Power.tool_names().len()
906        );
907    }
908
909    // ---------- Profile::parse ----------
910
911    #[test]
912    fn parse_empty_returns_core() {
913        assert_eq!(Profile::parse("").unwrap(), Profile::core());
914        assert_eq!(Profile::parse("   ").unwrap(), Profile::core());
915    }
916
917    #[test]
918    fn parse_named_profiles() {
919        assert_eq!(Profile::parse("core").unwrap(), Profile::core());
920        assert_eq!(Profile::parse("graph").unwrap(), Profile::graph());
921        assert_eq!(Profile::parse("admin").unwrap(), Profile::admin());
922        assert_eq!(Profile::parse("power").unwrap(), Profile::power());
923        assert_eq!(Profile::parse("full").unwrap(), Profile::full());
924    }
925
926    #[test]
927    fn parse_custom_comma_list_dedup() {
928        // `core,graph` → Core + Graph families. Meta is NOT included —
929        // `memory_capabilities` is always-on bootstrapped outside the
930        // family map (v0.6.4-002). Count derived from the SSOT slices.
931        let p = Profile::parse("core,graph").unwrap();
932        assert!(p.includes(Family::Core));
933        assert!(!p.includes(Family::Meta));
934        assert!(p.includes(Family::Graph));
935        assert_eq!(
936            p.expected_tool_count(),
937            Family::Core.tool_names().len() + Family::Graph.tool_names().len()
938        );
939    }
940
941    #[test]
942    fn parse_custom_dedupes_repeated_token() {
943        let p = Profile::parse("core,core").unwrap();
944        assert_eq!(p, Profile::core());
945    }
946
947    #[test]
948    fn parse_custom_with_full_subsumes() {
949        let p = Profile::parse("graph,full").unwrap();
950        assert_eq!(p, Profile::full());
951    }
952
953    #[test]
954    fn parse_custom_implicitly_includes_core() {
955        // Asking for just `archive` should still load core because
956        // there is no legitimate profile smaller than the 7 core tools at v0.7.0.
957        let p = Profile::parse("archive").unwrap();
958        assert!(p.includes(Family::Core));
959        assert!(p.includes(Family::Archive));
960    }
961
962    #[test]
963    fn parse_custom_unknown_family_errors() {
964        let err = Profile::parse("core,xyz").unwrap_err();
965        match err {
966            ProfileParseError::UnknownFamily(s) => assert_eq!(s, "xyz"),
967            _ => panic!("expected UnknownFamily, got {err:?}"),
968        }
969    }
970
971    #[test]
972    fn parse_rejects_mixed_case() {
973        assert!(matches!(
974            Profile::parse("Core"),
975            Err(ProfileParseError::CaseMismatch(_))
976        ));
977        assert!(matches!(
978            Profile::parse("core,Graph"),
979            Err(ProfileParseError::CaseMismatch(_))
980        ));
981    }
982
983    #[test]
984    fn parse_skips_whitespace_only_tokens() {
985        // `core, ,graph` should resolve to graph not error.
986        let p = Profile::parse("core, ,graph").unwrap();
987        assert_eq!(p, Profile::graph());
988    }
989
990    #[test]
991    fn parse_order_independence() {
992        // `graph,core` resolves identically to `core,graph`.
993        let a = Profile::parse("core,graph").unwrap();
994        let b = Profile::parse("graph,core").unwrap();
995        assert_eq!(a, b);
996    }
997
998    #[test]
999    fn parse_diagnostic_error_lists_valid_options() {
1000        let err = Profile::parse("xyz").unwrap_err();
1001        let msg = err.to_string();
1002        // The diagnostic must mention the valid profiles and families
1003        // so a confused operator can self-correct.
1004        assert!(msg.contains("core"));
1005        assert!(msg.contains("graph"));
1006        assert!(msg.contains("full"));
1007        assert!(msg.contains("xyz"));
1008    }
1009
1010    #[test]
1011    fn default_is_core() {
1012        assert_eq!(Profile::default(), Profile::core());
1013    }
1014
1015    // ---------- Tool name → family / loads ----------
1016
1017    #[test]
1018    fn family_for_tool_resolves_every_baseline_name() {
1019        // Source-anchored at crate::mcp::registry::tool_definitions() — if any
1020        // tool here is missing from `for_tool`, the family map is
1021        // out of sync and `--profile <family>` would silently miss it.
1022        let baseline = [
1023            // core
1024            "memory_store",
1025            "memory_recall",
1026            "memory_list",
1027            "memory_get",
1028            "memory_search",
1029            // core (v0.7 B1 addition)
1030            "memory_load_family",
1031            // core (v0.7 B2 addition)
1032            "memory_smart_load",
1033            // lifecycle
1034            "memory_update",
1035            "memory_delete",
1036            "memory_forget",
1037            "memory_gc",
1038            "memory_promote",
1039            // graph
1040            "memory_kg_query",
1041            "memory_kg_timeline",
1042            "memory_kg_invalidate",
1043            "memory_link",
1044            "memory_get_links",
1045            "memory_entity_register",
1046            "memory_entity_get_by_alias",
1047            "memory_get_taxonomy",
1048            // graph (v0.7.0 I4 addition)
1049            "memory_replay",
1050            // graph (v0.7 H4 addition)
1051            "memory_verify",
1052            // graph (v0.7 J7 addition)
1053            "memory_find_paths",
1054            // governance
1055            "memory_pending_list",
1056            "memory_pending_approve",
1057            "memory_pending_reject",
1058            "memory_namespace_set_standard",
1059            "memory_namespace_get_standard",
1060            "memory_namespace_clear_standard",
1061            "memory_subscribe",
1062            "memory_unsubscribe",
1063            // power
1064            "memory_consolidate",
1065            "memory_detect_contradiction",
1066            "memory_check_duplicate",
1067            "memory_auto_tag",
1068            "memory_expand_query",
1069            "memory_inbox",
1070            // power (v0.7 K7 additions — subscription reliability)
1071            "memory_subscription_replay",
1072            "memory_subscription_dlq_list",
1073            // power (v0.7 K8 addition — per-agent quota status)
1074            "memory_quota_status",
1075            // power (v0.7.0 Task 4/8 — substrate-native reflection primitive)
1076            "memory_reflect",
1077            // meta
1078            "memory_capabilities",
1079            "memory_agent_register",
1080            "memory_agent_list",
1081            "memory_session_start",
1082            "memory_stats",
1083            // archive
1084            "memory_archive_list",
1085            "memory_archive_purge",
1086            "memory_archive_restore",
1087            "memory_archive_stats",
1088            // other
1089            "memory_list_subscriptions",
1090            "memory_notify",
1091            // other (v0.7.0 L1-5 — Agent Skills substrate)
1092            "memory_skill_register",
1093            "memory_skill_list",
1094            "memory_skill_get",
1095            "memory_skill_resource",
1096            "memory_skill_export",
1097            // other (v0.7.0 L2-6 — issue #671: reflections become skills)
1098            "memory_skill_promote_from_reflection",
1099            // v0.7.0 L2-7 (issue #672) — reflection-skill composition.
1100            "memory_skill_compositional_context",
1101            // v0.7.0 QW-1 — file-backed reflection chain export (Family::Power).
1102            "memory_export_reflection",
1103            // v0.7.0 QW-3 follow-up — context-offload substrate (Family::Power).
1104            "memory_offload",
1105            "memory_deref",
1106            // v0.7.0 WT-1-C (curator-pass atomisation) — Family::Power.
1107            "memory_atomise",
1108            // v0.7.0 Form 3 (#756) — multi-step ingest orchestrator.
1109            "memory_ingest_multistep",
1110            // v0.7.0 Form 5 (issue #758) — calibration sweep over the
1111            // shadow-mode observation table (Family::Power).
1112            "memory_calibrate_confidence",
1113            // v0.7.0 (issues #224 + #311) — Phase 3 Memory Sharing &
1114            // Sync RFC pulled forward per operator directive
1115            // `28860423-d12c-4959-bc8b-8fa9a94a33d9`.
1116            "memory_share",
1117        ];
1118        assert_eq!(
1119            baseline.len(),
1120            66,
1121            "baseline list = 43 (v0.6.3.1) + 1 (v0.7.0 I4 memory_replay) + \
1122             1 (v0.7 H4 memory_verify) + 1 (v0.7 B1 memory_load_family) + \
1123             1 (v0.7 B2 memory_smart_load) + \
1124             2 (v0.7 K7 memory_subscription_replay + memory_subscription_dlq_list) + \
1125             1 (v0.7 J7 memory_find_paths) + 1 (v0.7 K8 memory_quota_status) + \
1126             1 (v0.7.0 Task 4/8 memory_reflect) + \
1127             5 (v0.7.0 L1-5 skill tools) + \
1128             1 (v0.7.0 L2-6 memory_skill_promote_from_reflection) + \
1129             1 (v0.7.0 L2-7 memory_skill_compositional_context) + \
1130             1 (v0.7.0 QW-1 memory_export_reflection) + \
1131             2 (v0.7.0 QW-3 follow-up memory_offload + memory_deref) + \
1132             1 (v0.7.0 WT-1-C memory_atomise) + \
1133             1 (v0.7.0 Form 3 memory_ingest_multistep) + \
1134             1 (v0.7.0 Form 5 memory_calibrate_confidence) + \
1135             1 (v0.7.0 issues #224 + #311 memory_share) = 66"
1136        );
1137        for name in baseline {
1138            assert!(
1139                Family::for_tool(name).is_some(),
1140                "Family::for_tool({name}) returned None — update the family map"
1141            );
1142        }
1143    }
1144
1145    #[test]
1146    fn family_for_tool_returns_none_for_unknown() {
1147        assert!(Family::for_tool("memory_does_not_exist").is_none());
1148        assert!(Family::for_tool("").is_none());
1149    }
1150
1151    #[test]
1152    fn loads_includes_core_tools_under_core_profile() {
1153        let p = Profile::core();
1154        assert!(p.loads("memory_store"));
1155        assert!(p.loads("memory_recall"));
1156        assert!(!p.loads("memory_kg_query"));
1157        // memory_capabilities is always-on bootstrap.
1158        assert!(p.loads("memory_capabilities"));
1159    }
1160
1161    #[test]
1162    fn loads_full_profile_includes_every_tool() {
1163        let p = Profile::full();
1164        // Every tool in the baseline must load under full.
1165        for name in [
1166            "memory_store",
1167            "memory_kg_query",
1168            "memory_consolidate",
1169            "memory_archive_list",
1170            "memory_notify",
1171            "memory_capabilities",
1172        ] {
1173            assert!(p.loads(name), "full profile should load {name}");
1174        }
1175    }
1176
1177    #[test]
1178    fn loads_unknown_tool_returns_false() {
1179        let p = Profile::full();
1180        assert!(!p.loads("memory_does_not_exist"));
1181    }
1182
1183    #[test]
1184    fn always_on_tools_loaded_in_every_profile() {
1185        for p in [
1186            Profile::core(),
1187            Profile::graph(),
1188            Profile::admin(),
1189            Profile::power(),
1190            Profile::full(),
1191        ] {
1192            for name in ALWAYS_ON_TOOLS {
1193                assert!(p.loads(name), "{name} must load in every profile");
1194            }
1195        }
1196    }
1197}