Skip to main content

wm_tools/
profiles.rs

1//! Tool surface profiles — curated release surfaces for the tool registry.
2//!
3//! 229 tools is an archive, not a v1 product. Profiles curate which tools the
4//! `wm` meta-tool can route to (and which are registered at the boundary) so
5//! `wm serve --profile curated` presents the differentiated surface — the
6//! learned memory hierarchy — instead of everything.
7//!
8//! The full registry is always built first (internal subsystems like the
9//! karma ledger, friction logging and the governance pipeline need their
10//! tools regardless), then filtered before the meta-tools are layered on.
11
12#![forbid(unsafe_code)]
13
14use wm_dispatch::{ToolRegistry, ToolRegistryBuilder};
15
16/// A named tool surface. `prefixes` are tool-name prefixes; a tool matches
17/// if its name starts with any prefix. `["*"]` means no filtering.
18#[derive(Debug, Clone, Copy)]
19pub struct ToolProfile {
20    /// Profile name (`full`, `curated`, `minimal`, or `allowlist`).
21    pub name: &'static str,
22    /// Matching prefixes, or `["*"]` for the full surface.
23    pub prefixes: &'static [&'static str],
24}
25
26/// The full surface — every tool registered. Library/daemon default.
27pub static PROFILE_FULL: ToolProfile = ToolProfile {
28    name: "full",
29    prefixes: &["*"],
30};
31
32/// The memory-hierarchy product surface: memory, sessions, transactions,
33/// the claims ledger, receipt evidence, and read-only galaxy/observability
34/// helpers.
35///
36/// `tools.list` and `gnosis` need no prefix here: `tools.list` is layered
37/// on with the meta-tools *after* filtering, and `gnosis` matches its own
38/// base tool. (The contract check flags a stale `tools.list` prefix as a
39/// dead route — that is how it was found, 2026-08-29.)
40///
41/// Deliberately excludes: destructive galaxy operations (purge/transfer/
42/// restore), the RSI friction/redteam/improve loop, sangha mesh, self-play,
43/// imagination, and polyglot/cyberbrain internals — those stay reachable
44/// under `--profile full`.
45pub static PROFILE_CURATED: ToolProfile = ToolProfile {
46    name: "curated",
47    prefixes: &[
48        "memory",
49        "session",
50        "claims",
51        "receipts",
52        "transaction",
53        "gnosis",
54    ],
55};
56
57/// The tightest surface: create/read/list/query/search/chat + discovery.
58pub static PROFILE_MINIMAL: ToolProfile = ToolProfile {
59    name: "minimal",
60    prefixes: &[
61        "memory.create",
62        "memory.read",
63        "memory.list",
64        "memory.query",
65        "memory.search",
66        "memory.chat",
67        "memory.associate",
68        "memory.associations",
69        "gnosis",
70    ],
71};
72
73/// The ultimate single-tool PRAY meta-tool surface: zero schema bloat.
74/// (PRAY = Polymorphic Resonant Adaptive Yoga; renamed from PRAT in v9.2.)
75pub static PROFILE_PRAY: ToolProfile = ToolProfile {
76    name: "pray",
77    prefixes: &["whitemagic"],
78};
79
80/// Deprecated alias kept for back-compat — renamed to [`PROFILE_PRAY`] in v9.2.
81#[deprecated(since = "9.2.0", note = "renamed to PROFILE_PRAY")]
82pub use self::PROFILE_PRAY as PROFILE_PRAT;
83
84/// A named, task-focused tool pack (Q04-HER.P2).
85///
86/// Packs are a thin data layer over the profile/allowlist mechanism: each
87/// pack declares tool-name prefixes and resolves to an `allowlist`-style
88/// surface. They do not add a second registry, and they are not advertised
89/// as a supported surface until a release carries them.
90#[derive(Debug, Clone, Copy)]
91pub struct ToolPack {
92    /// Pack name (`continuity`, `research`, `coding`, `ops`).
93    pub name: &'static str,
94    /// One-line task description.
95    pub description: &'static str,
96    /// Matching tool-name prefixes.
97    pub prefixes: &'static [&'static str],
98}
99
100/// Capture and resume: the memory hierarchy, session continuity, and receipt
101/// evidence.
102pub static PACK_CONTINUITY: ToolPack = ToolPack {
103    name: "continuity",
104    description: "capture, search, resume, replay, checkpoint sessions and emit receipts",
105    prefixes: &["memory", "session", "receipts", "gnosis"],
106};
107
108/// Evidence-oriented retrieval: search/read with claims and gnosis.
109pub static PACK_RESEARCH: ToolPack = ToolPack {
110    name: "research",
111    description: "evidence-oriented search, reads, claims and gnosis",
112    prefixes: &[
113        "memory.search",
114        "memory.read",
115        "memory.hybrid_recall",
116        "memory.query",
117        "session.continuity",
118        "session.replay",
119        "claims",
120        "gnosis",
121    ],
122};
123
124/// Project work: create/update memories, sessions, transaction snapshots.
125pub static PACK_CODING: ToolPack = ToolPack {
126    name: "coding",
127    description: "project memory writes, sessions and transaction snapshots",
128    prefixes: &[
129        "memory.create",
130        "memory.update",
131        "memory.read",
132        "memory.search",
133        "session",
134        "transaction",
135    ],
136};
137
138/// Operational visibility: telemetry, breakers and continuity reads.
139pub static PACK_OPS: ToolPack = ToolPack {
140    name: "ops",
141    description: "telemetry records/rollups/retention, breakers, continuity reads",
142    prefixes: &[
143        "telemetry",
144        "breaker",
145        "memory.search",
146        "memory.read",
147        "session.continuity",
148    ],
149};
150
151/// All shipped packs.
152pub static PACKS: &[&ToolPack] = &[&PACK_CONTINUITY, &PACK_RESEARCH, &PACK_CODING, &PACK_OPS];
153
154/// Look up a pack by name (case-insensitive).
155#[must_use]
156pub fn pack_from_name(name: &str) -> Option<&'static ToolPack> {
157    let name = name.trim().to_ascii_lowercase();
158    PACKS.iter().copied().find(|pack| pack.name == name)
159}
160
161/// The available pack names, for refusal messages.
162#[must_use]
163pub fn pack_names() -> Vec<&'static str> {
164    PACKS.iter().map(|pack| pack.name).collect()
165}
166
167/// Resolve a pack to an allowlist-style profile. The returned name is
168/// `pack:<name>` so contracts disclose which pack produced the surface.
169#[must_use]
170pub fn profile_from_pack(pack: &ToolPack) -> &'static ToolProfile {
171    Box::leak(Box::new(ToolProfile {
172        name: Box::leak(format!("pack:{}", pack.name).into_boxed_str()),
173        prefixes: pack.prefixes,
174    }))
175}
176
177/// Resolve the active tool surface with packs in the precedence chain:
178///
179/// 1. `WM_TOOL_ALLOWLIST` — an explicit prefix allowlist always wins.
180/// 2. `WM_TOOL_PACK` (or `wm serve --pack`) — a task-focused pack.
181/// 3. CLI `--profile` / `WM_TOOL_PROFILE`.
182/// 4. Caller default.
183///
184/// Unknown pack names warn and fall through to profile resolution.
185#[must_use]
186pub fn resolve_tool_surface(
187    cli_profile: Option<&str>,
188    env_profile: Option<&str>,
189    env_allowlist: Option<&str>,
190    env_pack: Option<&str>,
191) -> &'static ToolProfile {
192    if let Some(allow) = env_allowlist {
193        if let Some(profile) = allowlist_from_env(allow) {
194            tracing::info!(
195                allowlist = %allow,
196                "WM_TOOL_ALLOWLIST tool surface in effect"
197            );
198            return Box::leak(Box::new(profile));
199        }
200    }
201    if let Some(name) = env_pack {
202        if let Some(pack) = pack_from_name(name) {
203            tracing::info!(pack = pack.name, "WM_TOOL_PACK tool surface in effect");
204            return profile_from_pack(pack);
205        }
206        tracing::warn!(
207            pack = name,
208            available = ?pack_names(),
209            "unknown tool pack — falling back to profile resolution"
210        );
211    }
212    resolve_tool_profile(cli_profile, env_profile, None)
213}
214
215/// Look up a profile by name (`full`, `curated`, `minimal`, `pray`; `prat` kept as a deprecated alias).
216#[must_use]
217pub fn profile_from_name(name: &str) -> Option<&'static ToolProfile> {
218    match name.trim().to_ascii_lowercase().as_str() {
219        "full" => Some(&PROFILE_FULL),
220        "curated" => Some(&PROFILE_CURATED),
221        "minimal" => Some(&PROFILE_MINIMAL),
222        "pray" => Some(&PROFILE_PRAY),
223        // Deprecated alias: pre-v9.2 configs used `--profile prat`.
224        "prat" => Some(&PROFILE_PRAY),
225        _ => None,
226    }
227}
228
229/// Resolve the active tool profile with explicit precedence:
230///
231/// 1. `WM_TOOL_ALLOWLIST` — an explicit prefix allowlist always wins.
232/// 2. CLI `--profile` flag.
233/// 3. `WM_TOOL_PROFILE` environment variable.
234/// 4. Default: `full` (library / `wm daemon`). `wm serve` overlays
235///    curated when flag and env are both absent.
236///
237/// Unknown profile names log a warning and fall back to the full surface.
238#[must_use]
239pub fn resolve_tool_profile(
240    cli_profile: Option<&str>,
241    env_profile: Option<&str>,
242    env_allowlist: Option<&str>,
243) -> &'static ToolProfile {
244    if let Some(allow) = env_allowlist {
245        if let Some(profile) = allowlist_from_env(allow) {
246            tracing::info!(
247                allowlist = %allow,
248                "WM_TOOL_ALLOWLIST tool surface in effect"
249            );
250            return Box::leak(Box::new(profile));
251        }
252    }
253    match cli_profile.or(env_profile) {
254        Some(name) => profile_from_name(name).unwrap_or_else(|| {
255            tracing::warn!(
256                profile = name,
257                "unknown tool surface profile — using full tool surface"
258            );
259            &PROFILE_FULL
260        }),
261        None => &PROFILE_FULL,
262    }
263}
264
265/// Build a profile from a comma-separated allowlist of tool-name prefixes
266/// (e.g. `memory,session,claims`). Empty segments are ignored.
267#[must_use]
268pub fn allowlist_from_env(spec: &str) -> Option<ToolProfile> {
269    let prefixes: Vec<&'static str> = spec
270        .split(',')
271        .map(str::trim)
272        .filter(|p| !p.is_empty())
273        .collect::<Vec<_>>()
274        .into_iter()
275        .map(|p| Box::leak(p.to_string().into_boxed_str()) as &'static str)
276        .collect();
277    if prefixes.is_empty() {
278        return None;
279    }
280    Some(ToolProfile {
281        name: "allowlist",
282        prefixes: Box::leak(prefixes.into_boxed_slice()),
283    })
284}
285
286/// Filter a registry to a profile. `["*"]` profiles pass the registry
287/// through untouched (zero-copy — the registry is Arc-backed).
288#[must_use]
289pub fn apply_profile(registry: ToolRegistry, profile: &ToolProfile) -> ToolRegistry {
290    if profile.prefixes.contains(&"*") {
291        return registry;
292    }
293    let mut builder = ToolRegistryBuilder::new();
294    for tool in registry.all() {
295        if matches_prefixes(tool.name(), profile.prefixes) {
296            builder.register(tool);
297        }
298    }
299    builder.build()
300}
301
302/// Whether a tool name matches any of a profile's prefixes.
303#[must_use]
304pub fn matches_prefixes(name: &str, prefixes: &[&str]) -> bool {
305    prefixes.iter().any(|p| name.starts_with(p))
306}
307
308/// The profile contract — proof that the advertised surface is the
309/// declared surface.
310///
311/// Computed at server startup from the pre-filter ("full") registry and
312/// the post-filter (registered) registry. `ok == false` means surface
313/// drift: the boundary is advertising or routing something the declared
314/// profile does not cover, or declares prefixes that match nothing (the
315/// dead-route class the curated `galaxy.list` regression came from).
316/// Persisted as `profile_contract.json` in the store root so `wm doctor`
317/// can grade the last server start against it.
318#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
319pub struct ProfileContract {
320    /// Declared profile name (`full`, `curated`, `minimal`, `allowlist`).
321    pub profile: String,
322    /// Declared prefixes (`["*"]` for full).
323    pub prefixes: Vec<String>,
324    /// Tools the declared profile should register.
325    pub expected_count: usize,
326    /// Tools actually registered post-filter.
327    pub registered_count: usize,
328    /// Declared prefixes matching zero tools (dead routes).
329    pub dead_prefixes: Vec<String>,
330    /// Registered tools the declared profile does not cover (drift).
331    pub unexpected_tools: Vec<String>,
332    /// Destructive tools on the registered surface. Informational —
333    /// destructive effects are confirm-gated in the dispatch pipeline;
334    /// curated deliberately includes `memory.delete` and friends.
335    pub destructive_tools: Vec<String>,
336    /// RFC 3339 timestamp of the check (`wm_core::time`).
337    pub verified_at: String,
338    /// Package version of the binary that computed the contract.
339    /// `None` = pre-P-PROV-5 contract (serde default keeps them readable).
340    #[serde(default)]
341    pub binary_version: Option<String>,
342    /// Surface pin (P-PROV-5, 2026-09-10, rug-pull tripwire): hex SHA-256
343    /// over the sorted registered tool names (see [`surface_hash`]). Any
344    /// tool added, removed, or renamed changes the pin; `wm doctor`
345    /// discloses it so a reviewed release surface can be pinned externally.
346    /// `None` = pre-P-PROV-5 contract.
347    #[serde(default)]
348    pub surface_hash: Option<String>,
349    /// `true` iff the registered surface is exactly the declared one.
350    pub ok: bool,
351}
352
353/// Compute the surface pin: hex SHA-256 over sorted tool names.
354///
355/// Order-insensitive, content-sensitive — the same surface always pins
356/// identically regardless of registration order.
357#[must_use]
358pub fn surface_hash(registered: &[&str]) -> String {
359    use sha2::{Digest, Sha256};
360    use std::fmt::Write as _;
361    let mut names: Vec<&str> = registered.to_vec();
362    names.sort_unstable();
363    let mut h = Sha256::new();
364    for n in names {
365        h.update(n.as_bytes());
366        h.update(b"\n");
367    }
368    // Byte-wise hex (sha2 0.11's digest output has no LowerHex impl).
369    h.finalize().iter().fold(
370        String::with_capacity(sha2::Sha256::output_size() * 2),
371        |mut out, b| {
372            let _ = write!(out, "{b:02x}");
373            out
374        },
375    )
376}
377
378/// Compute the profile contract for a server start.
379#[must_use]
380pub fn profile_contract(
381    full: &ToolRegistry,
382    filtered: &ToolRegistry,
383    profile: &ToolProfile,
384) -> ProfileContract {
385    let full_names: Vec<&str> = full.all_ref().iter().map(|t| t.name()).collect();
386    let registered: Vec<&str> = filtered.all_ref().iter().map(|t| t.name()).collect();
387
388    let star = profile.prefixes.contains(&"*");
389    let matches = |name: &str| star || profile.prefixes.iter().any(|p| name.starts_with(p));
390    let expected_count = full_names.iter().filter(|n| matches(n)).count();
391    let unexpected_tools: Vec<String> = registered
392        .iter()
393        .filter(|n| !matches(n))
394        .map(|n| (*n).to_string())
395        .collect();
396    let dead_prefixes: Vec<String> = profile
397        .prefixes
398        .iter()
399        .filter(|p| **p != "*" && !full_names.iter().any(|n| n.starts_with(**p)))
400        .map(|p| (*p).to_string())
401        .collect();
402    let destructive_tools: Vec<String> = filtered
403        .all_ref()
404        .iter()
405        .filter(|t| t.effects().destructive)
406        .map(|t| t.name().to_string())
407        .collect();
408
409    let ok = expected_count == registered.len()
410        && unexpected_tools.is_empty()
411        && dead_prefixes.is_empty();
412
413    ProfileContract {
414        profile: profile.name.to_string(),
415        prefixes: profile.prefixes.iter().map(|p| (*p).to_string()).collect(),
416        expected_count,
417        registered_count: registered.len(),
418        dead_prefixes,
419        unexpected_tools,
420        destructive_tools,
421        verified_at: wm_core::time::now_rfc3339(),
422        binary_version: Some(env!("CARGO_PKG_VERSION").to_string()),
423        surface_hash: Some(surface_hash(&registered)),
424        ok,
425    }
426}
427
428/// Persist the contract to `<store-root>/profile_contract.json` (atomic
429/// rename, same discipline as the other root state files). Best-effort:
430/// a persistence failure warns and never blocks the server start.
431pub fn save_contract(root: &std::path::Path, contract: &ProfileContract) {
432    let path = root.join("profile_contract.json");
433    let tmp = root.join(".profile_contract.json.tmp");
434    let write = serde_json::to_string_pretty(contract)
435        .map(|body| std::fs::write(&tmp, body).and_then(|()| std::fs::rename(&tmp, &path)));
436    if let Err(e) = write {
437        tracing::warn!(
438            path = %path.display(),
439            error = %e,
440            "could not persist profile contract"
441        );
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use std::sync::Arc;
449    use wm_core::Tool;
450
451    #[test]
452    fn profile_names_resolve() {
453        assert_eq!(profile_from_name("full").map(|p| p.name), Some("full"));
454        assert_eq!(
455            profile_from_name("CURATED").map(|p| p.name),
456            Some("curated")
457        );
458        assert_eq!(
459            profile_from_name("minimal").map(|p| p.name),
460            Some("minimal")
461        );
462        assert!(profile_from_name("bogus").is_none());
463    }
464
465    #[test]
466    fn curated_has_no_dead_routes() {
467        // Regression: the curated profile once contained a `galaxy.list`
468        // prefix that matched no registered tool.
469        assert!(
470            !PROFILE_CURATED
471                .prefixes
472                .iter()
473                .any(|p| p.starts_with("galaxy")),
474            "curated profile must not include galaxy prefixes"
475        );
476    }
477
478    #[test]
479    fn curated_is_the_product_surface() {
480        assert_eq!(
481            PROFILE_CURATED.prefixes,
482            &[
483                "memory",
484                "session",
485                "claims",
486                "receipts",
487                "transaction",
488                "gnosis"
489            ]
490        );
491        assert!(
492            !PROFILE_CURATED
493                .prefixes
494                .iter()
495                .any(|p| *p == "nlu.shadow_report" || *p == "tools.usage_report"),
496            "observability tools belong on the full surface"
497        );
498    }
499
500    #[test]
501    fn allowlist_parses_and_rejects_empty() {
502        assert!(allowlist_from_env("").is_none());
503        assert!(allowlist_from_env(" , ").is_none());
504        let profile = allowlist_from_env("memory, claims , session").unwrap();
505        assert_eq!(profile.name, "allowlist");
506        assert_eq!(profile.prefixes, &["memory", "claims", "session"]);
507    }
508
509    #[test]
510    fn full_profile_is_passthrough() {
511        let registry = ToolRegistry::new();
512        let out = apply_profile(registry, &PROFILE_FULL);
513        assert_eq!(out.len(), 0);
514    }
515
516    #[test]
517    fn resolve_profile_precedence() {
518        // CLI flag wins over the environment variable.
519        assert_eq!(
520            resolve_tool_profile(Some("curated"), Some("minimal"), None).name,
521            "curated"
522        );
523        // Environment is used when the CLI flag is absent.
524        assert_eq!(
525            resolve_tool_profile(None, Some("minimal"), None).name,
526            "minimal"
527        );
528        // An explicit allowlist wins over both.
529        let resolved =
530            resolve_tool_profile(Some("curated"), Some("minimal"), Some("memory,session"));
531        assert_eq!(resolved.name, "allowlist");
532        assert_eq!(resolved.prefixes, &["memory", "session"]);
533        // All absent → full surface.
534        assert_eq!(resolve_tool_profile(None, None, None).name, "full");
535        // Unknown names fall back to full.
536        assert_eq!(resolve_tool_profile(Some("bogus"), None, None).name, "full");
537        assert_eq!(resolve_tool_profile(None, Some("bogus"), None).name, "full");
538    }
539
540    struct ContractMock {
541        name: String,
542        effects: wm_core::EffectRow,
543        stats: wm_core::ToolStats,
544    }
545
546    #[async_trait::async_trait]
547    impl wm_core::Tool for ContractMock {
548        fn name(&self) -> &str {
549            &self.name
550        }
551        fn gana(&self) -> wm_core::Gana {
552            wm_core::Gana::Horn
553        }
554        fn effects(&self) -> &wm_core::EffectRow {
555            &self.effects
556        }
557        fn stats(&self) -> &wm_core::ToolStats {
558            &self.stats
559        }
560        async fn call(
561            &self,
562            _ctx: &mut wm_core::Context,
563            _args: wm_core::Args,
564        ) -> wm_core::Result<wm_core::Output> {
565            Ok(serde_json::json!({"ok": true}))
566        }
567    }
568
569    fn contract_tool(name: &str, destructive: bool) -> Arc<dyn Tool> {
570        let effects = if destructive {
571            wm_core::EffectRow {
572                destructive: true,
573                ..wm_core::EffectRow::default()
574            }
575        } else {
576            wm_core::EffectRow::default()
577        };
578        Arc::new(ContractMock {
579            name: name.into(),
580            effects,
581            stats: wm_core::ToolStats::default(),
582        })
583    }
584
585    fn contract_registry(tools: &[Arc<dyn Tool>]) -> ToolRegistry {
586        let mut builder = ToolRegistryBuilder::new();
587        for tool in tools {
588            builder.register(Arc::clone(tool));
589        }
590        builder.build()
591    }
592
593    /// Build a registry covering every `PROFILE_MINIMAL` prefix so the
594    /// dead-prefix check has nothing to flag.
595    fn minimal_registry(tools: &[Arc<dyn Tool>]) -> ToolRegistry {
596        let prefix_tools: Vec<Arc<dyn Tool>> = [
597            "memory.create",
598            "memory.read",
599            "memory.list",
600            "memory.query",
601            "memory.search",
602            "memory.chat",
603            "memory.associate",
604            "memory.associations",
605            "gnosis",
606        ]
607        .iter()
608        .map(|n| contract_tool(n, false) as Arc<dyn Tool>)
609        .collect();
610        let mut all = prefix_tools;
611        all.extend(tools.iter().cloned());
612        contract_registry(&all)
613    }
614
615    #[test]
616    fn contract_ok_when_surface_is_exact() {
617        let full = minimal_registry(&[]);
618        let filtered = contract_registry(&full.all());
619        let c = profile_contract(&full, &filtered, &PROFILE_MINIMAL);
620        assert!(c.ok);
621        assert_eq!(c.expected_count, 9);
622        assert_eq!(c.registered_count, 9);
623        assert!(c.dead_prefixes.is_empty());
624        assert!(c.unexpected_tools.is_empty());
625    }
626
627    #[test]
628    fn contract_detects_dead_prefixes_and_unexpected_tools() {
629        let alpha = contract_tool("alpha.one", false);
630        let sneaky = contract_tool("sneaky.tool", false);
631        let full = contract_registry(std::slice::from_ref(&alpha));
632        // Post-filter registry carries a tool the profile does not declare.
633        let filtered = contract_registry(&[alpha, sneaky]);
634        let c = profile_contract(
635            &full,
636            &filtered,
637            &allowlist_from_env("alpha,gamma").unwrap(),
638        );
639        assert!(!c.ok);
640        assert_eq!(c.dead_prefixes, vec!["gamma".to_string()]);
641        assert_eq!(c.unexpected_tools, vec!["sneaky.tool".to_string()]);
642        assert_eq!(c.expected_count, 1);
643        assert_eq!(c.registered_count, 2);
644    }
645
646    #[test]
647    fn contract_reports_destructive_tools_informationally() {
648        let full = minimal_registry(&[]);
649        let filtered = contract_registry(&full.all());
650        let c = profile_contract(&full, &filtered, &PROFILE_MINIMAL);
651        assert!(
652            c.ok,
653            "destructive presence is informational, not a violation"
654        );
655        assert!(c.destructive_tools.is_empty());
656
657        // Curated-style surface, computed through the real filter path:
658        // memory.delete rides the `memory` prefix by design — it must be
659        // listed, and must not fail the contract; galaxy.purge must not.
660        let curated_tools: Vec<Arc<dyn Tool>> = [
661            "memory.create",
662            "session.start",
663            "claims.list",
664            "receipts.verify",
665            "transaction.begin",
666            "gnosis",
667            "tools.list",
668            "memory.delete",
669            "galaxy.purge",
670        ]
671        .iter()
672        .map(|n| contract_tool(n, *n == "memory.delete" || *n == "galaxy.purge") as Arc<dyn Tool>)
673        .collect();
674        let full2 = contract_registry(&curated_tools);
675        let filtered2 = apply_profile(full2.clone(), &PROFILE_CURATED);
676        let c2 = profile_contract(&full2, &filtered2, &PROFILE_CURATED);
677        assert_eq!(c2.destructive_tools, vec!["memory.delete".to_string()]);
678        assert!(c2.ok);
679        assert_eq!(c2.expected_count, 7);
680        assert_eq!(c2.registered_count, 7);
681    }
682
683    #[test]
684    fn full_profile_contract_counts_everything() {
685        let tools: Vec<Arc<dyn Tool>> = vec![
686            contract_tool("memory.create", false),
687            contract_tool("galaxy.purge", true),
688        ];
689        let full = contract_registry(&tools);
690        let filtered = contract_registry(&full.all().iter().map(Arc::clone).collect::<Vec<_>>());
691        let c = profile_contract(&full, &filtered, &PROFILE_FULL);
692        assert!(c.ok);
693        assert_eq!(c.expected_count, 2);
694        assert_eq!(c.registered_count, 2);
695        assert!(c.dead_prefixes.is_empty());
696    }
697
698    // P-PROV-5 (2026-09-10, rug-pull tripwire): the surface pin must be
699    // order-insensitive (registration order is not surface content) and
700    // content-sensitive (any add/remove/rename repins).
701    #[test]
702    fn surface_hash_is_order_insensitive_but_content_sensitive() {
703        let a = surface_hash(&["memory.create", "session.start", "gnosis"]);
704        let b = surface_hash(&["gnosis", "memory.create", "session.start"]);
705        assert_eq!(a, b, "registration order must not move the pin");
706        assert_eq!(a.len(), 64, "hex SHA-256 shape");
707        assert_ne!(
708            a,
709            surface_hash(&["memory.create", "session.start"]),
710            "removal must repin"
711        );
712        assert_ne!(
713            a,
714            surface_hash(&["memory.create", "session.start", "gnosis", "sneaky.tool"]),
715            "addition must repin"
716        );
717        assert_ne!(
718            a,
719            surface_hash(&["memory.create", "session.start", "gnosis2"]),
720            "rename must repin"
721        );
722    }
723
724    #[test]
725    fn contract_carries_binary_identity_and_surface_pin() {
726        let full = minimal_registry(&[]);
727        let filtered = contract_registry(&full.all());
728        let c = profile_contract(&full, &filtered, &PROFILE_MINIMAL);
729        assert!(c.ok);
730        assert_eq!(
731            c.binary_version.as_deref(),
732            Some(env!("CARGO_PKG_VERSION")),
733            "contract must name the binary that produced it"
734        );
735        let names: Vec<&str> = filtered.all_ref().iter().map(|t| t.name()).collect();
736        assert_eq!(
737            c.surface_hash.as_deref(),
738            Some(surface_hash(&names).as_str()),
739            "pin must cover exactly the registered surface"
740        );
741    }
742
743    #[test]
744    fn legacy_contract_without_pin_fields_deserializes() {
745        // Pre-P-PROV-5 contracts have neither pin field; serde defaults
746        // must admit them (store roots hold live history).
747        let legacy = serde_json::json!({
748            "profile": "curated",
749            "prefixes": ["memory"],
750            "expected_count": 1,
751            "registered_count": 1,
752            "dead_prefixes": [],
753            "unexpected_tools": [],
754            "destructive_tools": [],
755            "verified_at": "2026-08-29T00:00:00Z",
756            "ok": true,
757        });
758        let c: ProfileContract = serde_json::from_value(legacy).unwrap();
759        assert!(c.ok);
760        assert_eq!(c.binary_version, None);
761        assert_eq!(c.surface_hash, None);
762    }
763
764    #[test]
765    fn pray_profile_is_single_surface() {
766        assert_eq!(PROFILE_PRAY.prefixes, &["whitemagic"]);
767        assert_eq!(profile_from_name("pray").unwrap().name, "pray");
768        // Deprecated `prat` alias keeps resolving to the same surface.
769        assert_eq!(profile_from_name("prat").unwrap().name, "pray");
770    }
771
772    #[test]
773    fn packs_resolve_and_are_unique() {
774        assert_eq!(
775            pack_from_name("Continuity").map(|p| p.name),
776            Some("continuity")
777        );
778        assert!(pack_from_name("bogus").is_none());
779        assert_eq!(
780            pack_names(),
781            vec!["continuity", "research", "coding", "ops"]
782        );
783        for pack in PACKS {
784            assert!(!pack.prefixes.is_empty(), "{} has prefixes", pack.name);
785            assert!(
786                !pack.description.is_empty(),
787                "{} has a description",
788                pack.name
789            );
790        }
791    }
792
793    #[test]
794    fn pack_precedence_and_unknown_fallback() {
795        // Explicit allowlist wins over a pack.
796        let allow = resolve_tool_surface(None, None, Some("memory,session"), Some("continuity"));
797        assert_eq!(allow.name, "allowlist");
798        // Pack wins over the CLI profile and environment profile.
799        let pack = resolve_tool_surface(Some("minimal"), Some("minimal"), None, Some("continuity"));
800        assert_eq!(pack.name, "pack:continuity");
801        assert!(pack.prefixes.contains(&"session"));
802        // Unknown pack warns and falls back to profile resolution.
803        let fallback = resolve_tool_surface(Some("minimal"), None, None, Some("bogus"));
804        assert_eq!(fallback.name, "minimal");
805        // No pack → unchanged behavior.
806        assert_eq!(
807            resolve_tool_surface(Some("curated"), None, None, None).name,
808            "curated"
809        );
810    }
811}