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