Skip to main content

net/adapter/net/behavior/
capability.rs

1//! Capability Announcements (CAP-ANN) for Phase 4A.
2//!
3//! This module provides:
4//! - `CapabilitySet` - Structured capability representation
5//! - `CapabilityAnnouncement` - Versioned capability broadcast
6//! - `CapabilityFilter` - Query capabilities by various criteria
7//! - `CardinalityProvider` - Trait used by the predicate planner
8//!
9//! The legacy `CapabilityIndex` in-memory store was removed in
10//! Phase 3B of the multifold migration. Membership + cardinality
11//! data now live on the `CapabilityFold` (see
12//! `behavior/fold/capability`); downstream callers go through
13//! `MeshNode`'s fold helpers or `capability_bridge`.
14
15use serde::{Deserialize, Serialize};
16use std::cell::OnceCell;
17use std::collections::{BTreeMap, HashSet};
18use std::hash::Hash;
19
20use crate::adapter::net::behavior::tag::Tag;
21
22/// Version-discriminator byte for the compact (postcard) wire format
23/// used by [`CapabilitySet::to_bytes_compact`] and
24/// [`CapabilityAnnouncement::to_bytes_compact`]. JSON serializations
25/// start with `b'{'` (`0x7B`); compact serializations start with this
26/// byte. Any value other than `b'{'` or this constant in the leading
27/// byte position causes `from_bytes` to return `None`.
28///
29/// The numeric value is fixed at the wire-format level — bumping it
30/// is a wire-protocol break.
31const COMPACT_FORMAT_TAG: u8 = 0x01;
32
33// ============================================================================
34// Hardware Capabilities
35// ============================================================================
36
37/// GPU vendor enumeration
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
39#[repr(u8)]
40pub enum GpuVendor {
41    /// Unrecognized or unspecified GPU vendor.
42    #[default]
43    Unknown = 0,
44    /// NVIDIA Corporation.
45    Nvidia = 1,
46    /// Advanced Micro Devices (AMD).
47    Amd = 2,
48    /// Intel Corporation.
49    Intel = 3,
50    /// Apple Inc. (e.g., M-series integrated GPU).
51    Apple = 4,
52    /// Qualcomm (e.g., Adreno GPU).
53    Qualcomm = 5,
54}
55
56impl From<u8> for GpuVendor {
57    fn from(v: u8) -> Self {
58        match v {
59            1 => Self::Nvidia,
60            2 => Self::Amd,
61            3 => Self::Intel,
62            4 => Self::Apple,
63            5 => Self::Qualcomm,
64            _ => Self::Unknown,
65        }
66    }
67}
68
69/// GPU information
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct GpuInfo {
72    /// GPU vendor
73    pub vendor: GpuVendor,
74    /// Model name (e.g., "RTX 4090", "M2 Ultra")
75    pub model: String,
76    /// VRAM in GB
77    pub vram_gb: u32,
78    /// Compute units / SMs
79    pub compute_units: u16,
80    /// Tensor cores (0 if none)
81    pub tensor_cores: u16,
82    /// FP16 TFLOPS (scaled by 10, e.g., 825 = 82.5 TFLOPS).
83    ///
84    /// Widened from `u16` to `u32` because the old ceiling
85    /// (`u16::MAX / 10 ≈ 6.5 PFLOPS`) silently saturated on any
86    /// aggregated cluster figure worth reporting; individual GPUs
87    /// still fit in `u16` but operators roll these up per-node
88    /// and per-mesh.
89    pub fp16_tflops_x10: u32,
90}
91
92impl Default for GpuInfo {
93    fn default() -> Self {
94        Self {
95            vendor: GpuVendor::Unknown,
96            model: String::new(),
97            vram_gb: 0,
98            compute_units: 0,
99            tensor_cores: 0,
100            fp16_tflops_x10: 0,
101        }
102    }
103}
104
105impl GpuInfo {
106    /// Create new GPU info
107    pub fn new(vendor: GpuVendor, model: impl Into<String>, vram_gb: u32) -> Self {
108        Self {
109            vendor,
110            model: model.into(),
111            vram_gb,
112            ..Default::default()
113        }
114    }
115
116    /// Set compute units
117    pub fn with_compute_units(mut self, units: u16) -> Self {
118        self.compute_units = units;
119        self
120    }
121
122    /// Set tensor cores
123    pub fn with_tensor_cores(mut self, cores: u16) -> Self {
124        self.tensor_cores = cores;
125        self
126    }
127
128    /// Set FP16 performance.
129    ///
130    /// Clamped at `u32::MAX` to be explicit about the ceiling: a
131    /// pathological f32 (NaN, negative, > ~4.3e8 TFLOPS) saturates
132    /// rather than wrapping to a garbage value.
133    pub fn with_fp16_tflops(mut self, tflops: f32) -> Self {
134        let scaled = (tflops * 10.0).max(0.0);
135        self.fp16_tflops_x10 = if scaled.is_finite() && scaled < u32::MAX as f32 {
136            scaled as u32
137        } else {
138            u32::MAX
139        };
140        self
141    }
142}
143
144/// Accelerator type
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
146#[repr(u8)]
147pub enum AcceleratorType {
148    /// Unrecognized or unspecified accelerator type.
149    #[default]
150    Unknown = 0,
151    /// Tensor Processing Unit (e.g., Google TPU).
152    Tpu = 1,
153    /// Neural Processing Unit for on-device AI inference.
154    Npu = 2,
155    /// Field-Programmable Gate Array.
156    Fpga = 3,
157    /// Application-Specific Integrated Circuit.
158    Asic = 4,
159    /// Digital Signal Processor.
160    Dsp = 5,
161}
162
163impl From<u8> for AcceleratorType {
164    fn from(v: u8) -> Self {
165        match v {
166            1 => Self::Tpu,
167            2 => Self::Npu,
168            3 => Self::Fpga,
169            4 => Self::Asic,
170            5 => Self::Dsp,
171            _ => Self::Unknown,
172        }
173    }
174}
175
176/// Accelerator information (TPU, NPU, etc.)
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct AcceleratorInfo {
179    /// Accelerator type
180    pub accel_type: AcceleratorType,
181    /// Model/name
182    pub model: String,
183    /// Memory in GB (if applicable)
184    pub memory_gb: u32,
185    /// TOPS (tera operations per second, scaled by 10)
186    pub tops_x10: u16,
187}
188
189impl AcceleratorInfo {
190    /// Create new accelerator info
191    pub fn new(accel_type: AcceleratorType, model: impl Into<String>) -> Self {
192        Self {
193            accel_type,
194            model: model.into(),
195            memory_gb: 0,
196            tops_x10: 0,
197        }
198    }
199}
200
201/// Hardware capabilities
202#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
203pub struct HardwareCapabilities {
204    /// CPU cores
205    pub cpu_cores: u16,
206    /// CPU threads (if different from cores due to SMT)
207    pub cpu_threads: u16,
208    /// Total memory in GB
209    pub memory_gb: u32,
210    /// GPU info (if present)
211    pub gpu: Option<GpuInfo>,
212    /// Additional GPUs (for multi-GPU setups)
213    pub additional_gpus: Vec<GpuInfo>,
214    /// Storage in GB
215    pub storage_gb: u64,
216    /// Network bandwidth in Gbps
217    pub network_gbps: u32,
218    /// Accelerators (TPU, NPU, etc.)
219    pub accelerators: Vec<AcceleratorInfo>,
220}
221
222impl HardwareCapabilities {
223    /// Create new hardware capabilities
224    pub fn new() -> Self {
225        Self::default()
226    }
227
228    /// Set CPU cores
229    pub fn with_cpu(mut self, cores: u16, threads: u16) -> Self {
230        self.cpu_cores = cores;
231        self.cpu_threads = threads;
232        self
233    }
234
235    /// Set memory
236    pub fn with_memory(mut self, memory_gb: u32) -> Self {
237        self.memory_gb = memory_gb;
238        self
239    }
240
241    /// Set primary GPU
242    pub fn with_gpu(mut self, gpu: GpuInfo) -> Self {
243        self.gpu = Some(gpu);
244        self
245    }
246
247    /// Add additional GPU
248    pub fn add_gpu(mut self, gpu: GpuInfo) -> Self {
249        self.additional_gpus.push(gpu);
250        self
251    }
252
253    /// Set storage
254    pub fn with_storage(mut self, storage_gb: u64) -> Self {
255        self.storage_gb = storage_gb;
256        self
257    }
258
259    /// Set network bandwidth
260    pub fn with_network(mut self, network_gbps: u32) -> Self {
261        self.network_gbps = network_gbps;
262        self
263    }
264
265    /// Add accelerator
266    pub fn add_accelerator(mut self, accel: AcceleratorInfo) -> Self {
267        self.accelerators.push(accel);
268        self
269    }
270
271    /// Total GPU count
272    pub fn gpu_count(&self) -> usize {
273        self.gpu.as_ref().map(|_| 1).unwrap_or(0) + self.additional_gpus.len()
274    }
275
276    /// Total VRAM across all GPUs
277    pub fn total_vram_gb(&self) -> u32 {
278        let primary = self.gpu.as_ref().map(|g| g.vram_gb).unwrap_or(0);
279        let additional: u32 = self.additional_gpus.iter().map(|g| g.vram_gb).sum();
280        primary + additional
281    }
282
283    /// Check if has any GPU
284    pub fn has_gpu(&self) -> bool {
285        self.gpu.is_some()
286    }
287
288    /// Get primary GPU vendor
289    pub fn gpu_vendor(&self) -> Option<GpuVendor> {
290        self.gpu.as_ref().map(|g| g.vendor)
291    }
292}
293
294// ============================================================================
295// Software Capabilities
296// ============================================================================
297
298/// Software/runtime capabilities
299#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
300pub struct SoftwareCapabilities {
301    /// Operating system
302    pub os: String,
303    /// OS version
304    pub os_version: String,
305    /// Runtime versions (e.g., "python:3.11", "node:20")
306    pub runtimes: Vec<(String, String)>,
307    /// Installed frameworks (e.g., "pytorch:2.1", "tensorflow:2.15")
308    pub frameworks: Vec<(String, String)>,
309    /// CUDA version (if applicable)
310    pub cuda_version: Option<String>,
311    /// Driver versions
312    pub drivers: Vec<(String, String)>,
313}
314
315impl SoftwareCapabilities {
316    /// Create new software capabilities
317    pub fn new() -> Self {
318        Self::default()
319    }
320
321    /// Set OS
322    pub fn with_os(mut self, os: impl Into<String>, version: impl Into<String>) -> Self {
323        self.os = os.into();
324        self.os_version = version.into();
325        self
326    }
327
328    /// Add runtime
329    pub fn add_runtime(mut self, name: impl Into<String>, version: impl Into<String>) -> Self {
330        self.runtimes.push((name.into(), version.into()));
331        self
332    }
333
334    /// Add framework
335    pub fn add_framework(mut self, name: impl Into<String>, version: impl Into<String>) -> Self {
336        self.frameworks.push((name.into(), version.into()));
337        self
338    }
339
340    /// Set CUDA version
341    pub fn with_cuda(mut self, version: impl Into<String>) -> Self {
342        self.cuda_version = Some(version.into());
343        self
344    }
345
346    /// Check if has a specific runtime
347    pub fn has_runtime(&self, name: &str) -> bool {
348        self.runtimes.iter().any(|(n, _)| n == name)
349    }
350
351    /// Check if has a specific framework
352    pub fn has_framework(&self, name: &str) -> bool {
353        self.frameworks.iter().any(|(n, _)| n == name)
354    }
355}
356
357// ============================================================================
358// Model Capabilities
359// ============================================================================
360
361/// Modality support
362#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
363#[repr(u8)]
364pub enum Modality {
365    /// Plain text input/output.
366    Text = 0,
367    /// Static image understanding or generation.
368    Image = 1,
369    /// Audio understanding or synthesis.
370    Audio = 2,
371    /// Video understanding or generation.
372    Video = 3,
373    /// Source code generation or analysis.
374    Code = 4,
375    /// Vector embedding production.
376    Embedding = 5,
377    /// Structured tool/function calling.
378    ToolUse = 6,
379}
380
381impl From<u8> for Modality {
382    fn from(v: u8) -> Self {
383        match v {
384            0 => Self::Text,
385            1 => Self::Image,
386            2 => Self::Audio,
387            3 => Self::Video,
388            4 => Self::Code,
389            5 => Self::Embedding,
390            6 => Self::ToolUse,
391            _ => Self::Text,
392        }
393    }
394}
395
396/// Model capability
397#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
398pub struct ModelCapability {
399    /// Unique model identifier (e.g., "llama-3.1-70b")
400    pub model_id: String,
401    /// Model family (e.g., "llama", "mistral", "claude")
402    pub family: String,
403    /// Parameter count (in billions, scaled by 10: 700 = 70B)
404    pub parameters_b_x10: u32,
405    /// Context length in tokens
406    pub context_length: u32,
407    /// Quantization (e.g., "fp16", "int8", "int4")
408    pub quantization: Option<String>,
409    /// Supported modalities
410    pub modalities: Vec<Modality>,
411    /// Estimated tokens per second (for this hardware)
412    pub tokens_per_sec: u32,
413    /// Whether model is currently loaded
414    pub loaded: bool,
415}
416
417impl ModelCapability {
418    /// Create new model capability
419    pub fn new(model_id: impl Into<String>, family: impl Into<String>) -> Self {
420        Self {
421            model_id: model_id.into(),
422            family: family.into(),
423            parameters_b_x10: 0,
424            context_length: 0,
425            quantization: None,
426            modalities: vec![Modality::Text],
427            tokens_per_sec: 0,
428            loaded: false,
429        }
430    }
431
432    /// Set parameter count in billions
433    pub fn with_parameters(mut self, billions: f32) -> Self {
434        self.parameters_b_x10 = (billions * 10.0) as u32;
435        self
436    }
437
438    /// Set context length
439    pub fn with_context_length(mut self, length: u32) -> Self {
440        self.context_length = length;
441        self
442    }
443
444    /// Set quantization
445    pub fn with_quantization(mut self, quant: impl Into<String>) -> Self {
446        self.quantization = Some(quant.into());
447        self
448    }
449
450    /// Add modality
451    pub fn add_modality(mut self, modality: Modality) -> Self {
452        if !self.modalities.contains(&modality) {
453            self.modalities.push(modality);
454        }
455        self
456    }
457
458    /// Set tokens per second
459    pub fn with_tokens_per_sec(mut self, tps: u32) -> Self {
460        self.tokens_per_sec = tps;
461        self
462    }
463
464    /// Set loaded status
465    pub fn with_loaded(mut self, loaded: bool) -> Self {
466        self.loaded = loaded;
467        self
468    }
469
470    /// Get parameter count as f32
471    pub fn parameters(&self) -> f32 {
472        self.parameters_b_x10 as f32 / 10.0
473    }
474}
475
476// ============================================================================
477// Tool Capabilities
478// ============================================================================
479
480/// Tool capability
481#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
482pub struct ToolCapability {
483    /// Unique tool identifier
484    pub tool_id: String,
485    /// Human-readable name
486    pub name: String,
487    /// Version
488    pub version: String,
489    /// Input schema (JSON Schema as string)
490    pub input_schema: Option<String>,
491    /// Output schema (JSON Schema as string)
492    pub output_schema: Option<String>,
493    /// Required capabilities/dependencies
494    pub requires: Vec<String>,
495    /// Estimated execution time in ms (for typical input)
496    pub estimated_time_ms: u32,
497    /// Whether tool is stateless
498    pub stateless: bool,
499}
500
501impl ToolCapability {
502    /// Metadata key carrying this tool's input JSON Schema.
503    ///
504    /// Phase A.5.N convention: tool input/output schemas live in
505    /// `CapabilitySet::metadata` rather than the tag wire format
506    /// (JSON contains `=`/`:`/`,` which can't round-trip through
507    /// tags). Format: `tool::<tool_id>::input_schema`.
508    pub fn input_schema_metadata_key(tool_id: &str) -> String {
509        format!("tool::{tool_id}::input_schema")
510    }
511
512    /// Metadata key carrying this tool's output JSON Schema.
513    /// See [`Self::input_schema_metadata_key`].
514    pub fn output_schema_metadata_key(tool_id: &str) -> String {
515        format!("tool::{tool_id}::output_schema")
516    }
517
518    /// Create new tool capability
519    pub fn new(tool_id: impl Into<String>, name: impl Into<String>) -> Self {
520        Self {
521            tool_id: tool_id.into(),
522            name: name.into(),
523            version: "1.0.0".into(),
524            input_schema: None,
525            output_schema: None,
526            requires: Vec::new(),
527            estimated_time_ms: 0,
528            stateless: true,
529        }
530    }
531
532    /// Set version
533    pub fn with_version(mut self, version: impl Into<String>) -> Self {
534        self.version = version.into();
535        self
536    }
537
538    /// Set input schema
539    pub fn with_input_schema(mut self, schema: impl Into<String>) -> Self {
540        self.input_schema = Some(schema.into());
541        self
542    }
543
544    /// Set output schema
545    pub fn with_output_schema(mut self, schema: impl Into<String>) -> Self {
546        self.output_schema = Some(schema.into());
547        self
548    }
549
550    /// Add requirement
551    pub fn requires(mut self, dep: impl Into<String>) -> Self {
552        self.requires.push(dep.into());
553        self
554    }
555
556    /// Set estimated time
557    pub fn with_estimated_time(mut self, ms: u32) -> Self {
558        self.estimated_time_ms = ms;
559        self
560    }
561
562    /// Set stateless flag
563    pub fn with_stateless(mut self, stateless: bool) -> Self {
564        self.stateless = stateless;
565        self
566    }
567}
568
569// ============================================================================
570// Resource Limits
571// ============================================================================
572
573/// Resource limits
574#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
575pub struct ResourceLimits {
576    /// Maximum concurrent requests
577    pub max_concurrent_requests: u32,
578    /// Maximum tokens per request
579    pub max_tokens_per_request: u32,
580    /// Rate limit (requests per minute)
581    pub rate_limit_rpm: u32,
582    /// Maximum batch size
583    pub max_batch_size: u32,
584    /// Maximum input size in bytes
585    pub max_input_bytes: u32,
586    /// Maximum output size in bytes
587    pub max_output_bytes: u32,
588}
589
590impl ResourceLimits {
591    /// Create new resource limits
592    pub fn new() -> Self {
593        Self::default()
594    }
595
596    /// Set max concurrent requests
597    pub fn with_max_concurrent(mut self, max: u32) -> Self {
598        self.max_concurrent_requests = max;
599        self
600    }
601
602    /// Set max tokens per request
603    pub fn with_max_tokens(mut self, max: u32) -> Self {
604        self.max_tokens_per_request = max;
605        self
606    }
607
608    /// Set rate limit
609    pub fn with_rate_limit(mut self, rpm: u32) -> Self {
610        self.rate_limit_rpm = rpm;
611        self
612    }
613
614    /// Set max batch size
615    pub fn with_max_batch(mut self, max: u32) -> Self {
616        self.max_batch_size = max;
617        self
618    }
619}
620
621// ============================================================================
622// Capability Scope (reserved-tag discovery filter)
623// ============================================================================
624
625/// Reserved tag prefix marking a capability set as advertised under
626/// a specific tenant. Format: `scope:tenant:<id>`.
627pub const TAG_SCOPE_TENANT_PREFIX: &str = "scope:tenant:";
628
629/// Reserved tag prefix marking a capability set as advertised under
630/// a specific region. Format: `scope:region:<name>`.
631pub const TAG_SCOPE_REGION_PREFIX: &str = "scope:region:";
632
633/// Reserved tag marking a capability set as visible only to peers
634/// in the same subnet as the announcer. Mutually exclusive with
635/// tenant / region scopes — when present, the scope resolver
636/// returns `SubnetLocal` regardless of the other reserved tags
637/// (strictest scope wins).
638pub const TAG_SCOPE_SUBNET_LOCAL: &str = "scope:subnet-local";
639
640/// Optional explicit form of the default global scope. Carries no
641/// extra meaning over absence of any `scope:*` tag — included so
642/// callers can spell their intent.
643pub const TAG_SCOPE_GLOBAL: &str = "scope:global";
644
645/// Resolved scope of a capability announcement, derived from the
646/// reserved `scope:*` tags inside the announcer's [`CapabilitySet`].
647/// Pure derivation — never stored, recomputed on each query via
648/// `behavior::fold::capability_bridge::scope_from_membership_tags`.
649///
650/// Precedence: `SubnetLocal` > tenants/regions > `Global`. A node
651/// that tags itself with both `scope:subnet-local` and
652/// `scope:tenant:foo` resolves to `SubnetLocal` (strictest wins).
653#[derive(Debug, Clone, PartialEq, Eq)]
654pub(crate) enum CapabilityScope {
655    /// No `scope:*` tag, or `scope:global` only — visible to every
656    /// query that doesn't explicitly opt out (`GlobalOnly` /
657    /// `SameSubnet`).
658    Global,
659    /// `scope:subnet-local` present — visible only under
660    /// [`ScopeFilter::SameSubnet`]. Excluded from
661    /// [`ScopeFilter::Any`] and every other filter, because the
662    /// announcer has explicitly opted out of cross-subnet
663    /// discovery.
664    SubnetLocal,
665    /// One or more `scope:tenant:*` tags, no regions, no
666    /// subnet-local.
667    Tenants(Vec<String>),
668    /// One or more `scope:region:*` tags, no tenants, no
669    /// subnet-local.
670    Regions(Vec<String>),
671    /// Both tenants and regions present. Queries match if either
672    /// list satisfies the filter (logical OR — a tenant query and
673    /// a region query against the same node are independent
674    /// concerns).
675    TenantsAndRegions {
676        /// Tenant ids declared via `scope:tenant:*` tags.
677        tenants: Vec<String>,
678        /// Region names declared via `scope:region:*` tags.
679        regions: Vec<String>,
680    },
681}
682
683/// Parse `subnet:<hex32>` and `group:<hex64>` tags out of an
684/// announcement's tag set. Used at index time so the
685/// capability-auth `may_execute` gate can look up a peer's
686/// declared membership in O(1) without re-walking tags per call.
687///
688/// Multiple `subnet:` tags on one announcement are out of model:
689/// the substrate treats subnet membership as single-valued. To
690/// keep the gate verdict deterministic across receivers — a
691/// previous implementation read whichever subnet tag the
692/// `HashSet<Tag>` iterator surfaced first, which is hash-order
693/// dependent — multiple distinct subnet tags collapse to `None`
694/// and the announcement contributes no subnet membership. Single
695/// subnet tag works as expected. All distinct `group:` tags
696/// accumulate (deterministically sorted by byte value so receivers
697/// agree on iteration order); duplicates (Eq) are removed.
698///
699/// Kept (with `#[allow(dead_code)]`) for downstream consumers
700/// (capability_bridge translates the same shape onto the fold).
701/// The legacy `CapabilityIndex` caller was removed in Phase 3B
702/// of the multifold migration.
703#[allow(dead_code)]
704pub(crate) fn parse_membership_tags(
705    tags: &HashSet<Tag>,
706) -> (Option<super::subnet::SubnetId>, Vec<super::group::GroupId>) {
707    let mut subnet_candidates: Vec<super::subnet::SubnetId> = Vec::new();
708    let mut groups: Vec<super::group::GroupId> = Vec::new();
709    for tag in tags {
710        let rendered = tag.to_string();
711        if let Some(s) = super::subnet::SubnetId::from_tag(&rendered) {
712            if !subnet_candidates.contains(&s) {
713                subnet_candidates.push(s);
714            }
715            continue;
716        }
717        if let Some(g) = super::group::GroupId::from_tag(&rendered) {
718            if !groups.contains(&g) {
719                groups.push(g);
720            }
721        }
722    }
723    // Single distinct subnet → use it; zero or multiple → no
724    // subnet membership (multiple is out-of-model malformed and
725    // would otherwise pick a hash-order-dependent winner).
726    let subnet = if subnet_candidates.len() == 1 {
727        Some(subnet_candidates[0])
728    } else {
729        None
730    };
731    // Deterministic group order so receivers agree on iteration
732    // sequence regardless of local hash randomization. Lexicographic
733    // by byte value is stable and cheap on the 32-byte payload.
734    groups.sort_by_key(|g| g.0);
735    (subnet, groups)
736}
737
738/// Caller's intent for narrowing peer discovery by reserved scope
739/// tags. The legacy `CapabilityIndex::find_nodes_scoped` /
740/// `find_best_node_scoped` callers were rewired to the
741/// `CapabilityFold` in Phase 3B; this filter still parameterizes
742/// the scope-axis decision on the fold side.
743///
744/// `Any` reproduces v1 behavior for non-`SubnetLocal` peers but
745/// excludes peers that explicitly tagged themselves
746/// `scope:subnet-local` — that tag is an opt-out from cross-subnet
747/// discovery.
748#[derive(Debug, Clone)]
749pub enum ScopeFilter<'a> {
750    /// Match every peer regardless of scope, except those tagged
751    /// `scope:subnet-local` (which always require [`Self::SameSubnet`]).
752    Any,
753    /// Match only peers with no `scope:*` tag (resolve to
754    /// `Global`). Useful for opting out of all scoped peers.
755    GlobalOnly,
756    /// Match peers whose subnet equals the caller's. The actual
757    /// subnet comparison is supplied by the caller (typically by
758    /// closing over `MeshNode::peer_subnets`); the index doesn't
759    /// own subnet state.
760    SameSubnet,
761    /// Match peers tagged `scope:tenant:<t>` OR untagged
762    /// (`Global` is permissive across tenants by design).
763    Tenant(&'a str),
764    /// Match peers tagged `scope:tenant:<t>` for any `t` in the
765    /// list, OR untagged.
766    Tenants(&'a [&'a str]),
767    /// Match peers tagged `scope:region:<r>` OR untagged.
768    Region(&'a str),
769    /// Match peers tagged `scope:region:<r>` for any `r` in the
770    /// list, OR untagged.
771    Regions(&'a [&'a str]),
772}
773
774/// Predicate: does this candidate's resolved [`CapabilityScope`]
775/// satisfy the caller's [`ScopeFilter`]?
776///
777/// `same_subnet` is supplied by the caller and is consulted only
778/// when the filter is [`ScopeFilter::SameSubnet`] or the candidate
779/// is [`CapabilityScope::SubnetLocal`] (which always requires
780/// same-subnet membership). For the warm-up case where one
781/// side's subnet isn't known yet, callers default `same_subnet`
782/// to `true` (permissive).
783pub(crate) fn matches_scope(
784    candidate_scope: &CapabilityScope,
785    filter: &ScopeFilter<'_>,
786    same_subnet: bool,
787) -> bool {
788    use CapabilityScope as S;
789    use ScopeFilter as F;
790    match (filter, candidate_scope) {
791        // SubnetLocal is asymmetric: the announcer has explicitly
792        // opted out of cross-subnet discovery, so it shows up only
793        // under SameSubnet.
794        (F::SameSubnet, S::SubnetLocal) => same_subnet,
795        (_, S::SubnetLocal) => false,
796
797        // Any matches every non-SubnetLocal peer.
798        (F::Any, _) => true,
799
800        // GlobalOnly is the strict opposite of "include scoped peers."
801        (F::GlobalOnly, S::Global) => true,
802        (F::GlobalOnly, _) => false,
803
804        // SameSubnet for non-SubnetLocal candidates falls through to
805        // the caller-supplied predicate. Permissive when subnet is
806        // unknown for either side.
807        (F::SameSubnet, _) => same_subnet,
808
809        // Global candidates match every tenant/region query —
810        // permissive default, matches the v1 expectation that a
811        // node which doesn't tag itself stays discoverable.
812        (F::Tenant(_), S::Global)
813        | (F::Tenants(_), S::Global)
814        | (F::Region(_), S::Global)
815        | (F::Regions(_), S::Global) => true,
816
817        (F::Tenant(t), S::Tenants(ts))
818        | (F::Tenant(t), S::TenantsAndRegions { tenants: ts, .. }) => ts.iter().any(|x| x == t),
819        (F::Tenant(_), S::Regions(_)) => false,
820
821        (F::Tenants(wanted), S::Tenants(ts))
822        | (F::Tenants(wanted), S::TenantsAndRegions { tenants: ts, .. }) => {
823            ts.iter().any(|x| wanted.iter().any(|w| w == x))
824        }
825        (F::Tenants(_), S::Regions(_)) => false,
826
827        (F::Region(r), S::Regions(rs))
828        | (F::Region(r), S::TenantsAndRegions { regions: rs, .. }) => rs.iter().any(|x| x == r),
829        (F::Region(_), S::Tenants(_)) => false,
830
831        (F::Regions(wanted), S::Regions(rs))
832        | (F::Regions(wanted), S::TenantsAndRegions { regions: rs, .. }) => {
833            rs.iter().any(|x| wanted.iter().any(|w| w == x))
834        }
835        (F::Regions(_), S::Tenants(_)) => false,
836    }
837}
838
839// ============================================================================
840// Capability Set
841// ============================================================================
842
843/// Complete capability set for a node.
844///
845/// Phase A.5.N.3 final shape: a typed `tags: HashSet<Tag>` plus
846/// a `metadata: BTreeMap` for data that can't safely round-trip
847/// through the tag wire format. Hardware / Software / Model /
848/// Tool / ResourceLimits are *projections* of these two fields,
849/// computed on demand via `views()` / the `From<&CapabilitySet>`
850/// impls. Typed-struct fields no longer exist on the storage
851/// shape — every read goes through the projection layer; every
852/// write goes through the typed setters which re-encode into the
853/// canonical tag set.
854#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
855pub struct CapabilitySet {
856    /// Canonical typed tag set. Holds:
857    ///
858    /// - `Tag::AxisPresent` / `Tag::AxisValue` axis-prefixed tags
859    ///   (`hardware.gpu`, `hardware.memory_gb=64`,
860    ///   `software.model.0.id=llama-3.1-70b`, …) that encode the
861    ///   five projections.
862    /// - `Tag::Reserved` cross-axis tags (`scope:tenant:foo`,
863    ///   `causal:<hex>`, `fork-of:<hex>`, `heat:*`).
864    /// - `Tag::Legacy` untyped tags (free-form strings, e.g.
865    ///   `nat:full-cone` / `nrpc:<service>`).
866    ///
867    /// Wire format emits tags in sorted `Tag::to_string()` order so
868    /// every serialization is canonical. The `HashSet` keeps O(1)
869    /// membership for in-memory lookups; the `serialize_with` hook
870    /// flattens to a sorted `Vec` on the way out. Two sides of a
871    /// signed-announcement round-trip therefore produce identical
872    /// bytes regardless of `HashSet` iteration order (which is
873    /// process-local random and would otherwise cause spurious
874    /// signature-verification failures across processes).
875    #[serde(default, serialize_with = "serialize_tags_sorted")]
876    pub tags: HashSet<Tag>,
877    /// Free-form key-value metadata.
878    ///
879    /// Phase A.5.N introduction. Carries data that doesn't fit the
880    /// typed-tag taxonomy:
881    ///
882    /// - **Tool schemas**: `tool::<tool_id>::input_schema` and
883    ///   `tool::<tool_id>::output_schema` keys hold JSON Schema
884    ///   strings (the `=`/`:`/`,` characters in JSON make these
885    ///   unsafe to round-trip through the tag wire format).
886    /// - **Intent**: `intent` key carries the application-defined
887    ///   placement intent (Phase F).
888    /// - **Colocation hints**: `colocate-with` key carries a chain
889    ///   origin hash for chain-aware placement.
890    /// - Application-defined keys (subject to the metadata size cap
891    ///   in Phase C: 4 KB soft / 16 KB hard).
892    ///
893    /// `BTreeMap` for deterministic iteration order over the wire.
894    #[serde(default)]
895    pub metadata: BTreeMap<String, String>,
896}
897
898impl CapabilitySet {
899    /// Create empty capability set
900    pub fn new() -> Self {
901        Self::default()
902    }
903
904    /// Set hardware capabilities
905    pub fn with_hardware(mut self, hardware: HardwareCapabilities) -> Self {
906        self.set_hardware(hardware);
907        self
908    }
909
910    /// Set software capabilities
911    pub fn with_software(mut self, software: SoftwareCapabilities) -> Self {
912        self.set_software(software);
913        self
914    }
915
916    /// Add model capability. Read-modify-write through `views()`
917    /// since models live in the canonical tag set as
918    /// `software.model.<i>.*` indexed-encoding.
919    pub fn add_model(mut self, model: ModelCapability) -> Self {
920        let mut models = self.views().models().clone();
921        models.push(model);
922        self.set_models(models);
923        self
924    }
925
926    /// Add tool capability. Read-modify-write through `views()`
927    /// since tools live in the canonical tag set as
928    /// `software.tool.<i>.*` indexed-encoding; schemas are mirrored
929    /// into `metadata` by `set_tools`.
930    ///
931    /// For adding more than one tool, prefer
932    /// [`Self::add_tools`] — the batch form invokes `set_tools`
933    /// exactly once instead of N times, dropping the announce-path
934    /// cost from O(N²) to O(N).
935    pub fn add_tool(mut self, tool: ToolCapability) -> Self {
936        let mut tools = self.views().tools().clone();
937        tools.push(tool);
938        self.set_tools(tools);
939        self
940    }
941
942    /// Batch counterpart to [`Self::add_tool`] — extends the current
943    /// tool list with every element of `tools` and invokes
944    /// `set_tools` exactly once. The single-`set_tools` call clears
945    /// stale tags + metadata once and re-encodes the final list, so
946    /// the cost is O(N) regardless of how many tools the iterator
947    /// yields.
948    ///
949    /// Use this from announce paths that drain a `tool_registry`
950    /// (which can hold many tools); the per-call `add_tool` rebuilds
951    /// every previously-added tool's tags + metadata, an O(N²)
952    /// pattern in the size of the registry.
953    pub fn add_tools(mut self, tools: impl IntoIterator<Item = ToolCapability>) -> Self {
954        let mut merged = self.views().tools().clone();
955        merged.extend(tools);
956        self.set_tools(merged);
957        self
958    }
959
960    /// Add a tag (parsed via the application-facing parser, which
961    /// rejects reserved cross-axis prefixes — use the dedicated
962    /// scope helpers for those). Untyped strings parse as
963    /// `Tag::Legacy`; axis-prefixed strings (`hardware.gpu`,
964    /// `software.os=linux`) parse as `AxisPresent` / `AxisValue`.
965    /// Empty tags and reserved-prefix tags are silently dropped
966    /// (the parser returns `Err` and we ignore it).
967    pub fn add_tag(mut self, tag: impl Into<String>) -> Self {
968        let s: String = tag.into();
969        if let Ok(t) = Tag::parse_user(&s) {
970            self.tags.insert(t);
971        }
972        self
973    }
974
975    /// Add a typed `BlobCapability` projection. Emits the matching
976    /// `dataforts.blob.*` tags via the projection's `write_into`.
977    /// Builder-style; producer-side counterpart to
978    /// `BlobCapability::from_capability_set`. Round-tripping
979    /// through both functions returns the original projection.
980    #[cfg(feature = "dataforts")]
981    pub fn with_blob_capability(self, blob: super::dataforts_capabilities::BlobCapability) -> Self {
982        blob.write_into(self)
983    }
984
985    /// Add a typed `GreedyCapability` projection. Emits
986    /// `dataforts.greedy.*` tags.
987    #[cfg(feature = "dataforts")]
988    pub fn with_greedy_capability(
989        self,
990        greedy: super::dataforts_capabilities::GreedyCapability,
991    ) -> Self {
992        greedy.write_into(self)
993    }
994
995    /// Add a typed `GravityCapability` projection. Emits
996    /// `dataforts.gravity.*` tags.
997    #[cfg(feature = "dataforts")]
998    pub fn with_gravity_capability(
999        self,
1000        gravity: super::dataforts_capabilities::GravityCapability,
1001    ) -> Self {
1002        gravity.write_into(self)
1003    }
1004
1005    /// Add a `scope:tenant:<id>` reserved tag, marking this
1006    /// announcement as advertised under the given tenant. Idempotent
1007    /// — repeated calls with the same id do not duplicate. Empty
1008    /// `tenant_id` is silently dropped (matches the scope resolver,
1009    /// which rejects empty ids).
1010    pub fn with_tenant_scope(mut self, tenant_id: impl Into<String>) -> Self {
1011        let id = tenant_id.into();
1012        if id.is_empty() {
1013            return self;
1014        }
1015        let tag = format!("{TAG_SCOPE_TENANT_PREFIX}{id}");
1016        if let Ok(t) = Tag::parse(&tag) {
1017            self.tags.insert(t);
1018        }
1019        self
1020    }
1021
1022    /// Add a `scope:region:<name>` reserved tag, marking this
1023    /// announcement as advertised under the given region.
1024    /// Idempotent. Empty `region` is silently dropped.
1025    pub fn with_region_scope(mut self, region: impl Into<String>) -> Self {
1026        let name = region.into();
1027        if name.is_empty() {
1028            return self;
1029        }
1030        let tag = format!("{TAG_SCOPE_REGION_PREFIX}{name}");
1031        if let Ok(t) = Tag::parse(&tag) {
1032            self.tags.insert(t);
1033        }
1034        self
1035    }
1036
1037    /// Add the `scope:subnet-local` reserved tag, opting this
1038    /// announcement out of cross-subnet discovery. The strictest
1039    /// scope wins: any tenant / region tags also present on this
1040    /// set are ignored by the scope resolver while
1041    /// `scope:subnet-local` is set. Idempotent.
1042    pub fn with_subnet_local_scope(mut self) -> Self {
1043        if let Ok(t) = Tag::parse(TAG_SCOPE_SUBNET_LOCAL) {
1044            self.tags.insert(t);
1045        }
1046        self
1047    }
1048
1049    // ========================================================================
1050    // Chain composition helpers — Phase 3 of CAPABILITY_ENHANCEMENTS_PLAN.md.
1051    //
1052    // Pure syntactic sugar over the underlying `causal:` / `fork-of:` /
1053    // `heat:` reserved-prefix tags documented in CAPABILITY_SYSTEM_PLAN.md
1054    // §2 + CAPABILITIES_SCHEMA.md "Reserved cross-axis prefixes".
1055    // Each helper is a single-line wrapper around `Tag::parse(...)` plus
1056    // `tags.insert(...)` — the substrate gains no new primitives, just
1057    // ergonomic emission paths so call sites read cleanly.
1058    //
1059    // Empty / blank chain hashes are silently dropped (matches the
1060    // scope-helper convention so a builder fed an empty value doesn't
1061    // produce a malformed tag).
1062    // ========================================================================
1063
1064    /// Declare this node holds the chain identified by `chain_hash`.
1065    ///
1066    /// Emits the `causal:<chain_hash>` reserved tag. Idempotent —
1067    /// repeated calls with the same hash do not duplicate.
1068    pub fn require_chain(mut self, chain_hash: impl AsRef<str>) -> Self {
1069        let hash = chain_hash.as_ref();
1070        if hash.is_empty() {
1071            return self;
1072        }
1073        if let Ok(t) = Tag::parse(&format!("causal:{hash}")) {
1074            self.tags.insert(t);
1075        }
1076        self
1077    }
1078
1079    /// Declare this node holds the chain `<chain_hash>` up to the
1080    /// named `tip_seq`.
1081    ///
1082    /// Emits `causal:<chain_hash>:<tip_seq>`. Per
1083    /// `CAPABILITY_SYSTEM_PLAN.md` §2: receivers downsample chains
1084    /// shorter than they need, so a peer announcing a tip_seq is
1085    /// implicitly also a holder for every prefix of that chain.
1086    pub fn require_chain_tip(mut self, chain_hash: impl AsRef<str>, tip_seq: u64) -> Self {
1087        let hash = chain_hash.as_ref();
1088        if hash.is_empty() {
1089            return self;
1090        }
1091        if let Ok(t) = Tag::parse(&format!("causal:{hash}:{tip_seq}")) {
1092            self.tags.insert(t);
1093        }
1094        self
1095    }
1096
1097    /// Declare this node holds the half-open range `[start_seq..end_seq)`
1098    /// of the chain `<chain_hash>`.
1099    ///
1100    /// Emits `causal:<chain_hash>[<start>..<end>]`. The validator
1101    /// enforces `start_seq < end_seq`; equal or inverted ranges are
1102    /// silently dropped.
1103    pub fn require_chain_range(
1104        mut self,
1105        chain_hash: impl AsRef<str>,
1106        start_seq: u64,
1107        end_seq: u64,
1108    ) -> Self {
1109        let hash = chain_hash.as_ref();
1110        if hash.is_empty() || start_seq >= end_seq {
1111            return self;
1112        }
1113        if let Ok(t) = Tag::parse(&format!("causal:{hash}[{start_seq}..{end_seq}]")) {
1114            self.tags.insert(t);
1115        }
1116        self
1117    }
1118
1119    /// Declare this node holds any of the named chains. One
1120    /// `causal:<hash>` reserved tag emitted per non-empty hash.
1121    /// Empty / blank hashes in the iterator are silently skipped.
1122    pub fn require_any_chain<I, S>(mut self, chain_hashes: I) -> Self
1123    where
1124        I: IntoIterator<Item = S>,
1125        S: AsRef<str>,
1126    {
1127        for hash in chain_hashes {
1128            self = self.require_chain(hash);
1129        }
1130        self
1131    }
1132
1133    /// Declare this chain forks from `parent_chain_hash`.
1134    ///
1135    /// Emits the `fork-of:<parent_chain_hash>` reserved tag, used
1136    /// by the chain-discovery layer for lineage walks.
1137    pub fn from_fork(mut self, parent_chain_hash: impl AsRef<str>) -> Self {
1138        let hash = parent_chain_hash.as_ref();
1139        if hash.is_empty() {
1140            return self;
1141        }
1142        if let Ok(t) = Tag::parse(&format!("fork-of:{hash}")) {
1143            self.tags.insert(t);
1144        }
1145        self
1146    }
1147
1148    /// Declare this node's heat (read-rate / activity score) for
1149    /// the named chain.
1150    ///
1151    /// `rate` is clamped to `[0.0, 1.0]` and emitted with two-decimal
1152    /// precision (`heat:<chain_hash>=0.85`). Heat is per-chain, not
1153    /// per-node; one call per chain.
1154    pub fn heat_level(mut self, chain_hash: impl AsRef<str>, rate: f64) -> Self {
1155        let hash = chain_hash.as_ref();
1156        if hash.is_empty() {
1157            return self;
1158        }
1159        let clamped = if rate.is_finite() {
1160            rate.clamp(0.0, 1.0)
1161        } else {
1162            return self;
1163        };
1164        if let Ok(t) = Tag::parse(&format!("heat:{hash}={clamped:.2}")) {
1165            self.tags.insert(t);
1166        }
1167        self
1168    }
1169
1170    /// Set resource limits
1171    pub fn with_limits(mut self, limits: ResourceLimits) -> Self {
1172        self.set_limits(limits);
1173        self
1174    }
1175
1176    /// Set or overwrite a metadata key-value entry.
1177    ///
1178    /// CR-16: silently drops writes whose key matches a
1179    /// substrate-reserved *prefix* (`tool::`). Those keys are
1180    /// authored by the substrate's own codecs (the tool codec
1181    /// emits `tool::<id>::input_schema` etc.) and user code
1182    /// must not collide with them — same shape as `Tag::parse_user`
1183    /// rejecting reserved tag prefixes.
1184    ///
1185    /// Note: the schema's `metadata_reserved` *exact-match* list
1186    /// (`intent`, `colocate-with`, `priority`, `owner`) is
1187    /// intentionally NOT gated — those are well-known *user-facing*
1188    /// scheduler hints; the substrate reads them to make placement
1189    /// decisions, but user code is expected to *set* them. The
1190    /// validator (`validate_capabilities`) does flag user writes
1191    /// onto exact-match reserved keys as a `MetadataReservedKey`
1192    /// warning so misconfiguration is visible without being fatal.
1193    ///
1194    /// Substrate-internal callers that need to emit `tool::*` keys
1195    /// use the `with_metadata_unchecked` sibling (crate-private).
1196    pub fn with_metadata(self, key: impl Into<String>, value: impl Into<String>) -> Self {
1197        let key: String = key.into();
1198        if super::schema::AXIS_SCHEMA
1199            .metadata_reserved_prefixes
1200            .iter()
1201            .any(|p| key.starts_with(*p))
1202        {
1203            return self;
1204        }
1205        self.with_metadata_unchecked(key, value)
1206    }
1207
1208    /// Internal counterpart to [`Self::with_metadata`] that bypasses
1209    /// the reserved-prefix gate. Substrate-side code that authors
1210    /// reserved metadata (`tool::<id>::input_schema` from the tool
1211    /// codec) goes through this; user code MUST use the gated
1212    /// [`Self::with_metadata`].
1213    pub(crate) fn with_metadata_unchecked(
1214        mut self,
1215        key: impl Into<String>,
1216        value: impl Into<String>,
1217    ) -> Self {
1218        self.metadata.insert(key.into(), value.into());
1219        self
1220    }
1221
1222    // ========================================================================
1223    // Mutable setters — Phase A.5.6 write-path seam.
1224    //
1225    // These are the *only* places that should write to typed-struct
1226    // state on a `CapabilitySet`. The diff engine, the FFI layer
1227    // (when applying a remote update), and any other write path go
1228    // through these methods, so Phase A.5.N can rewrite the bodies
1229    // (e.g. to re-encode into a `tag_set: HashSet<Tag>`) without
1230    // touching call sites.
1231    //
1232    // Each setter takes ownership of the new value to make the
1233    // replacement obvious (no ambiguity about whether the caller
1234    // retains a partial view) and to give the eventual tag-set
1235    // reencoder a single owned input to consume.
1236    // ========================================================================
1237
1238    /// Replace the hardware projection in-place.
1239    ///
1240    /// Phase A.5.N.3: clears every `hardware.*` tag (excluding
1241    /// `hardware.limits.*` which belongs to `ResourceLimits`) and
1242    /// re-emits the new ones via `hardware_to_tags`.
1243    pub fn set_hardware(&mut self, hardware: HardwareCapabilities) {
1244        self.tags
1245            .retain(|t| !crate::adapter::net::behavior::tag_codec::is_hardware_owned_tag(t));
1246        self.tags
1247            .extend(crate::adapter::net::behavior::tag_codec::hardware_to_tags(
1248                &hardware,
1249            ));
1250    }
1251
1252    /// Replace the software projection in-place.
1253    ///
1254    /// Phase A.5.N.3: clears every `software.*` tag (excluding
1255    /// `software.model.*` and `software.tool.*` which belong to
1256    /// model/tool sub-collections) and re-emits the new ones.
1257    pub fn set_software(&mut self, software: SoftwareCapabilities) {
1258        self.tags
1259            .retain(|t| !crate::adapter::net::behavior::tag_codec::is_software_owned_tag(t));
1260        self.tags
1261            .extend(crate::adapter::net::behavior::tag_codec::software_to_tags(
1262                &software,
1263            ));
1264    }
1265
1266    /// Replace the resource-limits projection in-place.
1267    ///
1268    /// Phase A.5.N.3: clears every `hardware.limits.*` tag and
1269    /// re-emits the new ones.
1270    pub fn set_limits(&mut self, limits: ResourceLimits) {
1271        self.tags
1272            .retain(|t| !crate::adapter::net::behavior::tag_codec::is_resource_limits_owned_tag(t));
1273        self.tags
1274            .extend(crate::adapter::net::behavior::tag_codec::resource_limits_to_tags(&limits));
1275    }
1276
1277    /// Replace the loaded-model list in-place.
1278    ///
1279    /// Phase A.5.N.3: clears every `software.model.*` tag and
1280    /// re-emits the new indexed encoding via `models_to_tags`.
1281    pub fn set_models(&mut self, models: Vec<ModelCapability>) {
1282        self.tags
1283            .retain(|t| !crate::adapter::net::behavior::tag_codec::is_models_owned_tag(t));
1284        self.tags
1285            .extend(crate::adapter::net::behavior::tag_codec::models_to_tags(
1286                &models,
1287            ));
1288    }
1289
1290    /// Replace the available-tool list in-place.
1291    ///
1292    /// Phase A.5.N.3: clears every `software.tool.*` tag, prunes
1293    /// stale `tool::<id>::*_schema` metadata, re-emits the indexed
1294    /// tag encoding, and mirrors fresh schemas into metadata.
1295    pub fn set_tools(&mut self, tools: Vec<ToolCapability>) {
1296        // Clear tool tags from the canonical set.
1297        self.tags
1298            .retain(|t| !crate::adapter::net::behavior::tag_codec::is_tools_owned_tag(t));
1299
1300        // Drop schema metadata entries for tools no longer present.
1301        let new_ids: HashSet<&str> = tools.iter().map(|t| t.tool_id.as_str()).collect();
1302        self.metadata.retain(|key, _| {
1303            let Some(rest) = key.strip_prefix("tool::") else {
1304                return true;
1305            };
1306            let Some((id, _suffix)) = rest.split_once("::") else {
1307                return true;
1308            };
1309            new_ids.contains(id)
1310        });
1311
1312        // Re-emit the tag encoding (which intentionally drops
1313        // schemas — they ride in metadata).
1314        self.tags
1315            .extend(crate::adapter::net::behavior::tag_codec::tools_to_tags(
1316                &tools,
1317            ));
1318
1319        // Mirror fresh schemas into metadata.
1320        for tool in &tools {
1321            if let Some(schema) = &tool.input_schema {
1322                self.metadata.insert(
1323                    ToolCapability::input_schema_metadata_key(&tool.tool_id),
1324                    schema.clone(),
1325                );
1326            }
1327            if let Some(schema) = &tool.output_schema {
1328                self.metadata.insert(
1329                    ToolCapability::output_schema_metadata_key(&tool.tool_id),
1330                    schema.clone(),
1331                );
1332            }
1333        }
1334    }
1335
1336    /// Check if has a specific tag.
1337    ///
1338    /// The query string is parsed via the permissive parser
1339    /// ([`Tag::parse`]) so reserved-prefix queries (`scope:tenant:foo`)
1340    /// resolve correctly. Set membership is exact: a query for
1341    /// `hardware.gpu` matches the AxisPresent tag, not an
1342    /// AxisValue with a different value.
1343    pub fn has_tag(&self, tag: &str) -> bool {
1344        let Ok(parsed) = Tag::parse(tag) else {
1345            return false;
1346        };
1347        // Separator-agnostic membership: a stored `software.os=linux`
1348        // matches a query `software.os:linux` (and vice versa). Plain
1349        // `HashSet::contains` would distinguish them via PartialEq's
1350        // separator field — see CR-1 in
1351        // `CODE_REVIEW_2026_05_10_CAPABILITY_SYSTEM_2.md`.
1352        self.tags.iter().any(|t| t.semantic_eq(&parsed))
1353    }
1354
1355    /// Check if has a specific model.
1356    ///
1357    /// Phase A.5.N.3: scans for `software.model.<i>.id=<model_id>`
1358    /// directly in the canonical tag set rather than reconstructing
1359    /// the full `Vec<ModelCapability>` via `views()`.
1360    pub fn has_model(&self, model_id: &str) -> bool {
1361        self.has_indexed_software_value("model.", "id", model_id)
1362    }
1363
1364    /// Check if has a specific tool.
1365    ///
1366    /// Phase A.5.N.3: scans for `software.tool.<i>.tool_id=<tool_id>`
1367    /// directly in the canonical tag set.
1368    pub fn has_tool(&self, tool_id: &str) -> bool {
1369        self.has_indexed_software_value("tool.", "tool_id", tool_id)
1370    }
1371
1372    /// Shared scan body for `has_model` / `has_tool` — looks for a
1373    /// `software.<family_prefix><idx>.<sub_key>=<expected_value>` tag
1374    /// (e.g. `software.model.0.id=llama-3.1-7b`).
1375    ///
1376    /// Performance note: matches `Tag::AxisValue` directly to avoid
1377    /// `Tag::axis_key()`'s per-tag `String` clone. The value compare
1378    /// runs first because most tags in the set won't carry the target
1379    /// value — that lets the key parse (`strip_prefix` + `split_once`)
1380    /// run only on the small set of value-matching candidates. See
1381    /// `docs/misc/PERF_AUDIT_2026_05_28_CAPABILITY.md` fix #5.
1382    fn has_indexed_software_value(
1383        &self,
1384        family_prefix: &str,
1385        sub_key: &str,
1386        expected_value: &str,
1387    ) -> bool {
1388        use crate::adapter::net::behavior::tag::TaxonomyAxis;
1389        self.tags.iter().any(|tag| match tag {
1390            Tag::AxisValue {
1391                axis: TaxonomyAxis::Software,
1392                key,
1393                value,
1394                ..
1395            } if value == expected_value => {
1396                let Some(rest) = key.strip_prefix(family_prefix) else {
1397                    return false;
1398                };
1399                let Some((_idx, sub)) = rest.split_once('.') else {
1400                    return false;
1401                };
1402                sub == sub_key
1403            }
1404            _ => false,
1405        })
1406    }
1407
1408    /// Check if has GPU.
1409    ///
1410    /// Phase A.5.N.3: looks for the `hardware.gpu` AxisPresent
1411    /// marker directly. Cheaper than reconstructing the full
1412    /// `HardwareCapabilities` projection.
1413    pub fn has_gpu(&self) -> bool {
1414        use crate::adapter::net::behavior::tag::TaxonomyAxis;
1415        self.tags.contains(&Tag::AxisPresent {
1416            axis: TaxonomyAxis::Hardware,
1417            key: "gpu".into(),
1418        })
1419    }
1420
1421    /// First `AxisValue` tag matching `(axis, key)`, returning its
1422    /// value if present. Linear in `tags` count with early return.
1423    ///
1424    /// Phase A.5.N.3 fast-path helper for single-field predicates
1425    /// (`CapabilityFilter::matches` memory / VRAM checks). Avoids
1426    /// forcing the full `HardwareCapabilities` decode via
1427    /// `views().hardware()` when only one tag is needed. See
1428    /// `docs/misc/PERF_AUDIT_2026_05_28_CAPABILITY.md` fix #2.
1429    pub(crate) fn axis_value(
1430        &self,
1431        axis: crate::adapter::net::behavior::tag::TaxonomyAxis,
1432        key: &str,
1433    ) -> Option<&str> {
1434        self.tags.iter().find_map(|tag| match tag {
1435            Tag::AxisValue {
1436                axis: a,
1437                key: k,
1438                value,
1439                ..
1440            } if *a == axis && k == key => Some(value.as_str()),
1441            _ => None,
1442        })
1443    }
1444
1445    /// Get all model IDs.
1446    ///
1447    /// Phase A.5.N.3: returns owned `String`s (rather than borrowed
1448    /// `&str` over a typed-struct field that no longer exists).
1449    pub fn model_ids(&self) -> Vec<String> {
1450        self.views()
1451            .models()
1452            .iter()
1453            .map(|m| m.model_id.clone())
1454            .collect()
1455    }
1456
1457    /// Get all tool IDs.
1458    pub fn tool_ids(&self) -> Vec<String> {
1459        self.views()
1460            .tools()
1461            .iter()
1462            .map(|t| t.tool_id.clone())
1463            .collect()
1464    }
1465
1466    /// Serialize to bytes — JSON format, kept as the default for wire
1467    /// compatibility with peers running pre-postcard code. New callers
1468    /// that don't need to interop with old peers should prefer
1469    /// [`Self::to_bytes_compact`] (~10× faster, ~3× smaller).
1470    pub fn to_bytes(&self) -> Vec<u8> {
1471        serde_json::to_vec(self).unwrap_or_default()
1472    }
1473
1474    /// Serialize to bytes using the compact postcard wire format —
1475    /// a single leading `0x01` version byte followed by the postcard
1476    /// payload. [`Self::from_bytes`] reads either format via
1477    /// first-byte sniff, so receivers running this code accept both
1478    /// compact and JSON inputs.
1479    ///
1480    /// See `docs/misc/PERF_AUDIT_2026_05_28_CAPABILITY.md` fix #3 for
1481    /// the rollout staging — flipping `to_bytes` itself to compact is
1482    /// a separate, deliberate wire-format change.
1483    pub fn to_bytes_compact(&self) -> Vec<u8> {
1484        let out = vec![COMPACT_FORMAT_TAG];
1485        postcard::to_extend(self, out).unwrap_or_default()
1486    }
1487
1488    /// Deserialize from bytes. Accepts both the legacy JSON wire
1489    /// format (peers running pre-postcard code) and the compact
1490    /// postcard format (peers using [`Self::to_bytes_compact`]).
1491    /// Discriminates on the first byte: `b'{'` → JSON, `0x01` →
1492    /// postcard, anything else → `None`.
1493    pub fn from_bytes(data: &[u8]) -> Option<Self> {
1494        match data.first() {
1495            Some(&b'{') => serde_json::from_slice(data).ok(),
1496            Some(&COMPACT_FORMAT_TAG) => postcard::from_bytes(&data[1..]).ok(),
1497            _ => None,
1498        }
1499    }
1500
1501    /// Compute the structural change from `prev` to `self`.
1502    ///
1503    /// Phase 1 of `CAPABILITY_ENHANCEMENTS_PLAN.md`: a cheap
1504    /// before/after change detector that returns the raw set/map
1505    /// difference — added tags, removed tags, and per-key
1506    /// metadata changes (Added / Removed / Updated). Powers
1507    /// event-driven placement updates, capability-aware dashboards,
1508    /// and delta-based metadata propagation.
1509    ///
1510    /// Cost: `O(|tags| + |metadata|)`. Two `HashSet::difference`
1511    /// scans + a `BTreeMap` walk; no allocation beyond the output
1512    /// collections.
1513    ///
1514    /// **Composes with [`crate::adapter::net::behavior::diff::DiffEngine`]**:
1515    /// `DiffEngine::diff` produces structural `DiffOp`s (used by
1516    /// the propagation path); this method returns the raw set/map
1517    /// diff (better for change-event consumers). Same input data;
1518    /// pick the surface that matches the consumer's shape.
1519    pub fn diff(&self, prev: &CapabilitySet) -> CapabilitySetDiff {
1520        // Tag diff: separator-agnostic. Plain `HashSet::difference`
1521        // would compare via `Tag::PartialEq`, which distinguishes
1522        // `=` vs `:` on `AxisValue` — two semantically-identical
1523        // tags would land as both Added and Removed. The structural
1524        // `DiffEngine::diff` was patched for this in 38612b61; this
1525        // companion API was not. See CR-3 in
1526        // `CODE_REVIEW_2026_05_10_CAPABILITY_SYSTEM_2.md`.
1527        let added_tags: HashSet<Tag> = self
1528            .tags
1529            .iter()
1530            .filter(|t| !prev.tags.iter().any(|p| p.semantic_eq(t)))
1531            .cloned()
1532            .collect();
1533        let removed_tags: HashSet<Tag> = prev
1534            .tags
1535            .iter()
1536            .filter(|t| !self.tags.iter().any(|c| c.semantic_eq(t)))
1537            .cloned()
1538            .collect();
1539
1540        // Metadata diff: walk both maps simultaneously. Both are
1541        // `BTreeMap` so we can rely on ordered iteration; merge
1542        // by key.
1543        let mut changed_metadata = Vec::new();
1544        let mut prev_iter = prev.metadata.iter().peekable();
1545        let mut curr_iter = self.metadata.iter().peekable();
1546        loop {
1547            match (prev_iter.peek(), curr_iter.peek()) {
1548                (Some((pk, pv)), Some((ck, cv))) => match pk.cmp(ck) {
1549                    std::cmp::Ordering::Less => {
1550                        changed_metadata.push(MetadataChange::Removed {
1551                            key: (*pk).clone(),
1552                            prev_value: (*pv).clone(),
1553                        });
1554                        prev_iter.next();
1555                    }
1556                    std::cmp::Ordering::Greater => {
1557                        changed_metadata.push(MetadataChange::Added {
1558                            key: (*ck).clone(),
1559                            value: (*cv).clone(),
1560                        });
1561                        curr_iter.next();
1562                    }
1563                    std::cmp::Ordering::Equal => {
1564                        if pv != cv {
1565                            changed_metadata.push(MetadataChange::Updated {
1566                                key: (*pk).clone(),
1567                                prev_value: (*pv).clone(),
1568                                new_value: (*cv).clone(),
1569                            });
1570                        }
1571                        prev_iter.next();
1572                        curr_iter.next();
1573                    }
1574                },
1575                (Some((pk, pv)), None) => {
1576                    changed_metadata.push(MetadataChange::Removed {
1577                        key: (*pk).clone(),
1578                        prev_value: (*pv).clone(),
1579                    });
1580                    prev_iter.next();
1581                }
1582                (None, Some((ck, cv))) => {
1583                    changed_metadata.push(MetadataChange::Added {
1584                        key: (*ck).clone(),
1585                        value: (*cv).clone(),
1586                    });
1587                    curr_iter.next();
1588                }
1589                (None, None) => break,
1590            }
1591        }
1592
1593        CapabilitySetDiff {
1594            added_tags,
1595            removed_tags,
1596            changed_metadata,
1597        }
1598    }
1599
1600    // ========================================================================
1601    // View projections — Capability System Plan §1, Phase A.4.
1602    //
1603    // Today these are simple field clones because `CapabilitySet`
1604    // still carries the typed structs as fields. Phase A.5 removes
1605    // the typed-struct fields and migrates wire format to
1606    // `tags: HashSet<Tag>`; the same `From<&CapabilitySet>` impls
1607    // then reconstruct the typed view by scanning the tag set.
1608    //
1609    // Downstream code SHOULD adopt the projection accessors NOW
1610    // (`caps.views().hardware`, `HardwareCapabilities::from(&caps)`)
1611    // so the migration in A.5 doesn't ripple through every call
1612    // site. The legacy direct-field access (`caps.hardware`)
1613    // continues to work in this commit but is documented as
1614    // deprecated in `CAPABILITY_SYSTEM_PLAN.md` Locked decision 1.
1615    // ========================================================================
1616
1617    /// All five view projections rolled into one struct, computed
1618    /// once per call. Cheaper than calling each `From<&CapabilitySet>`
1619    /// individually when the consumer reads more than one of them.
1620    ///
1621    /// ```
1622    /// # use net::adapter::net::behavior::capability::CapabilitySet;
1623    /// let caps = CapabilitySet::new();
1624    /// let views = caps.views();
1625    /// let _ = views.hardware();
1626    /// let _ = views.software();
1627    /// let _ = views.resource_limits();
1628    /// let _ = views.models();
1629    /// let _ = views.tools();
1630    /// ```
1631    /// Borrowing handle exposing the five typed projections
1632    /// ([`HardwareCapabilities`], [`SoftwareCapabilities`],
1633    /// [`ResourceLimits`], `Vec<ModelCapability>`,
1634    /// `Vec<ToolCapability>`).
1635    ///
1636    /// Phase A.5.N.3 + Phase 1 of `CAPABILITY_ENHANCEMENTS_PLAN.md`:
1637    /// each projection is decoded from the canonical tag set
1638    /// (+ metadata, for tool schemas) on first access and cached
1639    /// for the lifetime of the handle. Repeated reads of the same
1640    /// projection hit the cache; reads of unrelated projections
1641    /// don't force the full set of decoders.
1642    ///
1643    /// The handle borrows `self`. Mutations to `self` invalidate
1644    /// the handle (compiler-enforced through the lifetime).
1645    pub fn views(&self) -> CapabilityViews<'_> {
1646        CapabilityViews {
1647            caps: self,
1648            sorted_tags: OnceCell::new(),
1649            hardware: OnceCell::new(),
1650            software: OnceCell::new(),
1651            resource_limits: OnceCell::new(),
1652            models: OnceCell::new(),
1653            tools: OnceCell::new(),
1654        }
1655    }
1656
1657    // ========================================================================
1658    // Typed-tag-set access — Phase A.5.1 ergonomic accessors.
1659    //
1660    // These methods give downstream code the future access pattern
1661    // for capability data. Downstream code SHOULD adopt these now
1662    // so Phase A.5.2+ (when typed-struct fields are removed from
1663    // `CapabilitySet`) is invisible at the consumer level.
1664    //
1665    // Uses the bijection helpers from `behavior::tag_codec`. Today
1666    // computed on demand (no field change); Phase A.5.N introduces
1667    // internal `tag_set: HashSet<Tag>` storage as the source of truth
1668    // and removes the typed-struct fields. Either way, the surface
1669    // below stays stable.
1670    //
1671    // Migration path for downstream code:
1672    //
1673    // ```text
1674    // // Before (typed-struct field access):
1675    // if caps.hardware.gpu.is_some() { ... }
1676    // for tag in &caps.tags { ... }
1677    //
1678    // // After (read via the projection — canonical):
1679    // let views = caps.views();
1680    // if views.hardware().gpu.is_some() { ... }
1681    // for model in views.models() { ... }
1682    //
1683    // // Or directly through the `From` impl when only one field is needed:
1684    // if HardwareCapabilities::from(&caps).gpu.is_some() { ... }
1685    //
1686    // // Tags survive Phase A.5.N as a top-level field; iterate as before:
1687    // for tag in &caps.tags { ... }
1688    //
1689    // // Or read the typed-tag set (Phase A.5.1):
1690    // for tag in caps.typed_tags() { ... }
1691    //
1692    // // Writes go through the typed setters (Phase A.5.6):
1693    // caps.set_hardware(new_hw);
1694    // ```
1695    //
1696    // Application code that needs to compose with federated query
1697    // primitives (Phase E) will use `typed_tags()` to feed the
1698    // tag set into `Predicate::evaluate`'s `EvalContext`.
1699    // ========================================================================
1700
1701    /// All capability data as a typed-tag set, including the
1702    /// hardware / software / models / tools / limits structs
1703    /// re-encoded as axis-prefixed tags AND the legacy `tags`
1704    /// `Vec<String>` parsed via [`Tag::parse`]. The future wire
1705    /// format (Phase A.5.2+) is exactly this `HashSet<Tag>`.
1706    ///
1707    /// Round-trip-stable: `Self::from_typed_tags(&caps.typed_tags())`
1708    /// produces a `CapabilitySet` semantically equal to `caps`,
1709    /// modulo the documented order non-preservation for non-indexed
1710    /// `Vec` fields (runtimes / frameworks / drivers).
1711    ///
1712    /// Cost: linear in tag count. Currently computed on every
1713    /// call; downstream callers that read in a hot loop should
1714    /// cache the result.
1715    pub fn typed_tags(&self) -> std::collections::HashSet<crate::adapter::net::behavior::tag::Tag> {
1716        crate::adapter::net::behavior::tag_codec::capability_set_to_tag_set(self)
1717    }
1718
1719    /// Build a `CapabilitySet` from a typed-tag set. Inverse of
1720    /// [`Self::typed_tags`]; uses the per-struct decoders to
1721    /// reconstruct the typed fields plus a legacy-carrier scan for
1722    /// reserved-prefix tags + unknown axis tags.
1723    ///
1724    /// See [`Self::typed_tags`] for the round-trip contract.
1725    pub fn from_typed_tags(
1726        tags: &std::collections::HashSet<crate::adapter::net::behavior::tag::Tag>,
1727    ) -> Self {
1728        crate::adapter::net::behavior::tag_codec::capability_set_from_tag_set(tags)
1729    }
1730}
1731
1732/// Lazy borrowing handle exposing the five typed projections of a
1733/// [`CapabilitySet`].
1734///
1735/// Returned by [`CapabilitySet::views`]. Each projection is decoded
1736/// from the canonical tag set on first access and cached for the
1737/// lifetime of the handle:
1738///
1739/// ```ignore
1740/// let caps = CapabilitySet::default();
1741/// let v = caps.views();
1742/// let _ = v.hardware();   // first read: decodes hardware tags
1743/// let _ = v.hardware();   // cached; no re-decode
1744/// let _ = v.models();     // separate cache; decodes model tags
1745/// ```
1746///
1747/// Phase 1 of `CAPABILITY_ENHANCEMENTS_PLAN.md`: callers that
1748/// previously read `views.hardware` (field) now call
1749/// `views.hardware()` (accessor). Hot-path post-cache cost is a
1750/// single pointer load (`OnceCell::get`); pre-cache cost is one
1751/// invocation of the underlying `*_from_tags` decoder.
1752#[derive(Debug)]
1753pub struct CapabilityViews<'a> {
1754    caps: &'a CapabilitySet,
1755    sorted_tags: OnceCell<Vec<Tag>>,
1756    hardware: OnceCell<HardwareCapabilities>,
1757    software: OnceCell<SoftwareCapabilities>,
1758    resource_limits: OnceCell<ResourceLimits>,
1759    models: OnceCell<Vec<ModelCapability>>,
1760    tools: OnceCell<Vec<ToolCapability>>,
1761}
1762
1763impl<'a> CapabilityViews<'a> {
1764    /// Sorted tag vector — shared scratch for the per-axis
1765    /// decoders. Sort stabilizes Vec-valued fields whose tag
1766    /// encoding is non-indexed (`software.runtimes` etc.) so
1767    /// repeated reads produce identical projections.
1768    fn sorted_tags(&self) -> &Vec<Tag> {
1769        self.sorted_tags
1770            .get_or_init(|| decoder_sorted_tag_vec(&self.caps.tags))
1771    }
1772
1773    /// Hardware projection. Decodes the `hardware.*` axis tags
1774    /// (excluding `hardware.limits.*`) on first call; subsequent
1775    /// calls return the cached projection.
1776    pub fn hardware(&self) -> &HardwareCapabilities {
1777        self.hardware.get_or_init(|| {
1778            crate::adapter::net::behavior::tag_codec::hardware_from_tags(self.sorted_tags())
1779        })
1780    }
1781
1782    /// Software projection. Decodes the `software.*` axis tags
1783    /// (excluding `software.model.*` and `software.tool.*`) on
1784    /// first call.
1785    pub fn software(&self) -> &SoftwareCapabilities {
1786        self.software.get_or_init(|| {
1787            crate::adapter::net::behavior::tag_codec::software_from_tags(self.sorted_tags())
1788        })
1789    }
1790
1791    /// Resource-limits projection. Decodes the `hardware.limits.*`
1792    /// tags on first call.
1793    pub fn resource_limits(&self) -> &ResourceLimits {
1794        self.resource_limits.get_or_init(|| {
1795            crate::adapter::net::behavior::tag_codec::resource_limits_from_tags(self.sorted_tags())
1796        })
1797    }
1798
1799    /// Loaded-model projection. Decodes the `software.model.<i>.*`
1800    /// indexed tags on first call.
1801    pub fn models(&self) -> &Vec<ModelCapability> {
1802        self.models.get_or_init(|| {
1803            crate::adapter::net::behavior::tag_codec::models_from_tags(self.sorted_tags())
1804        })
1805    }
1806
1807    /// Available-tool projection. Decodes the `software.tool.<i>.*`
1808    /// indexed tags on first call AND layers tool input/output JSON
1809    /// Schemas back from `caps.metadata` (key shape:
1810    /// `tool::<id>::input_schema` / `tool::<id>::output_schema`).
1811    pub fn tools(&self) -> &Vec<ToolCapability> {
1812        self.tools.get_or_init(|| {
1813            let mut tools =
1814                crate::adapter::net::behavior::tag_codec::tools_from_tags(self.sorted_tags());
1815            for tool in &mut tools {
1816                if let Some(s) = self
1817                    .caps
1818                    .metadata
1819                    .get(&ToolCapability::input_schema_metadata_key(&tool.tool_id))
1820                {
1821                    tool.input_schema = Some(s.clone());
1822                }
1823                if let Some(s) = self
1824                    .caps
1825                    .metadata
1826                    .get(&ToolCapability::output_schema_metadata_key(&tool.tool_id))
1827                {
1828                    tool.output_schema = Some(s.clone());
1829                }
1830            }
1831            tools
1832        })
1833    }
1834}
1835
1836// ============================================================================
1837// View projections — `From<&CapabilitySet>` for each typed struct.
1838//
1839// Phase A.5.N.3: each impl scans the canonical `tags: HashSet<Tag>`
1840// via the `tag_codec::*_from_tags` decoders. The typed-struct
1841// fields they previously cloned no longer exist; the tag set is
1842// the source of truth.
1843// ============================================================================
1844
1845/// Materialize the tag set as a sorted `Vec<Tag>` for the
1846/// per-struct decoders. Sort stabilizes Vec-valued fields
1847/// whose tag encoding is non-indexed (`software.runtimes` etc.)
1848/// so consecutive `views()` calls produce identical projections.
1849fn sorted_tag_vec(tags: &HashSet<Tag>) -> Vec<Tag> {
1850    let mut v: Vec<Tag> = tags.iter().cloned().collect();
1851    // `sort_by_cached_key` computes each `Tag::to_string()` exactly once
1852    // (N allocations) instead of `sort_by_key`'s re-evaluation on every
1853    // comparison (~N log N allocations). Output order is identical, so signed
1854    // announcement bytes stay stable. See
1855    // docs/misc/PERF_AUDIT_2026_06_08_BENCHMARK_WINS.md §3.
1856    v.sort_by_cached_key(|a| a.to_string());
1857    v
1858}
1859
1860/// Decoder-path sort: stabilizes tag order for the per-axis
1861/// projection decoders. Uses `Tag`'s derived `Ord` (no per-element
1862/// `String` allocation) — any total order works here as long as it
1863/// is deterministic. Wire serialization keeps `sorted_tag_vec`'s
1864/// `Tag::to_string()` order so signed-announcement bytes stay stable.
1865fn decoder_sorted_tag_vec(tags: &HashSet<Tag>) -> Vec<Tag> {
1866    let mut v: Vec<Tag> = tags.iter().cloned().collect();
1867    v.sort_unstable();
1868    v
1869}
1870
1871/// Serialize a `HashSet<Tag>` as a sequence of tags. For
1872/// human-readable formats (JSON) the sequence is sorted via
1873/// `Tag::to_string()` so a signed `CapabilityAnnouncement`
1874/// round-trips byte-for-byte regardless of process-local `HashSet`
1875/// iteration order — that byte stability is what makes signature
1876/// verification work across peers.
1877///
1878/// For non-human-readable formats (postcard via
1879/// [`CapabilitySet::to_bytes_compact`]) the sort is skipped:
1880/// `CapabilityAnnouncement` itself never takes the compact path
1881/// (its `#[serde(skip_serializing_if)]` fields don't survive
1882/// positional encoding), and bare `CapabilitySet` bytes aren't
1883/// signed — readers reconstruct the same `HashSet` regardless of
1884/// iteration order. Skipping the sort avoids ~N × `Tag::to_string()`
1885/// allocations on every compact serialize.
1886fn serialize_tags_sorted<S: serde::Serializer>(
1887    tags: &HashSet<Tag>,
1888    serializer: S,
1889) -> Result<S::Ok, S::Error> {
1890    use serde::ser::SerializeSeq;
1891    if serializer.is_human_readable() {
1892        let sorted = sorted_tag_vec(tags);
1893        let mut seq = serializer.serialize_seq(Some(sorted.len()))?;
1894        for t in &sorted {
1895            seq.serialize_element(t)?;
1896        }
1897        seq.end()
1898    } else {
1899        let mut seq = serializer.serialize_seq(Some(tags.len()))?;
1900        for t in tags {
1901            seq.serialize_element(t)?;
1902        }
1903        seq.end()
1904    }
1905}
1906
1907impl From<&CapabilitySet> for HardwareCapabilities {
1908    fn from(caps: &CapabilitySet) -> Self {
1909        crate::adapter::net::behavior::tag_codec::hardware_from_tags(&decoder_sorted_tag_vec(
1910            &caps.tags,
1911        ))
1912    }
1913}
1914
1915impl From<&CapabilitySet> for SoftwareCapabilities {
1916    fn from(caps: &CapabilitySet) -> Self {
1917        crate::adapter::net::behavior::tag_codec::software_from_tags(&decoder_sorted_tag_vec(
1918            &caps.tags,
1919        ))
1920    }
1921}
1922
1923impl From<&CapabilitySet> for ResourceLimits {
1924    fn from(caps: &CapabilitySet) -> Self {
1925        crate::adapter::net::behavior::tag_codec::resource_limits_from_tags(
1926            &decoder_sorted_tag_vec(&caps.tags),
1927        )
1928    }
1929}
1930
1931// ============================================================================
1932// CapabilitySet diff (Phase 1 of CAPABILITY_ENHANCEMENTS_PLAN.md)
1933// ============================================================================
1934
1935/// Structural difference between two [`CapabilitySet`] values.
1936///
1937/// Returned by [`CapabilitySet::diff`]. Carries:
1938///
1939/// - `added_tags`: tags in `self` that aren't in `prev`.
1940/// - `removed_tags`: tags in `prev` that aren't in `self`.
1941/// - `changed_metadata`: per-key metadata changes (Added /
1942///   Removed / Updated). Key renames surface as Removed + Added,
1943///   not as Updated, since the key identity changed.
1944///
1945/// The diff is the input shape for event-driven placement
1946/// updates, capability-change dashboards, and delta-based
1947/// metadata propagation. For the structural ops shape consumed
1948/// by the propagation path, use
1949/// [`crate::adapter::net::behavior::diff::DiffEngine::diff`].
1950#[derive(Debug, Clone, PartialEq)]
1951pub struct CapabilitySetDiff {
1952    /// Tags newly present in `self`.
1953    pub added_tags: HashSet<Tag>,
1954    /// Tags that were in `prev` but are no longer in `self`.
1955    pub removed_tags: HashSet<Tag>,
1956    /// Per-key metadata changes, in key order.
1957    pub changed_metadata: Vec<MetadataChange>,
1958}
1959
1960impl CapabilitySetDiff {
1961    /// True if no tags or metadata entries differ.
1962    pub fn is_empty(&self) -> bool {
1963        self.added_tags.is_empty()
1964            && self.removed_tags.is_empty()
1965            && self.changed_metadata.is_empty()
1966    }
1967}
1968
1969/// One metadata-key change between two [`CapabilitySet`]s.
1970///
1971/// Renamed keys surface as `Removed { old_key } + Added { new_key }`,
1972/// not `Updated`, because key identity changes are semantically
1973/// distinct from value changes.
1974#[derive(Debug, Clone, PartialEq)]
1975pub enum MetadataChange {
1976    /// Key was not present in `prev`; now has `value`.
1977    Added {
1978        /// Metadata key.
1979        key: String,
1980        /// New value.
1981        value: String,
1982    },
1983    /// Key was present in `prev` with `prev_value`; no longer in `self`.
1984    Removed {
1985        /// Metadata key.
1986        key: String,
1987        /// Value held in the previous state.
1988        prev_value: String,
1989    },
1990    /// Key present in both; value changed.
1991    Updated {
1992        /// Metadata key.
1993        key: String,
1994        /// Value held in the previous state.
1995        prev_value: String,
1996        /// New value.
1997        new_value: String,
1998    },
1999}
2000
2001// ============================================================================
2002// Capability Announcement
2003// ============================================================================
2004
2005/// Capability announcement message
2006#[derive(Debug, Clone, Serialize, Deserialize)]
2007pub struct CapabilityAnnouncement {
2008    /// Announcing node ID
2009    pub node_id: u64,
2010    /// Announcing entity — the 32-byte ed25519 public key. Pairs
2011    /// with `signature` so receivers can verify end-to-end, and
2012    /// lets the mesh's channel-auth path resolve
2013    /// `node_id → EntityId` for token lookups.
2014    pub entity_id: super::super::identity::EntityId,
2015    /// Monotonic version (for diffing)
2016    pub version: u64,
2017    /// Timestamp of announcement (nanoseconds since epoch)
2018    pub timestamp_ns: u64,
2019    /// TTL for this announcement in seconds
2020    pub ttl_secs: u32,
2021    /// Capability set
2022    pub capabilities: CapabilitySet,
2023    /// Optional Ed25519 signature (64 bytes, hex encoded for serde).
2024    /// Covers every other field EXCEPT [`Self::hop_count`] — the
2025    /// internal signing helper zeros `hop_count` before serializing
2026    /// and hashing, so forwarders can increment it without
2027    /// invalidating this signature. See [`Self::sign`] /
2028    /// [`Self::verify`] for the public API; the zeroing is an
2029    /// implementation detail of both.
2030    #[serde(default, skip_serializing_if = "Option::is_none")]
2031    pub signature: Option<Signature64>,
2032    /// Number of times this announcement has been forwarded. Origin
2033    /// sets 0; each forwarder increments before re-broadcasting.
2034    /// Sits *outside* the signed envelope so forwarders don't need
2035    /// the origin's secret key. Capped at `MAX_CAPABILITY_HOPS` —
2036    /// announcements at or beyond the cap are dropped rather than
2037    /// re-broadcast. Old-format announcements missing this field
2038    /// deserialize as 0 via `#[serde(default)]`.
2039    ///
2040    /// `skip_serializing_if` omits the field when it's zero so the
2041    /// SIGNED byte form stays identical to pre-M-1 announcements —
2042    /// a pre-M-1 node's signature verifies on a post-M-1 node
2043    /// during a rolling upgrade because both produce the same
2044    /// canonical bytes for the origin (hop_count=0). Forwarded
2045    /// announcements (hop_count > 0) serialize the field; receivers
2046    /// still zero it in `signed_payload()` so verification hits the
2047    /// omitted-when-zero form.
2048    #[serde(default, skip_serializing_if = "is_hop_count_zero")]
2049    pub hop_count: u8,
2050    /// Observer-visible reflexive `SocketAddr` as seen by this
2051    /// node's anchor peers during NAT classification. Populated
2052    /// once the `ClassifyFsm` (under the `nat-traversal` feature,
2053    /// in `adapter/net/traversal/classify.rs`) has ≥ 2 probe
2054    /// results; stays `None` on nodes that haven't classified
2055    /// yet, ran with `nat-traversal` disabled, or landed in the
2056    /// `Unknown` bucket (different peers disagree on our port
2057    /// so no single address is truthful).
2058    ///
2059    /// **Peer usage.** Receivers cache this alongside the
2060    /// `nat:*` tag and use it as the initial rendezvous target
2061    /// for hole punching — one fewer reflex round-trip per
2062    /// first-contact punch. The field is advisory: the punch
2063    /// step still waits for a real keep-alive exchange on the
2064    /// advertised address before handing off to the Noise
2065    /// handshake, so a lying peer can only fail its own
2066    /// incoming punches, not redirect traffic to a third party
2067    /// (see `docs/NAT_TRAVERSAL_PLAN.md` §7 for the trust model).
2068    ///
2069    /// **Wire compat.** `skip_serializing_if` keeps the old
2070    /// on-wire shape when the field is `None`, so pre-stage-2
2071    /// nodes round-trip through a post-stage-2 deserializer
2072    /// without breaking signatures. A post-stage-2 node
2073    /// deserializing a pre-stage-2 announcement sees the field
2074    /// default to `None` via `#[serde(default)]`.
2075    #[serde(default, skip_serializing_if = "Option::is_none")]
2076    pub reflex_addr: Option<std::net::SocketAddr>,
2077    /// v0.4 capability-auth allow-list — explicit `NodeId`s that
2078    /// may invoke any capability listed in `capabilities`. Empty
2079    /// vec = permissive default (anyone may invoke, subject to
2080    /// the other two lists). See `CAPABILITY_AUTH_PLAN.md`.
2081    ///
2082    /// Capped at [`MAX_ALLOW_LIST_LEN`] (64) per axis — past that,
2083    /// operators use a [`super::group::GroupId`] instead.
2084    ///
2085    /// `skip_serializing_if` preserves byte-identity with pre-v0.4
2086    /// announcements: an unrestricted (empty) list serializes to
2087    /// nothing, so an existing signature verifies on a v0.4 reader
2088    /// and a v0.4 signature verifies on a pre-v0.4 reader (which
2089    /// defaults the field to empty via `#[serde(default)]`).
2090    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2091    pub allowed_nodes: Vec<u64>,
2092    /// v0.4 capability-auth allow-list — [`super::subnet::SubnetId`]s
2093    /// whose members may invoke. Empty = permissive default.
2094    /// Receivers determine a caller's subnet via the `subnet:<hex>`
2095    /// tag on the caller's own announcement (self-declared, signed,
2096    /// TOFU-bound). Same wire-compat treatment as `allowed_nodes`.
2097    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2098    pub allowed_subnets: Vec<super::subnet::SubnetId>,
2099    /// v0.4 capability-auth allow-list — [`super::group::GroupId`]s
2100    /// whose claimants may invoke. Empty = permissive default.
2101    /// Group membership is self-declared via `group:<hex>` tags on
2102    /// the caller's own announcement. Same wire-compat treatment
2103    /// as `allowed_nodes`.
2104    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2105    pub allowed_groups: Vec<super::group::GroupId>,
2106}
2107
2108/// Cap on any single allow-list axis on a
2109/// [`CapabilityAnnouncement`]. 64 entries keeps the announcement
2110/// under the wire-size ceiling and matches the operator guidance
2111/// "lists > 64 use a group, not inline node enumeration."
2112pub const MAX_ALLOW_LIST_LEN: usize = 64;
2113
2114/// Serde predicate: skip serializing `hop_count` when it's zero.
2115/// Preserves on-wire byte-compat with pre-M-1 announcements that
2116/// didn't carry this field at all. See
2117/// [`CapabilityAnnouncement::hop_count`] for the rationale.
2118fn is_hop_count_zero(v: &u8) -> bool {
2119    *v == 0
2120}
2121
2122/// Hard cap on `CapabilityAnnouncement::hop_count`. Mirrors the
2123/// pingwave `MAX_HOPS` so both multi-hop broadcast paths share the
2124/// same forwarding-depth contract.
2125pub const MAX_CAPABILITY_HOPS: u8 = 16;
2126
2127/// 64-byte signature wrapper with serde support
2128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2129pub struct Signature64(pub [u8; 64]);
2130
2131impl Serialize for Signature64 {
2132    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2133    where
2134        S: serde::Serializer,
2135    {
2136        // Serialize as hex string for JSON compatibility
2137        if serializer.is_human_readable() {
2138            let hex = hex::encode(self.0);
2139            serializer.serialize_str(&hex)
2140        } else {
2141            serializer.serialize_bytes(&self.0)
2142        }
2143    }
2144}
2145
2146impl<'de> Deserialize<'de> for Signature64 {
2147    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2148    where
2149        D: serde::Deserializer<'de>,
2150    {
2151        if deserializer.is_human_readable() {
2152            let hex_str = String::deserialize(deserializer)?;
2153            let bytes = hex::decode(&hex_str).map_err(serde::de::Error::custom)?;
2154            if bytes.len() != 64 {
2155                return Err(serde::de::Error::custom("signature must be 64 bytes"));
2156            }
2157            let mut arr = [0u8; 64];
2158            arr.copy_from_slice(&bytes);
2159            Ok(Signature64(arr))
2160        } else {
2161            let bytes = <Vec<u8>>::deserialize(deserializer)?;
2162            if bytes.len() != 64 {
2163                return Err(serde::de::Error::custom("signature must be 64 bytes"));
2164            }
2165            let mut arr = [0u8; 64];
2166            arr.copy_from_slice(&bytes);
2167            Ok(Signature64(arr))
2168        }
2169    }
2170}
2171
2172impl CapabilityAnnouncement {
2173    /// Default `ttl_secs` value assigned by [`Self::new`]. Five
2174    /// minutes — long enough that a missed re-announcement on one
2175    /// node doesn't immediately evict it from every peer's
2176    /// capability fold, short enough that stale state clears on
2177    /// realistic operational timescales. Exposed as a constant so
2178    /// multi-hop dedup retention can be scaled off it.
2179    pub const DEFAULT_TTL_SECS: u32 = 300;
2180
2181    /// Create a new unsigned announcement. Receivers that run with
2182    /// `require_signed_capabilities = true` will drop it until
2183    /// [`Self::sign`] is called.
2184    pub fn new(
2185        node_id: u64,
2186        entity_id: super::super::identity::EntityId,
2187        version: u64,
2188        capabilities: CapabilitySet,
2189    ) -> Self {
2190        use std::time::{SystemTime, UNIX_EPOCH};
2191        let timestamp_ns = SystemTime::now()
2192            .duration_since(UNIX_EPOCH)
2193            .map(|d| d.as_nanos() as u64)
2194            .unwrap_or(0);
2195
2196        Self {
2197            node_id,
2198            entity_id,
2199            version,
2200            timestamp_ns,
2201            ttl_secs: Self::DEFAULT_TTL_SECS,
2202            capabilities,
2203            signature: None,
2204            hop_count: 0,
2205            reflex_addr: None,
2206            allowed_nodes: Vec::new(),
2207            allowed_subnets: Vec::new(),
2208            allowed_groups: Vec::new(),
2209        }
2210    }
2211
2212    /// Set TTL
2213    pub fn with_ttl(mut self, ttl_secs: u32) -> Self {
2214        self.ttl_secs = ttl_secs;
2215        self
2216    }
2217
2218    /// Attach the classifier's observed reflex address. Typically
2219    /// called by the mesh's capability-broadcast path after NAT
2220    /// classification has completed at least two probes. Pass
2221    /// `None` to clear a previously-set address — e.g. if
2222    /// reclassification landed in `Unknown`.
2223    ///
2224    /// Included in the signed envelope: a post-signing change
2225    /// invalidates verification.
2226    pub fn with_reflex_addr(mut self, reflex: Option<std::net::SocketAddr>) -> Self {
2227        self.reflex_addr = reflex;
2228        self
2229    }
2230
2231    /// Set signature
2232    pub fn with_signature(mut self, sig: [u8; 64]) -> Self {
2233        self.signature = Some(Signature64(sig));
2234        self
2235    }
2236
2237    /// Serialize the sign/verify payload: same bytes on both sides
2238    /// of the signature round-trip, with `signature` cleared AND
2239    /// `hop_count` zeroed. Keeping `hop_count` out of the signed
2240    /// envelope is what lets downstream forwarders bump it without
2241    /// invalidating the origin's signature — standard multi-hop
2242    /// gossip design (libp2p gossipsub, Chord, etc.).
2243    ///
2244    /// Pre-fix this called `to_bytes()` (= `unwrap_or_default`) on
2245    /// the canonical clone. A `serde_json::to_vec` failure produced
2246    /// an empty `Vec` that signer + verifier both observed as the
2247    /// same constant transcript, defeating the signature for every
2248    /// affected announcement and making a single captured signature
2249    /// replay across every other failing call. The failure mode is
2250    /// unreachable today (none of the `CapabilityAnnouncement`
2251    /// fields have a fallible `Serialize`), but propagating the
2252    /// error explicitly with a panic gives a loud diagnostic if a
2253    /// future refactor ever adds one — strictly better than silent
2254    /// signature-compromise.
2255    #[expect(
2256        clippy::expect_used,
2257        reason = "no CapabilityAnnouncement field has a fallible Serialize impl today; panic is the documented loud-diagnostic strategy for a future refactor that introduces one"
2258    )]
2259    fn signed_payload(&self) -> Vec<u8> {
2260        let mut canonical = self.clone();
2261        canonical.signature = None;
2262        canonical.hop_count = 0;
2263        serde_json::to_vec(&canonical).expect(
2264            "CapabilityAnnouncement::signed_payload: serde_json::to_vec is infallible \
2265             over the current field set; if this ever fires, a fallible Serialize impl \
2266             was added and the signed transcript must be re-designed before merging",
2267        )
2268    }
2269
2270    /// Sign this announcement in place with `keypair`. The resulting
2271    /// signature covers every field EXCEPT [`Self::hop_count`] — the
2272    /// caller must still ensure `keypair.entity_id() == self.entity_id`
2273    /// or receivers will reject with `InvalidSignature`.
2274    pub fn sign(&mut self, keypair: &super::super::identity::EntityKeypair) {
2275        let payload = self.signed_payload();
2276        let sig = keypair.sign(&payload);
2277        self.signature = Some(Signature64(sig.to_bytes()));
2278    }
2279
2280    /// Verify the signature against the announcement's own
2281    /// `entity_id`. Ignores [`Self::hop_count`] — forwarders are
2282    /// expected to bump it. Returns `Err` if no signature is
2283    /// present, if the signature can't be decoded, or if
2284    /// verification fails.
2285    pub fn verify(&self) -> Result<(), super::super::identity::EntityError> {
2286        let Some(Signature64(raw)) = self.signature else {
2287            return Err(super::super::identity::EntityError::InvalidSignature);
2288        };
2289        let payload = self.signed_payload();
2290        let sig = ed25519_dalek::Signature::from_bytes(&raw);
2291        self.entity_id.verify(&payload, &sig)
2292    }
2293
2294    /// Serialize to bytes — JSON. The compact-postcard codec used by
2295    /// [`CapabilitySet::to_bytes_compact`] is not applied here:
2296    /// `CapabilityAnnouncement`'s wire-compat surface relies on
2297    /// several `#[serde(skip_serializing_if = ...)]` field
2298    /// omissions (`signature`, `hop_count`, `reflex_addr`, the three
2299    /// `allowed_*` lists) so signed bytes round-trip byte-for-byte
2300    /// against pre-M-1 / pre-v0.4 peers — and postcard's positional
2301    /// encoding can't reconstruct an omitted field. A compact
2302    /// announcement codec would need a separate canonicalized wire
2303    /// struct (TODO; tracked in PERF_AUDIT_2026_05_28_CAPABILITY.md
2304    /// fix #3 follow-ups).
2305    pub fn to_bytes(&self) -> Vec<u8> {
2306        serde_json::to_vec(self).unwrap_or_default()
2307    }
2308
2309    /// Deserialize from bytes (JSON only — see [`Self::to_bytes`]).
2310    /// Returns `None` on a parse failure OR when any v0.4
2311    /// capability-auth allow-list exceeds [`MAX_ALLOW_LIST_LEN`] —
2312    /// the cap is a wire-level invariant (operators above 64 entries
2313    /// per axis must use a group), so receivers reject oversized
2314    /// announcements at the deserializer boundary rather than
2315    /// scanning unbounded vectors inside `may_execute` on every call.
2316    /// Symmetric with the CLI's announce-side check; closes the
2317    /// asymmetry where the substrate accepted any vector length the
2318    /// wire delivered.
2319    pub fn from_bytes(data: &[u8]) -> Option<Self> {
2320        let ann: Self = serde_json::from_slice(data).ok()?;
2321        if ann.allowed_nodes.len() > MAX_ALLOW_LIST_LEN
2322            || ann.allowed_subnets.len() > MAX_ALLOW_LIST_LEN
2323            || ann.allowed_groups.len() > MAX_ALLOW_LIST_LEN
2324        {
2325            return None;
2326        }
2327        Some(ann)
2328    }
2329
2330    /// Drop every metadata key that the substrate reserves for
2331    /// local trust use (`intent`, `colocate-with`, `priority`,
2332    /// `owner`). Call this on every announcement decoded from an
2333    /// inbound peer before its metadata is consulted by greedy
2334    /// admission, placement scoring, or anything else that lets a
2335    /// metadata value steer substrate decisions: pre-fix a peer
2336    /// could stamp `intent = "high-priority-tenant-X"` on its own
2337    /// announcement and steer the receiver's admission to itself.
2338    ///
2339    /// `tool::*` keys are NOT stripped — they're peer-advertised
2340    /// AI tool descriptors (schemas, descriptions, tags) that
2341    /// `MeshNode::list_tools` surfaces to agents. Substrate never
2342    /// makes trust decisions from them, so stripping would only
2343    /// defeat cross-mesh tool discovery. See
2344    /// [`schema::METADATA_RESERVED_PREFIXES`](super::schema).
2345    ///
2346    /// The schema's `metadata_reserved` doc says these keys are
2347    /// **writable by user code on the local node** — the local
2348    /// node knows its own legitimate intent. But the same wire
2349    /// shape carries inbound peer announcements that the
2350    /// substrate must NOT trust for those decisions. This method
2351    /// is the boundary that draws the distinction; callers on the
2352    /// receive path invoke it after `from_bytes`.
2353    pub fn strip_reserved_metadata(&mut self) {
2354        use super::schema::AXIS_SCHEMA;
2355        self.capabilities.metadata.retain(|key, _| {
2356            if AXIS_SCHEMA.metadata_reserved.contains(&key.as_str()) {
2357                return false;
2358            }
2359            // `metadata_reserved_prefixes` is empty as of A-4 — the
2360            // `tool::*` family that used to live here is intentionally
2361            // peer-advertised content. See the prefix-list doc in
2362            // `behavior::schema`. The retain loop is kept for forward
2363            // compat if a future substrate-trust prefix needs gating.
2364            !AXIS_SCHEMA
2365                .metadata_reserved_prefixes
2366                .iter()
2367                .any(|prefix| key.starts_with(prefix))
2368        });
2369    }
2370
2371    /// Check if expired
2372    pub fn is_expired(&self) -> bool {
2373        use std::time::{SystemTime, UNIX_EPOCH};
2374        let now_ns = SystemTime::now()
2375            .duration_since(UNIX_EPOCH)
2376            .map(|d| d.as_nanos() as u64)
2377            .unwrap_or(0);
2378        let age_secs = (now_ns.saturating_sub(self.timestamp_ns)) / 1_000_000_000;
2379        // Inclusive-expiry: at age == ttl the announcement is already expired.
2380        // Matches `PermissionToken::is_valid` (see identity/token.rs) so the
2381        // effective lifetime is exactly `ttl_secs` seconds.
2382        age_secs >= self.ttl_secs as u64
2383    }
2384}
2385
2386// ============================================================================
2387// Capability Filter
2388// ============================================================================
2389
2390/// Filter for querying capabilities
2391#[derive(Debug, Clone, Default)]
2392pub struct CapabilityFilter {
2393    /// Require specific tags (all must match)
2394    pub require_tags: Vec<String>,
2395    /// Require specific models (any must match)
2396    pub require_models: Vec<String>,
2397    /// Require specific tools (any must match)
2398    pub require_tools: Vec<String>,
2399    /// Minimum memory in GB
2400    pub min_memory_gb: Option<u32>,
2401    /// Require GPU
2402    pub require_gpu: bool,
2403    /// Specific GPU vendor
2404    pub gpu_vendor: Option<GpuVendor>,
2405    /// Minimum VRAM in GB
2406    pub min_vram_gb: Option<u32>,
2407    /// Minimum context length
2408    pub min_context_length: Option<u32>,
2409    /// Require specific modalities
2410    pub require_modalities: Vec<Modality>,
2411}
2412
2413impl CapabilityFilter {
2414    /// Create empty filter (matches all)
2415    pub fn new() -> Self {
2416        Self::default()
2417    }
2418
2419    /// Require tag
2420    pub fn require_tag(mut self, tag: impl Into<String>) -> Self {
2421        self.require_tags.push(tag.into());
2422        self
2423    }
2424
2425    /// Require model
2426    pub fn require_model(mut self, model: impl Into<String>) -> Self {
2427        self.require_models.push(model.into());
2428        self
2429    }
2430
2431    /// Require tool
2432    pub fn require_tool(mut self, tool: impl Into<String>) -> Self {
2433        self.require_tools.push(tool.into());
2434        self
2435    }
2436
2437    /// Set minimum memory
2438    pub fn with_min_memory(mut self, gb: u32) -> Self {
2439        self.min_memory_gb = Some(gb);
2440        self
2441    }
2442
2443    /// Require GPU
2444    pub fn require_gpu(mut self) -> Self {
2445        self.require_gpu = true;
2446        self
2447    }
2448
2449    /// Require specific GPU vendor
2450    pub fn with_gpu_vendor(mut self, vendor: GpuVendor) -> Self {
2451        self.gpu_vendor = Some(vendor);
2452        self.require_gpu = true;
2453        self
2454    }
2455
2456    /// Set minimum VRAM
2457    pub fn with_min_vram(mut self, gb: u32) -> Self {
2458        self.min_vram_gb = Some(gb);
2459        self.require_gpu = true;
2460        self
2461    }
2462
2463    /// Set minimum context length
2464    pub fn with_min_context(mut self, length: u32) -> Self {
2465        self.min_context_length = Some(length);
2466        self
2467    }
2468
2469    /// Require modality
2470    pub fn require_modality(mut self, modality: Modality) -> Self {
2471        self.require_modalities.push(modality);
2472        self
2473    }
2474
2475    /// Check if a capability set matches this filter.
2476    ///
2477    /// Phase A.5.2: reads through `caps.views()` for the
2478    /// hardware / models / tools projections. Methods that already
2479    /// abstract field access (`has_tag` / `has_gpu` / `has_model`
2480    /// / `has_tool`) keep working unchanged. Once Phase A.5.N
2481    /// removes the typed-struct fields from `CapabilitySet`, the
2482    /// `views()` body becomes a tag-set scan and this matcher
2483    /// keeps working without further changes.
2484    pub fn matches(&self, caps: &CapabilitySet) -> bool {
2485        use crate::adapter::net::behavior::tag::{AxisSeparator, TaxonomyAxis};
2486        use crate::adapter::net::behavior::tag_codec::gpu_vendor_str;
2487
2488        // Check tags (all required tags must be present)
2489        for tag in &self.require_tags {
2490            if !caps.has_tag(tag) {
2491                return false;
2492            }
2493        }
2494
2495        // Check models (any required model must be present)
2496        if !self.require_models.is_empty() {
2497            let has_model = self.require_models.iter().any(|m| caps.has_model(m));
2498            if !has_model {
2499                return false;
2500            }
2501        }
2502
2503        // Check tools (any required tool must be present)
2504        if !self.require_tools.is_empty() {
2505            let has_tool = self.require_tools.iter().any(|t| caps.has_tool(t));
2506            if !has_tool {
2507                return false;
2508            }
2509        }
2510
2511        // Tag-direct fast paths for single-field hardware predicates —
2512        // avoid forcing the full `HardwareCapabilities` decode (sort +
2513        // per-tag axis_key parse) when only one tag's value is needed.
2514        // See `docs/misc/PERF_AUDIT_2026_05_28_CAPABILITY.md` fix #2.
2515        if let Some(min_mem) = self.min_memory_gb {
2516            let mem = caps
2517                .axis_value(TaxonomyAxis::Hardware, "memory_gb")
2518                .and_then(|s| s.parse::<u32>().ok())
2519                .unwrap_or(0);
2520            if mem < min_mem {
2521                return false;
2522            }
2523        }
2524
2525        if self.require_gpu && !caps.has_gpu() {
2526            return false;
2527        }
2528
2529        if let Some(vendor) = self.gpu_vendor {
2530            // O(1) HashSet probe for `hardware.gpu.vendor=<vendor>`.
2531            let expected = Tag::AxisValue {
2532                axis: TaxonomyAxis::Hardware,
2533                key: "gpu.vendor".to_string(),
2534                value: gpu_vendor_str(vendor).to_string(),
2535                separator: AxisSeparator::Eq,
2536            };
2537            if !caps.tags.contains(&expected) {
2538                return false;
2539            }
2540        }
2541
2542        if let Some(min_vram) = self.min_vram_gb {
2543            // Single-GPU fast path: `hardware.gpu.vram_gb=<n>`. Falls
2544            // through to the full `HardwareCapabilities::total_vram_gb`
2545            // sum for multi-GPU configs (where additional `gpu.<i>.*`
2546            // tags exist beyond the primary).
2547            let vram = caps
2548                .axis_value(TaxonomyAxis::Hardware, "gpu.vram_gb")
2549                .and_then(|s| s.parse::<u32>().ok())
2550                .unwrap_or(0);
2551            if vram < min_vram {
2552                let total = caps.views().hardware().total_vram_gb();
2553                if total < min_vram {
2554                    return false;
2555                }
2556            }
2557        }
2558
2559        // Remaining predicates need the model projection — decode lazily.
2560        if self.min_context_length.is_some() || !self.require_modalities.is_empty() {
2561            let views = caps.views();
2562
2563            if let Some(min_ctx) = self.min_context_length {
2564                let has_sufficient = views.models().iter().any(|m| m.context_length >= min_ctx);
2565                if !has_sufficient {
2566                    return false;
2567                }
2568            }
2569
2570            for modality in &self.require_modalities {
2571                let has_modality = views
2572                    .models()
2573                    .iter()
2574                    .any(|m| m.modalities.contains(modality));
2575                if !has_modality {
2576                    return false;
2577                }
2578            }
2579        }
2580
2581        true
2582    }
2583}
2584
2585// ============================================================================
2586// Capability Requirement (for load balancing)
2587// ============================================================================
2588
2589/// Capability requirement with scoring
2590#[derive(Debug, Clone, Default)]
2591pub struct CapabilityRequirement {
2592    /// Base filter
2593    pub filter: CapabilityFilter,
2594    /// Prefer more memory (weight 0.0-1.0)
2595    pub prefer_more_memory: f32,
2596    /// Prefer more VRAM (weight 0.0-1.0)
2597    pub prefer_more_vram: f32,
2598    /// Prefer faster tokens/sec (weight 0.0-1.0)
2599    pub prefer_faster_inference: f32,
2600    /// Prefer loaded models (weight 0.0-1.0)
2601    pub prefer_loaded_models: f32,
2602}
2603
2604impl CapabilityRequirement {
2605    /// Create from filter
2606    pub fn from_filter(filter: CapabilityFilter) -> Self {
2607        Self {
2608            filter,
2609            ..Default::default()
2610        }
2611    }
2612
2613    /// Set memory preference weight
2614    pub fn prefer_memory(mut self, weight: f32) -> Self {
2615        self.prefer_more_memory = weight.clamp(0.0, 1.0);
2616        self
2617    }
2618
2619    /// Set VRAM preference weight
2620    pub fn prefer_vram(mut self, weight: f32) -> Self {
2621        self.prefer_more_vram = weight.clamp(0.0, 1.0);
2622        self
2623    }
2624
2625    /// Set inference speed preference
2626    pub fn prefer_speed(mut self, weight: f32) -> Self {
2627        self.prefer_faster_inference = weight.clamp(0.0, 1.0);
2628        self
2629    }
2630
2631    /// Set loaded model preference
2632    pub fn prefer_loaded(mut self, weight: f32) -> Self {
2633        self.prefer_loaded_models = weight.clamp(0.0, 1.0);
2634        self
2635    }
2636
2637    /// Score a capability set (higher is better)
2638    pub fn score(&self, caps: &CapabilitySet) -> f32 {
2639        if !self.filter.matches(caps) {
2640            return 0.0;
2641        }
2642
2643        // Phase A.5.5: read through views() once. Same projection
2644        // pattern Phase A.5.2/A.5.3/A.5.4 applied to filter / proximity
2645        // / diff — survives Phase A.5.N field removal unchanged.
2646        let views = caps.views();
2647
2648        let mut score = 1.0;
2649
2650        // Memory score (normalized to 256GB)
2651        if self.prefer_more_memory > 0.0 {
2652            let mem_score = (views.hardware().memory_gb as f32 / 256.0).min(1.0);
2653            score += self.prefer_more_memory * mem_score;
2654        }
2655
2656        // VRAM score (normalized to 80GB)
2657        if self.prefer_more_vram > 0.0 {
2658            let vram_score = (views.hardware().total_vram_gb() as f32 / 80.0).min(1.0);
2659            score += self.prefer_more_vram * vram_score;
2660        }
2661
2662        // Inference speed score (normalized to 1000 tok/s)
2663        if self.prefer_faster_inference > 0.0 {
2664            let max_tps: u32 = views
2665                .models()
2666                .iter()
2667                .map(|m| m.tokens_per_sec)
2668                .max()
2669                .unwrap_or(0);
2670            let speed_score = (max_tps as f32 / 1000.0).min(1.0);
2671            score += self.prefer_faster_inference * speed_score;
2672        }
2673
2674        // Loaded model score
2675        if self.prefer_loaded_models > 0.0 {
2676            let models = views.models();
2677            let loaded_count = models.iter().filter(|m| m.loaded).count();
2678            let loaded_ratio = if models.is_empty() {
2679                0.0
2680            } else {
2681                loaded_count as f32 / models.len() as f32
2682            };
2683            score += self.prefer_loaded_models * loaded_ratio;
2684        }
2685
2686        score
2687    }
2688}
2689
2690// ============================================================================
2691// CardinalityProvider trait — used by the predicate planner for
2692// per-key selectivity estimates. The legacy CapabilityIndex impl
2693// was removed in Phase 3B of the multifold migration; downstream
2694// users now bring their own provider (the fold side ships one
2695// through `capability::CapabilityFold`).
2696// ============================================================================
2697
2698/// Source of per-key cardinality data for the predicate query
2699/// planner. Implementors return distinct-value counts for axis
2700/// tag keys and metadata keys; the planner uses these to order
2701/// And-clauses (rare-true first) and Or-clauses (often-true
2702/// first) for early-out savings.
2703pub trait CardinalityProvider {
2704    /// Distinct-value count for the given axis tag key. Returns 0
2705    /// when the key is absent — planner treats this as "no data,
2706    /// fall back to static cost".
2707    fn axis_cardinality(&self, key: &crate::adapter::net::behavior::tag::TagKey) -> usize;
2708
2709    /// Distinct-value count for the given metadata key.
2710    fn metadata_value_cardinality(&self, key: &str) -> usize;
2711}
2712
2713// ============================================================================
2714// Tests
2715// ============================================================================
2716
2717#[cfg(test)]
2718mod tests {
2719    use super::*;
2720    /// Fixed-bytes `EntityId` for unit-test fixtures. Valid as a
2721    /// *value* (it's just 32 bytes) but not a valid ed25519 public
2722    /// key — callers that also exercise signature verification
2723    /// should construct a real `EntityKeypair` instead.
2724    fn test_entity() -> super::super::super::identity::EntityId {
2725        super::super::super::identity::EntityId::from_bytes([0u8; 32])
2726    }
2727    /// `strip_reserved_metadata` drops every exact-match reserved
2728    /// key (`intent`, `colocate-with`, `priority`, `owner`) and
2729    /// leaves all other keys intact. The substrate calls this on
2730    /// every inbound peer announcement before downstream consumers
2731    /// (greedy admission, placement scoring) read metadata, so a
2732    /// peer can't steer receiver decisions through the
2733    /// substrate-trusted slot keys.
2734    ///
2735    /// A-4 update: `tool::*` keys are NOT stripped any more — they
2736    /// carry peer-advertised AI tool schemas / descriptions /
2737    /// tags that `MeshNode::list_tools` surfaces to agents. The
2738    /// substrate never makes trust decisions from those values, so
2739    /// stripping them would only defeat cross-mesh tool discovery.
2740    #[test]
2741    fn strip_reserved_metadata_drops_reserved_keys() {
2742        let mut ann = CapabilityAnnouncement::new(0xDEAD, test_entity(), 7, CapabilitySet::new());
2743        ann.capabilities
2744            .metadata
2745            .insert("intent".into(), "evil-tenant".into());
2746        ann.capabilities
2747            .metadata
2748            .insert("colocate-with".into(), "0xdeadbeef".into());
2749        ann.capabilities
2750            .metadata
2751            .insert("priority".into(), "9999".into());
2752        ann.capabilities
2753            .metadata
2754            .insert("owner".into(), "attacker".into());
2755        ann.capabilities.metadata.insert(
2756            "tool::web_search::description".into(),
2757            "Search the web.".into(),
2758        );
2759        ann.capabilities
2760            .metadata
2761            .insert("app::region".into(), "us-east".into());
2762        ann.capabilities
2763            .metadata
2764            .insert("user_tag".into(), "fine".into());
2765
2766        ann.strip_reserved_metadata();
2767
2768        assert!(!ann.capabilities.metadata.contains_key("intent"));
2769        assert!(!ann.capabilities.metadata.contains_key("colocate-with"));
2770        assert!(!ann.capabilities.metadata.contains_key("priority"));
2771        assert!(!ann.capabilities.metadata.contains_key("owner"));
2772        // `tool::*` keys survive — they are peer-advertised AI tool
2773        // descriptor content, not substrate trust signal.
2774        assert_eq!(
2775            ann.capabilities
2776                .metadata
2777                .get("tool::web_search::description")
2778                .map(String::as_str),
2779            Some("Search the web."),
2780        );
2781        // Non-reserved keys survive — substrate only filters its
2782        // own reserved namespace, not the caller's app namespace.
2783        assert_eq!(
2784            ann.capabilities
2785                .metadata
2786                .get("app::region")
2787                .map(String::as_str),
2788            Some("us-east"),
2789        );
2790        assert_eq!(
2791            ann.capabilities
2792                .metadata
2793                .get("user_tag")
2794                .map(String::as_str),
2795            Some("fine"),
2796        );
2797    }
2798    /// The signature transcript covers `capabilities.metadata`, so
2799    /// `strip_reserved_metadata` invalidates the signature. The
2800    /// inbound dispatch path must therefore re-broadcast the
2801    /// announcement BEFORE stripping; otherwise a multi-hop
2802    /// receiver with `require_signed_capabilities = true` would
2803    /// reject every forwarded announcement that originally carried
2804    /// a reserved metadata key.
2805    #[test]
2806    fn strip_reserved_metadata_invalidates_signature() {
2807        use super::super::super::identity::EntityKeypair;
2808        let keypair = EntityKeypair::generate();
2809        let mut ann =
2810            CapabilityAnnouncement::new(1, keypair.entity_id().clone(), 1, sample_capability_set());
2811        ann.capabilities
2812            .metadata
2813            .insert("intent".into(), "compute".into());
2814        ann.sign(&keypair);
2815
2816        // Baseline: signed announcement verifies, and the bytes a
2817        // forwarder would re-broadcast also verify (the bug a
2818        // pre-forward strip would cause).
2819        assert!(ann.verify().is_ok());
2820        let forward_bytes = ann.to_bytes();
2821        let forwarded =
2822            CapabilityAnnouncement::from_bytes(&forward_bytes).expect("forwarded parses");
2823        assert!(
2824            forwarded.verify().is_ok(),
2825            "downstream verifier must accept the un-stripped wire bytes"
2826        );
2827
2828        // After strip the signature transcript no longer matches —
2829        // pins the invariant the inbound dispatch order in
2830        // `mesh.rs::process_capability_announcement` relies on.
2831        ann.strip_reserved_metadata();
2832        assert!(
2833            ann.verify().is_err(),
2834            "strip must invalidate the signature so the substrate is forced \
2835             to strip the local copy AFTER any re-broadcast"
2836        );
2837    }
2838    fn sample_capability_set() -> CapabilitySet {
2839        let gpu = GpuInfo::new(GpuVendor::Nvidia, "RTX 4090", 24)
2840            .with_compute_units(128)
2841            .with_tensor_cores(512)
2842            .with_fp16_tflops(82.5);
2843
2844        let hardware = HardwareCapabilities::new()
2845            .with_cpu(16, 32)
2846            .with_memory(64)
2847            .with_gpu(gpu)
2848            .with_storage(2000)
2849            .with_network(10);
2850
2851        let software = SoftwareCapabilities::new()
2852            .with_os("linux", "6.1")
2853            .add_runtime("python", "3.11")
2854            .add_framework("pytorch", "2.1")
2855            .with_cuda("12.1");
2856
2857        let model = ModelCapability::new("llama-3.1-70b", "llama")
2858            .with_parameters(70.0)
2859            .with_context_length(128000)
2860            .with_quantization("fp16")
2861            .add_modality(Modality::Text)
2862            .add_modality(Modality::Code)
2863            .with_tokens_per_sec(50)
2864            .with_loaded(true);
2865
2866        let tool = ToolCapability::new("python_repl", "Python REPL")
2867            .with_version("1.0.0")
2868            .with_estimated_time(100);
2869
2870        CapabilitySet::new()
2871            .with_hardware(hardware)
2872            .with_software(software)
2873            .add_model(model)
2874            .add_tool(tool)
2875            .add_tag("inference")
2876            .add_tag("gpu")
2877            .with_limits(ResourceLimits::new().with_max_concurrent(10))
2878    }
2879    #[test]
2880    fn test_capability_set_creation() {
2881        let caps = sample_capability_set();
2882        assert!(caps.has_gpu());
2883        assert!(caps.has_tag("inference"));
2884        assert!(caps.has_model("llama-3.1-70b"));
2885        assert!(caps.has_tool("python_repl"));
2886        assert_eq!(caps.views().hardware().memory_gb, 64);
2887    }
2888    #[test]
2889    fn test_capability_set_serialization() {
2890        let caps = sample_capability_set();
2891        let bytes = caps.to_bytes();
2892        let parsed = CapabilitySet::from_bytes(&bytes).unwrap();
2893
2894        assert_eq!(
2895            caps.views().hardware().memory_gb,
2896            parsed.views().hardware().memory_gb,
2897        );
2898        assert_eq!(caps.tags, parsed.tags);
2899        assert_eq!(caps.views().models().len(), parsed.views().models().len());
2900    }
2901
2902    /// Regression guard for the `sort_by_cached_key` optimization in
2903    /// `sorted_tag_vec`: human-readable (JSON) serialization must remain
2904    /// byte-stable regardless of `HashSet` iteration order, otherwise signed
2905    /// `CapabilityAnnouncement` bytes would diverge across peers.
2906    #[test]
2907    fn json_tag_serialization_is_byte_stable_across_insertion_orders() {
2908        // Plain legacy tags (no axis prefixes, no substring overlaps) so
2909        // `Tag::to_string()` == the input string and ordering is unambiguous.
2910        let tags = [
2911            "inference",
2912            "alpha",
2913            "zulu",
2914            "mike",
2915            "bravo",
2916            "yankee",
2917            "delta",
2918        ];
2919
2920        let mut forward = CapabilitySet::new();
2921        for t in tags.iter() {
2922            forward = forward.add_tag(*t);
2923        }
2924        let mut reverse = CapabilitySet::new();
2925        for t in tags.iter().rev() {
2926            reverse = reverse.add_tag(*t);
2927        }
2928        assert_eq!(forward.tags.len(), tags.len(), "all tags should parse");
2929
2930        // JSON (human-readable) bytes must be identical regardless of the
2931        // order tags were inserted into the underlying HashSet.
2932        let a = serde_json::to_vec(&forward).unwrap();
2933        let b = serde_json::to_vec(&reverse).unwrap();
2934        assert_eq!(
2935            a, b,
2936            "JSON serialization must be insertion-order-independent"
2937        );
2938
2939        // The emitted `tags` array must be in `Tag::to_string()` sorted order.
2940        let value: serde_json::Value = serde_json::from_slice(&a).unwrap();
2941        let emitted: Vec<String> = value["tags"]
2942            .as_array()
2943            .expect("tags is a JSON array")
2944            .iter()
2945            .map(|t| t.as_str().unwrap().to_string())
2946            .collect();
2947        let mut expected = emitted.clone();
2948        expected.sort();
2949        assert_eq!(emitted, expected, "tags must be emitted in sorted order");
2950    }
2951    /// A-4: `with_metadata` consults `METADATA_RESERVED_PREFIXES`,
2952    /// which is now empty — the `tool::*` family was hoisted out
2953    /// because tool descriptors are peer-advertised content, not
2954    /// substrate-trust slots. The gate stays wired so a future
2955    /// re-add (e.g. a new substrate-internal prefix) plugs back
2956    /// in here without a fan-out edit, but the current contract is
2957    /// "tool::* writes pass through". Exact-match reserved keys
2958    /// (`intent`, `owner`, …) are NOT gated by `with_metadata`
2959    /// either — those are well-known user-facing scheduler hints
2960    /// the substrate reads and the user is expected to set.
2961    #[test]
2962    fn with_metadata_preserves_tool_prefix_after_a4() {
2963        // `tool::*` writes survive — A-4 contract.
2964        let caps = CapabilitySet::new()
2965            .with_metadata("tool::web_search::input_schema", "{}")
2966            .with_metadata("region", "us-east");
2967        assert_eq!(
2968            caps.metadata
2969                .get("tool::web_search::input_schema")
2970                .map(|s| s.as_str()),
2971            Some("{}"),
2972            "tool::* writes must pass through with_metadata: {:?}",
2973            caps.metadata,
2974        );
2975        // Non-reserved key passes through.
2976        assert_eq!(
2977            caps.metadata.get("region").map(|s| s.as_str()),
2978            Some("us-east")
2979        );
2980
2981        // Exact-match reserved keys (NOT gated) — these are
2982        // user-facing scheduler hints, the substrate reads them
2983        // and user code is expected to set them.
2984        let caps = CapabilitySet::new().with_metadata("intent", "ml-training");
2985        assert_eq!(
2986            caps.metadata.get("intent").map(|s| s.as_str()),
2987            Some("ml-training")
2988        );
2989    }
2990    /// E-2 regression: add_tools must produce the same final
2991    /// CapabilitySet as N successive add_tool calls, but via one
2992    /// set_tools invocation. We verify the equivalence by building
2993    /// the same capability set both ways and comparing.
2994    #[test]
2995    fn add_tools_batch_matches_repeated_add_tool() {
2996        let tools = [
2997            ToolCapability::new("web_search", "Web Search").with_version("1.0.0"),
2998            ToolCapability::new("summarize", "Summarize").with_version("1.0.0"),
2999            ToolCapability::new("code_eval", "Code Eval")
3000                .with_version("2.0.0")
3001                .with_input_schema(r#"{"type":"object"}"#),
3002        ];
3003
3004        let via_repeated = tools
3005            .iter()
3006            .fold(CapabilitySet::new(), |caps, t| caps.add_tool(t.clone()));
3007        let via_batch = CapabilitySet::new().add_tools(tools.iter().cloned());
3008
3009        // Tag sets must be byte-equal (the canonical software.tool.*
3010        // indexed encoding is order-stable for set_tools).
3011        assert_eq!(via_repeated.tags, via_batch.tags);
3012        // Schema metadata must be byte-equal too — set_tools is the
3013        // codepath that mirrors input/output schemas.
3014        assert_eq!(via_repeated.metadata, via_batch.metadata);
3015        // And the typed view must agree.
3016        assert_eq!(
3017            via_repeated.views().tools().len(),
3018            via_batch.views().tools().len()
3019        );
3020    }
3021
3022    /// E-2 regression: add_tools onto a non-empty set must extend,
3023    /// not replace. Guards against a future implementation that
3024    /// might mistakenly call `set_tools(iter.collect())` and drop
3025    /// the prior tools.
3026    #[test]
3027    fn add_tools_extends_existing_tools() {
3028        let caps = CapabilitySet::new()
3029            .add_tool(ToolCapability::new("first", "First").with_version("1.0.0"))
3030            .add_tools(vec![
3031                ToolCapability::new("second", "Second").with_version("1.0.0"),
3032                ToolCapability::new("third", "Third").with_version("1.0.0"),
3033            ]);
3034        assert!(caps.has_tool("first"));
3035        assert!(caps.has_tool("second"));
3036        assert!(caps.has_tool("third"));
3037    }
3038
3039    #[test]
3040    fn has_tag_matches_across_separator_forms() {
3041        // Regression for CR-1: `Tag::AxisValue` derives `PartialEq`
3042        // including the `=` vs `:` separator. A capability set built
3043        // by inserting one wire form must still be findable when the
3044        // caller queries the other — the separator is a serialization
3045        // detail, not part of identity. Mirrors the prior diff-engine
3046        // fix in commit 38612b61 but for the public membership API.
3047        use crate::adapter::net::behavior::tag::{AxisSeparator, Tag, TaxonomyAxis};
3048        let mut caps = CapabilitySet::new();
3049        caps.tags.insert(Tag::AxisValue {
3050            axis: TaxonomyAxis::Software,
3051            key: "os".to_string(),
3052            value: "linux".to_string(),
3053            separator: AxisSeparator::Colon,
3054        });
3055        // Stored colon, queried equals — must hit.
3056        assert!(caps.has_tag("software.os=linux"));
3057        // Stored colon, queried colon — must hit.
3058        assert!(caps.has_tag("software.os:linux"));
3059        // Different value — must miss.
3060        assert!(!caps.has_tag("software.os=darwin"));
3061
3062        let mut caps = CapabilitySet::new();
3063        caps.tags.insert(Tag::AxisValue {
3064            axis: TaxonomyAxis::Hardware,
3065            key: "gpu.vram_gb".to_string(),
3066            value: "80".to_string(),
3067            separator: AxisSeparator::Eq,
3068        });
3069        // Stored equals, queried colon — must hit.
3070        assert!(caps.has_tag("hardware.gpu.vram_gb:80"));
3071        // Stored equals, queried equals — must hit.
3072        assert!(caps.has_tag("hardware.gpu.vram_gb=80"));
3073    }
3074    #[test]
3075    fn test_capability_filter_matches() {
3076        let caps = sample_capability_set();
3077
3078        // Tag filter
3079        let filter = CapabilityFilter::new().require_tag("inference");
3080        assert!(filter.matches(&caps));
3081
3082        let filter = CapabilityFilter::new().require_tag("training");
3083        assert!(!filter.matches(&caps));
3084
3085        // GPU filter
3086        let filter = CapabilityFilter::new().require_gpu();
3087        assert!(filter.matches(&caps));
3088
3089        let filter = CapabilityFilter::new().with_gpu_vendor(GpuVendor::Nvidia);
3090        assert!(filter.matches(&caps));
3091
3092        let filter = CapabilityFilter::new().with_gpu_vendor(GpuVendor::Amd);
3093        assert!(!filter.matches(&caps));
3094
3095        // Memory filter
3096        let filter = CapabilityFilter::new().with_min_memory(32);
3097        assert!(filter.matches(&caps));
3098
3099        let filter = CapabilityFilter::new().with_min_memory(128);
3100        assert!(!filter.matches(&caps));
3101
3102        // Model filter
3103        let filter = CapabilityFilter::new().require_model("llama-3.1-70b");
3104        assert!(filter.matches(&caps));
3105
3106        let filter = CapabilityFilter::new().require_model("gpt-4");
3107        assert!(!filter.matches(&caps));
3108    }
3109    #[test]
3110    fn test_capability_requirement_scoring() {
3111        let caps = sample_capability_set();
3112
3113        let req = CapabilityRequirement::from_filter(CapabilityFilter::new().require_gpu())
3114            .prefer_memory(0.5)
3115            .prefer_vram(0.5)
3116            .prefer_speed(0.5);
3117
3118        let score = req.score(&caps);
3119        assert!(score > 1.0); // Base score + preferences
3120    }
3121    #[test]
3122    fn test_capability_announcement_expiry() {
3123        let caps = sample_capability_set();
3124        let mut ann = CapabilityAnnouncement::new(1, test_entity(), 1, caps);
3125
3126        // Fresh announcement should not be expired
3127        assert!(!ann.is_expired());
3128
3129        // Set timestamp to the past
3130        ann.timestamp_ns = 0;
3131        ann.ttl_secs = 1;
3132
3133        // Should be expired now
3134        assert!(ann.is_expired());
3135    }
3136    /// `CapabilityAnnouncement::is_expired()` uses `SystemTime`, so
3137    /// we can backdate `timestamp_ns` and exercise the ttl boundary
3138    /// directly. Covers the inclusive-expiry contract at every TTL
3139    /// bucket in the plan.
3140    #[test]
3141    fn announcement_is_expired_table_driven_across_ttl_buckets() {
3142        use std::time::{SystemTime, UNIX_EPOCH};
3143
3144        let now_ns = SystemTime::now()
3145            .duration_since(UNIX_EPOCH)
3146            .unwrap()
3147            .as_nanos() as u64;
3148        let sec_ns = 1_000_000_000u64;
3149
3150        // (ttl_secs, age_secs, expected_is_expired, label)
3151        let cases: &[(u32, u64, bool, &str)] = &[
3152            // TTL=0: inclusive-expiry — any age (including 0) is expired.
3153            (0, 0, true, "ttl=0 fresh"),
3154            // TTL=1: 0s age → fresh; 2s age → expired.
3155            (1, 0, false, "ttl=1s fresh"),
3156            (1, 2, true, "ttl=1s aged 2s"),
3157            // TTL=1h: boundary at 3600s.
3158            (3_600, 1, false, "ttl=1h aged 1s"),
3159            (3_600, 3_599, false, "ttl=1h aged 3599s"),
3160            (3_600, 3_600, true, "ttl=1h aged exactly 3600s (inclusive)"),
3161            (3_600, 3_601, true, "ttl=1h aged 3601s"),
3162            // TTL=1yr: day-old is fresh, 2yr-old is expired.
3163            (31_536_000, 86_400, false, "ttl=1yr aged 1 day"),
3164            (31_536_000, 31_536_001, true, "ttl=1yr aged just past"),
3165            // TTL=u32::MAX: a 1-year-old entry is still fresh. Pins
3166            // that `ttl_secs as u64` widens without wrapping.
3167            (u32::MAX, 31_536_000, false, "ttl=u32::MAX aged 1 year"),
3168        ];
3169
3170        for &(ttl_secs, age_secs, expected, label) in cases {
3171            let mut ann = CapabilityAnnouncement::new(1, test_entity(), 1, sample_capability_set());
3172            ann.ttl_secs = ttl_secs;
3173            ann.timestamp_ns = now_ns.saturating_sub(age_secs.saturating_mul(sec_ns));
3174
3175            assert_eq!(
3176                ann.is_expired(),
3177                expected,
3178                "is_expired({label}) must be {expected}",
3179            );
3180        }
3181    }
3182    // ========================================================================
3183    // Multi-hop wire format (M-1)
3184    // ========================================================================
3185
3186    #[test]
3187    fn hop_count_defaults_to_zero() {
3188        let ann = CapabilityAnnouncement::new(1, test_entity(), 1, sample_capability_set());
3189        assert_eq!(ann.hop_count, 0);
3190    }
3191    #[test]
3192    fn hop_count_roundtrips_through_serde() {
3193        let mut ann = CapabilityAnnouncement::new(1, test_entity(), 1, sample_capability_set());
3194        ann.hop_count = 7;
3195        let bytes = ann.to_bytes();
3196        let restored = CapabilityAnnouncement::from_bytes(&bytes).expect("parse");
3197        assert_eq!(restored.hop_count, 7);
3198    }
3199    #[test]
3200    fn old_format_without_hop_count_parses_as_zero() {
3201        // Hand-crafted JSON missing the `hop_count` field — the
3202        // #[serde(default)] attribute should rescue us.
3203        let payload = serde_json::json!({
3204            "node_id": 1,
3205            "entity_id": hex::encode([0u8; 32]),
3206            "version": 1,
3207            "timestamp_ns": 0u64,
3208            "ttl_secs": 300u32,
3209            "capabilities": sample_capability_set(),
3210        });
3211        let bytes = serde_json::to_vec(&payload).expect("serialize");
3212        let parsed = CapabilityAnnouncement::from_bytes(&bytes).expect("parse old format");
3213        assert_eq!(parsed.hop_count, 0);
3214    }
3215    #[test]
3216    fn signature_verifies_across_hop_count_bumps() {
3217        use super::super::super::identity::EntityKeypair;
3218        let keypair = EntityKeypair::generate();
3219        let mut ann =
3220            CapabilityAnnouncement::new(1, keypair.entity_id().clone(), 1, sample_capability_set());
3221        ann.sign(&keypair);
3222        // Baseline: freshly signed announcement verifies.
3223        assert!(ann.verify().is_ok());
3224
3225        // Simulate a forwarder bumping the counter. Signature still
3226        // holds because `hop_count` sits outside the signed envelope.
3227        for bumped in 1..=MAX_CAPABILITY_HOPS {
3228            ann.hop_count = bumped;
3229            assert!(
3230                ann.verify().is_ok(),
3231                "signature should remain valid after hop_count={}",
3232                bumped
3233            );
3234        }
3235    }
3236    #[test]
3237    fn signature_rejects_tampered_payload_even_at_hop_zero() {
3238        use super::super::super::identity::EntityKeypair;
3239        let keypair = EntityKeypair::generate();
3240        let mut ann =
3241            CapabilityAnnouncement::new(1, keypair.entity_id().clone(), 1, sample_capability_set());
3242        ann.sign(&keypair);
3243        // Flip a byte inside the signed envelope (node_id).
3244        ann.node_id ^= 0x01;
3245        assert!(ann.verify().is_err());
3246    }
3247    #[test]
3248    fn max_capability_hops_matches_pingwave_contract() {
3249        // MAX_CAPABILITY_HOPS is documented to mirror the pingwave
3250        // MAX_HOPS. If the pingwave side is ever renumbered this
3251        // test flags the divergence at compile time.
3252        assert_eq!(MAX_CAPABILITY_HOPS, 16);
3253    }
3254    // ─────────────────────────────────────────────────────────────────
3255    // v0.4 capability-auth: allow-list wire-format + signing tests
3256    // ─────────────────────────────────────────────────────────────────
3257
3258    /// An announcement with all three allow-lists empty must
3259    /// produce JSON bytes identical to a pre-v0.4 announcement.
3260    /// This is the wire-compat contract the plan §"What ships"
3261    /// pins: existing peers must round-trip a v0.4-produced
3262    /// unrestricted announcement byte-for-byte.
3263    #[test]
3264    fn empty_allow_lists_omit_fields_from_wire() {
3265        let ann = CapabilityAnnouncement::new(
3266            42,
3267            super::super::super::identity::EntityId::from_bytes([0xAA; 32]),
3268            1,
3269            sample_capability_set(),
3270        );
3271        let bytes = ann.to_bytes();
3272        let s = std::str::from_utf8(&bytes).unwrap();
3273        assert!(
3274            !s.contains("allowed_nodes"),
3275            "empty allowed_nodes must be skipped on the wire; got: {}",
3276            s
3277        );
3278        assert!(
3279            !s.contains("allowed_subnets"),
3280            "empty allowed_subnets must be skipped on the wire; got: {}",
3281            s
3282        );
3283        assert!(
3284            !s.contains("allowed_groups"),
3285            "empty allowed_groups must be skipped on the wire; got: {}",
3286            s
3287        );
3288    }
3289    /// Round-trip an announcement with each allow-list populated
3290    /// — the decoder must reconstruct the exact field values.
3291    #[test]
3292    fn populated_allow_lists_round_trip() {
3293        let mut ann = CapabilityAnnouncement::new(
3294            7,
3295            super::super::super::identity::EntityId::from_bytes([0xBB; 32]),
3296            2,
3297            sample_capability_set(),
3298        );
3299        ann.allowed_nodes = vec![100, 200, 300];
3300        ann.allowed_subnets = vec![super::super::subnet::SubnetId([0x11; 16])];
3301        ann.allowed_groups = vec![
3302            super::super::group::GroupId([0x33; 32]),
3303            super::super::group::GroupId([0x44; 32]),
3304        ];
3305        let bytes = ann.to_bytes();
3306        let decoded = CapabilityAnnouncement::from_bytes(&bytes).expect("decode");
3307        assert_eq!(decoded.allowed_nodes, ann.allowed_nodes);
3308        assert_eq!(decoded.allowed_subnets, ann.allowed_subnets);
3309        assert_eq!(decoded.allowed_groups, ann.allowed_groups);
3310    }
3311    /// The canonical signed payload of an unrestricted
3312    /// announcement must NOT carry the three allow-list keys at
3313    /// all — that's what keeps the v0.4 signed byte-pattern
3314    /// identical to the pre-v0.4 shape, so a pre-v0.4 verifier
3315    /// validates a v0.4 unrestricted announcement and vice versa.
3316    /// Distinct from `empty_allow_lists_omit_fields_from_wire`,
3317    /// which checks the same invariant on the serialized wire
3318    /// form (`to_bytes`); this one checks the canonical signed
3319    /// payload (`signed_payload`, which also zeroes `hop_count`).
3320    #[test]
3321    fn signed_payload_omits_empty_allow_lists() {
3322        use super::super::super::identity::EntityKeypair;
3323        let keypair = EntityKeypair::generate();
3324        let ann =
3325            CapabilityAnnouncement::new(5, keypair.entity_id().clone(), 1, sample_capability_set());
3326        let canonical = ann.signed_payload();
3327        let v: serde_json::Value = serde_json::from_slice(&canonical).expect("parse");
3328        let obj = v.as_object().expect("object");
3329        assert!(
3330            !obj.contains_key("allowed_nodes"),
3331            "pre-v0.4 wire shape must not carry allowed_nodes when empty"
3332        );
3333        assert!(
3334            !obj.contains_key("allowed_subnets"),
3335            "pre-v0.4 wire shape must not carry allowed_subnets when empty"
3336        );
3337        assert!(
3338            !obj.contains_key("allowed_groups"),
3339            "pre-v0.4 wire shape must not carry allowed_groups when empty"
3340        );
3341    }
3342    /// A signed announcement carrying non-empty allow-lists
3343    /// verifies after wire round-trip. Pins that the signature
3344    /// covers the new fields end-to-end.
3345    #[test]
3346    fn signed_announcement_with_allow_lists_verifies_after_round_trip() {
3347        use super::super::super::identity::EntityKeypair;
3348        let keypair = EntityKeypair::generate();
3349        let mut ann =
3350            CapabilityAnnouncement::new(9, keypair.entity_id().clone(), 1, sample_capability_set());
3351        ann.allowed_nodes = vec![1, 2, 3];
3352        ann.allowed_subnets = vec![super::super::subnet::SubnetId([0x55; 16])];
3353        ann.allowed_groups = vec![super::super::group::GroupId([0x66; 32])];
3354        ann.sign(&keypair);
3355        let bytes = ann.to_bytes();
3356        let decoded = CapabilityAnnouncement::from_bytes(&bytes).expect("decode");
3357        assert!(
3358            decoded.verify().is_ok(),
3359            "signature must cover the new allow-list fields end-to-end"
3360        );
3361    }
3362    /// Tampering with any allow-list after signing must fail
3363    /// verification — proves the signature covers each new field.
3364    #[test]
3365    fn signed_announcement_rejects_tampered_allow_lists() {
3366        use super::super::super::identity::EntityKeypair;
3367        let keypair = EntityKeypair::generate();
3368        for which in &["nodes", "subnets", "groups"] {
3369            let mut ann = CapabilityAnnouncement::new(
3370                9,
3371                keypair.entity_id().clone(),
3372                1,
3373                sample_capability_set(),
3374            );
3375            ann.allowed_nodes = vec![1, 2];
3376            ann.allowed_subnets = vec![super::super::subnet::SubnetId([0x77; 16])];
3377            ann.allowed_groups = vec![super::super::group::GroupId([0x88; 32])];
3378            ann.sign(&keypair);
3379            // Tamper post-sign.
3380            match *which {
3381                "nodes" => ann.allowed_nodes.push(999),
3382                "subnets" => ann
3383                    .allowed_subnets
3384                    .push(super::super::subnet::SubnetId([0x99; 16])),
3385                "groups" => ann
3386                    .allowed_groups
3387                    .push(super::super::group::GroupId([0xAA; 32])),
3388                _ => unreachable!(),
3389            }
3390            assert!(
3391                ann.verify().is_err(),
3392                "tampering with allowed_{} must invalidate signature",
3393                which
3394            );
3395        }
3396    }
3397    #[test]
3398    fn allow_list_cap_documented() {
3399        // Sanity: keep the doc-string + the constant in sync. If
3400        // someone bumps the cap they have to re-think wire-size
3401        // budgeting — explicit pin makes the change visible.
3402        assert_eq!(MAX_ALLOW_LIST_LEN, 64);
3403    }
3404    /// M1 regression — pre-fix, `from_bytes` accepted any allow-list
3405    /// length the wire delivered; a malicious or buggy peer could
3406    /// ship a million-entry `allowed_nodes` and the receiver would
3407    /// fold it, with every `may_execute` then linearly scanning the
3408    /// unbounded vector. Post-fix, the deserializer rejects
3409    /// announcements exceeding the documented per-axis cap.
3410    #[test]
3411    fn from_bytes_rejects_allow_list_over_cap() {
3412        for which in ["nodes", "subnets", "groups"] {
3413            let mut ann = CapabilityAnnouncement::new(
3414                1,
3415                super::super::super::identity::EntityId::from_bytes([0xAA; 32]),
3416                1,
3417                sample_capability_set(),
3418            );
3419            match which {
3420                "nodes" => {
3421                    ann.allowed_nodes = (0..(MAX_ALLOW_LIST_LEN as u64) + 1).collect();
3422                }
3423                "subnets" => {
3424                    ann.allowed_subnets = (0..(MAX_ALLOW_LIST_LEN as u8) + 1)
3425                        .map(|i| super::super::subnet::SubnetId([i; 16]))
3426                        .collect();
3427                }
3428                "groups" => {
3429                    ann.allowed_groups = (0..(MAX_ALLOW_LIST_LEN as u8) + 1)
3430                        .map(|i| super::super::group::GroupId([i; 32]))
3431                        .collect();
3432                }
3433                _ => unreachable!(),
3434            }
3435            let bytes = ann.to_bytes();
3436            assert!(
3437                CapabilityAnnouncement::from_bytes(&bytes).is_none(),
3438                "from_bytes must reject allowed_{which} exceeding MAX_ALLOW_LIST_LEN",
3439            );
3440        }
3441    }
3442    /// Boundary check — exactly `MAX_ALLOW_LIST_LEN` entries
3443    /// must STILL deserialize (the cap is inclusive).
3444    #[test]
3445    fn from_bytes_accepts_allow_list_at_cap() {
3446        let mut ann = CapabilityAnnouncement::new(
3447            1,
3448            super::super::super::identity::EntityId::from_bytes([0xAB; 32]),
3449            1,
3450            sample_capability_set(),
3451        );
3452        ann.allowed_nodes = (0..MAX_ALLOW_LIST_LEN as u64).collect();
3453        let bytes = ann.to_bytes();
3454        let decoded =
3455            CapabilityAnnouncement::from_bytes(&bytes).expect("exactly-at-cap must deserialize");
3456        assert_eq!(decoded.allowed_nodes.len(), MAX_ALLOW_LIST_LEN);
3457    }
3458    /// Regression for a cubic-flagged P1: adding `hop_count` to the
3459    /// signed canonical serialization broke rolling-upgrade
3460    /// compatibility — pre-M-1 announcements were signed over bytes
3461    /// that had no `hop_count` key, so a post-M-1 verifier's
3462    /// recomputed `signed_payload()` (which unconditionally
3463    /// serialized `hop_count: 0`) produced different bytes and the
3464    /// signature failed.
3465    ///
3466    /// The fix is `#[serde(skip_serializing_if = "is_hop_count_zero")]`:
3467    /// both pre-M-1 signers AND post-M-1 signed_payload (which
3468    /// always zeros hop_count) omit the field, producing identical
3469    /// canonical bytes.
3470    ///
3471    /// Approach: construct a mirror struct matching pre-M-1's layout
3472    /// (same fields, no hop_count) and compare its serialized output
3473    /// byte-for-byte with the current node's `signed_payload()`.
3474    /// Can't use `serde_json::json!` — that goes through
3475    /// `serde_json::Map` which sorts keys alphabetically, whereas
3476    /// `CapabilityAnnouncement`'s derived Serialize writes in
3477    /// struct-declaration order. The mirror struct keeps the same
3478    /// serialization path.
3479    #[test]
3480    fn reflex_addr_roundtrips_through_serde_when_set() {
3481        // Stage 2 of NAT traversal: `reflex_addr` rides the
3482        // signed envelope when the classifier has an observed
3483        // address. Round-trip must preserve it intact.
3484        let reflex: std::net::SocketAddr = "198.51.100.5:54321".parse().unwrap();
3485        let ann = CapabilityAnnouncement::new(1, test_entity(), 1, sample_capability_set())
3486            .with_reflex_addr(Some(reflex));
3487        let bytes = ann.to_bytes();
3488        let restored = CapabilityAnnouncement::from_bytes(&bytes).expect("parse");
3489        assert_eq!(restored.reflex_addr, Some(reflex));
3490    }
3491    #[test]
3492    fn reflex_addr_none_is_omitted_from_wire_bytes() {
3493        // The `skip_serializing_if = "Option::is_none"` on
3494        // `reflex_addr` is what preserves on-wire byte-compat
3495        // with pre-stage-2 announcements. The canonical bytes
3496        // must not mention `reflex_addr` at all when it's None —
3497        // otherwise pre-stage-2 nodes' signatures wouldn't
3498        // verify on post-stage-2 nodes (same shape of
3499        // compatibility guarantee as `hop_count`).
3500        let ann = CapabilityAnnouncement::new(1, test_entity(), 1, sample_capability_set());
3501        let bytes = ann.to_bytes();
3502        let text = std::str::from_utf8(&bytes).expect("valid utf8");
3503        assert!(
3504            !text.contains("reflex_addr"),
3505            "reflex_addr key must be omitted when the field is None; got: {text}",
3506        );
3507    }
3508    // `signed_payload_stays_compatible_with_pre_hop_count_format`
3509    // intentionally removed in Phase A.5.N.3. That test pinned the
3510    // pre-hop_count byte-identical serialization so signatures
3511    // issued before that field landed could still verify after a
3512    // rolling upgrade. Phase A.5.N.3 changes the CapabilitySet
3513    // wire format outright (no more `hardware`/`software`/`models`/
3514    // `tools`/`limits` keys; just `tags` + `metadata`), so peers
3515    // must upgrade together — there is no rolling-upgrade path
3516    // across this commit. The hop_count omission contract itself
3517    // is still pinned by `hop_count_zero_omits_key_while_nonzero_keeps_it`.
3518
3519    #[test]
3520    fn hop_count_zero_omits_key_while_nonzero_keeps_it() {
3521        // Complements the cross-version compat test: proves the
3522        // serde predicate behaves as documented — hop_count=0 is
3523        // elided (old-format compat) but hop_count=N>0 survives on
3524        // the wire so forwarders can read + bump it.
3525        let caps = sample_capability_set();
3526        let mut ann = CapabilityAnnouncement::new(1, test_entity(), 1, caps);
3527
3528        let zero_bytes = ann.to_bytes();
3529        let zero_str = std::str::from_utf8(&zero_bytes).expect("utf8");
3530        assert!(
3531            !zero_str.contains("hop_count"),
3532            "hop_count=0 must be omitted from serialized output",
3533        );
3534
3535        ann.hop_count = 3;
3536        let bumped_bytes = ann.to_bytes();
3537        let bumped_str = std::str::from_utf8(&bumped_bytes).expect("utf8");
3538        assert!(
3539            bumped_str.contains("\"hop_count\":3"),
3540            "hop_count>0 must survive serialization so forwarders \
3541             can read + bump. Got: {}",
3542            bumped_str,
3543        );
3544    }
3545    // ========================================================================
3546    // Scope helpers (`matches_scope`) — scope tag resolution itself
3547    // is tested in `behavior::fold::capability_bridge::tests` under
3548    // `scope_from_membership_tags`.
3549    // ========================================================================
3550
3551    #[test]
3552    fn matches_scope_global_visible_to_tenant_filter() {
3553        // A peer that doesn't tag itself stays discoverable under
3554        // tenant queries — this is the v1-permissive default that
3555        // keeps existing announcements working when scoped queries
3556        // ship.
3557        let global = CapabilityScope::Global;
3558        assert!(matches_scope(
3559            &global,
3560            &ScopeFilter::Tenant("oem-123"),
3561            false
3562        ));
3563        assert!(matches_scope(
3564            &global,
3565            &ScopeFilter::Region("eu-west"),
3566            false
3567        ));
3568        assert!(matches_scope(&global, &ScopeFilter::Any, false));
3569
3570        // GlobalOnly filter: only Global candidates pass.
3571        assert!(matches_scope(&global, &ScopeFilter::GlobalOnly, false));
3572        let tenant_only = CapabilityScope::Tenants(vec!["foo".to_string()]);
3573        assert!(!matches_scope(
3574            &tenant_only,
3575            &ScopeFilter::GlobalOnly,
3576            false
3577        ));
3578    }
3579    #[test]
3580    fn matches_scope_subnet_local_excluded_from_any() {
3581        // SubnetLocal is opt-out from cross-subnet discovery: it
3582        // shows up only under SameSubnet (and only when the
3583        // caller-supplied predicate confirms membership).
3584        let sl = CapabilityScope::SubnetLocal;
3585        assert!(!matches_scope(&sl, &ScopeFilter::Any, false));
3586        assert!(!matches_scope(&sl, &ScopeFilter::Any, true));
3587        assert!(!matches_scope(&sl, &ScopeFilter::Tenant("foo"), true));
3588        assert!(!matches_scope(&sl, &ScopeFilter::GlobalOnly, true));
3589
3590        // SameSubnet with same_subnet=true admits SubnetLocal.
3591        assert!(matches_scope(&sl, &ScopeFilter::SameSubnet, true));
3592        // SameSubnet with same_subnet=false rejects SubnetLocal.
3593        assert!(!matches_scope(&sl, &ScopeFilter::SameSubnet, false));
3594
3595        // Tenant filter against a tenant-tagged candidate behaves
3596        // as expected — verifies the SubnetLocal branch isn't
3597        // bleeding into the tenant arm.
3598        let tenants = CapabilityScope::Tenants(vec!["oem-123".to_string()]);
3599        assert!(matches_scope(
3600            &tenants,
3601            &ScopeFilter::Tenant("oem-123"),
3602            false
3603        ));
3604        assert!(!matches_scope(
3605            &tenants,
3606            &ScopeFilter::Tenant("other"),
3607            false
3608        ));
3609    }
3610    // ========================================================================
3611    // CapabilitySet builders for reserved scope tags
3612    // ========================================================================
3613
3614    #[test]
3615    fn with_tenant_scope_appends_prefixed_tag() {
3616        let caps = CapabilitySet::new()
3617            .add_tag("gpu")
3618            .with_tenant_scope("oem-123");
3619        assert!(caps.has_tag("gpu"));
3620        assert!(caps.has_tag("scope:tenant:oem-123"));
3621
3622        // The builder writes the wire string the bridge's
3623        // `scope_from_membership_tags` matches on.
3624        let wire_tags: Vec<String> = caps.tags.iter().map(|t| t.to_string()).collect();
3625        let resolved =
3626            super::super::fold::capability_bridge::scope_from_membership_tags(&wire_tags);
3627        assert_eq!(
3628            resolved,
3629            CapabilityScope::Tenants(vec!["oem-123".to_string()]),
3630        );
3631    }
3632    #[test]
3633    fn with_tenant_scope_is_idempotent_and_drops_empty() {
3634        let caps = CapabilitySet::new()
3635            .with_tenant_scope("oem-123")
3636            .with_tenant_scope("oem-123") // duplicate
3637            .with_tenant_scope(""); // empty — silently dropped
3638                                    // Phase A.5.N.2: tags are typed; render to wire form
3639                                    // for prefix-string filtering.
3640        let tenant_tags: Vec<String> = caps
3641            .tags
3642            .iter()
3643            .map(|t| t.to_string())
3644            .filter(|s| s.starts_with(TAG_SCOPE_TENANT_PREFIX))
3645            .collect();
3646        assert_eq!(
3647            tenant_tags.len(),
3648            1,
3649            "duplicate not deduped: {:?}",
3650            caps.tags
3651        );
3652        assert_eq!(tenant_tags[0], "scope:tenant:oem-123");
3653    }
3654    #[test]
3655    fn with_region_and_subnet_local_scope_compose_with_resolver() {
3656        use super::super::fold::capability_bridge::scope_from_membership_tags;
3657        let to_wire = |caps: &CapabilitySet| -> Vec<String> {
3658            caps.tags.iter().map(|t| t.to_string()).collect()
3659        };
3660
3661        // Region builder produces a Regions scope.
3662        let caps_region = CapabilitySet::new().with_region_scope("eu-west");
3663        assert!(caps_region.has_tag("scope:region:eu-west"));
3664        assert_eq!(
3665            scope_from_membership_tags(&to_wire(&caps_region)),
3666            CapabilityScope::Regions(vec!["eu-west".to_string()]),
3667        );
3668
3669        // Empty region is dropped by the builder (matches the
3670        // resolver's empty-id rejection).
3671        let caps_empty_region = CapabilitySet::new().with_region_scope("");
3672        assert!(caps_empty_region.tags.is_empty());
3673
3674        // SubnetLocal builder is idempotent and dominates tenant
3675        // tags (strictest scope wins) — the resolver test below
3676        // is what locks in the precedence; the builder just has
3677        // to produce a list the resolver reads correctly.
3678        let caps_local = CapabilitySet::new()
3679            .with_tenant_scope("oem-123")
3680            .with_subnet_local_scope()
3681            .with_subnet_local_scope(); // idempotent
3682        let local_tags: Vec<String> = caps_local
3683            .tags
3684            .iter()
3685            .map(|t| t.to_string())
3686            .filter(|s| s.as_str() == TAG_SCOPE_SUBNET_LOCAL)
3687            .collect();
3688        assert_eq!(local_tags.len(), 1);
3689        assert_eq!(
3690            scope_from_membership_tags(&to_wire(&caps_local)),
3691            CapabilityScope::SubnetLocal
3692        );
3693    }
3694    // ========================================================================
3695    // Chain composition helpers — Phase 3 of CAPABILITY_ENHANCEMENTS_PLAN.md.
3696    // ========================================================================
3697
3698    fn reserved_tag(prefix: &str, body: &str) -> Tag {
3699        Tag::Reserved {
3700            prefix: prefix.to_string(),
3701            body: body.to_string(),
3702        }
3703    }
3704    #[test]
3705    fn require_chain_emits_causal_reserved_tag() {
3706        let caps = CapabilitySet::new().require_chain("abc123");
3707        assert!(caps.tags.contains(&reserved_tag("causal:", "abc123")));
3708    }
3709    #[test]
3710    fn require_chain_is_idempotent() {
3711        let caps = CapabilitySet::new()
3712            .require_chain("abc123")
3713            .require_chain("abc123");
3714        let causal_count = caps
3715            .tags
3716            .iter()
3717            .filter(|t| matches!(t, Tag::Reserved { prefix, .. } if prefix == "causal:"))
3718            .count();
3719        assert_eq!(causal_count, 1);
3720    }
3721    #[test]
3722    fn require_chain_drops_empty_hash() {
3723        let caps = CapabilitySet::new().require_chain("");
3724        assert!(caps.tags.is_empty());
3725    }
3726    #[test]
3727    fn require_chain_tip_emits_with_seq_separator() {
3728        let caps = CapabilitySet::new().require_chain_tip("abc", 100);
3729        assert!(caps.tags.contains(&reserved_tag("causal:", "abc:100")));
3730    }
3731    #[test]
3732    fn require_chain_range_emits_bracket_form() {
3733        let caps = CapabilitySet::new().require_chain_range("abc", 100, 200);
3734        assert!(caps
3735            .tags
3736            .contains(&reserved_tag("causal:", "abc[100..200]")));
3737    }
3738    #[test]
3739    fn require_chain_range_drops_inverted_or_equal_range() {
3740        // Equal range: silently dropped (zero-length range is meaningless).
3741        let caps = CapabilitySet::new().require_chain_range("abc", 100, 100);
3742        assert!(caps.tags.is_empty());
3743        // Inverted range: silently dropped.
3744        let caps = CapabilitySet::new().require_chain_range("abc", 200, 100);
3745        assert!(caps.tags.is_empty());
3746    }
3747    #[test]
3748    fn require_any_chain_emits_one_tag_per_hash() {
3749        let caps = CapabilitySet::new().require_any_chain(["abc", "def", "ghi"]);
3750        assert!(caps.tags.contains(&reserved_tag("causal:", "abc")));
3751        assert!(caps.tags.contains(&reserved_tag("causal:", "def")));
3752        assert!(caps.tags.contains(&reserved_tag("causal:", "ghi")));
3753        assert_eq!(caps.tags.len(), 3);
3754    }
3755    #[test]
3756    fn require_any_chain_skips_empty_hashes() {
3757        let caps = CapabilitySet::new().require_any_chain(["abc", "", "def"]);
3758        assert_eq!(caps.tags.len(), 2);
3759    }
3760    #[test]
3761    fn from_fork_emits_fork_of_reserved_tag() {
3762        let caps = CapabilitySet::new().from_fork("parent_hash");
3763        assert!(caps.tags.contains(&reserved_tag("fork-of:", "parent_hash")));
3764    }
3765    #[test]
3766    fn heat_level_emits_chain_hash_equals_rate_with_two_decimals() {
3767        let caps = CapabilitySet::new().heat_level("abc", 0.85);
3768        assert!(caps.tags.contains(&reserved_tag("heat:", "abc=0.85")));
3769    }
3770    #[test]
3771    fn heat_level_clamps_out_of_range_rate() {
3772        // Above 1.0 clamps to 1.00.
3773        let caps = CapabilitySet::new().heat_level("abc", 1.5);
3774        assert!(caps.tags.contains(&reserved_tag("heat:", "abc=1.00")));
3775        // Below 0.0 clamps to 0.00.
3776        let caps = CapabilitySet::new().heat_level("abc", -0.3);
3777        assert!(caps.tags.contains(&reserved_tag("heat:", "abc=0.00")));
3778    }
3779    #[test]
3780    fn heat_level_drops_non_finite_rate() {
3781        let caps = CapabilitySet::new().heat_level("abc", f64::NAN);
3782        assert!(caps.tags.is_empty());
3783        let caps = CapabilitySet::new().heat_level("abc", f64::INFINITY);
3784        assert!(caps.tags.is_empty());
3785    }
3786    #[test]
3787    fn chain_helpers_compose_naturally_in_a_builder_chain() {
3788        // Pinned: helpers chain ergonomically without intermediate
3789        // bindings or `.clone()`s. This is the contract that makes
3790        // the surface readable in operator code.
3791        let caps = CapabilitySet::new()
3792            .require_chain("origin-hash")
3793            .require_chain_tip("chain-with-tip", 1024)
3794            .require_chain_range("range-chain", 100, 500)
3795            .require_any_chain(["alt-1", "alt-2"])
3796            .from_fork("parent")
3797            .heat_level("origin-hash", 0.5);
3798        // Six emissions: 1 + 1 + 1 + 2 + 1 + 1 = 7 reserved tags.
3799        let reserved_count = caps
3800            .tags
3801            .iter()
3802            .filter(|t| matches!(t, Tag::Reserved { .. }))
3803            .count();
3804        assert_eq!(reserved_count, 7, "tags: {:?}", caps.tags);
3805    }
3806    // ========================================================================
3807    // View projections — `From<&CapabilitySet>` + `CapabilitySet::views`.
3808    // Phase A.4: pin the contract so Phase A.5's wire-format migration
3809    // doesn't drift the projection semantics.
3810    // ========================================================================
3811
3812    #[test]
3813    fn projection_hardware_round_trips_via_from_impl() {
3814        // Phase A.5.N.3: `From<&CapabilitySet>` reconstructs the
3815        // typed view by scanning the tag set. The round-trip
3816        // through builder → views → comparison pins the bijection
3817        // for hardware fields the codec covers.
3818        let hw_input = HardwareCapabilities::new().with_cpu(8, 16).with_memory(64);
3819        let caps = CapabilitySet::new().with_hardware(hw_input.clone());
3820        let hw_via_from: HardwareCapabilities = (&caps).into();
3821        assert_eq!(hw_via_from, hw_input);
3822    }
3823    #[test]
3824    fn projection_software_and_resource_limits_round_trip() {
3825        // Round-trip via builder → views for software and limits.
3826        let sw_input = SoftwareCapabilities::new().with_os("linux", "6.5");
3827        let limits_input = ResourceLimits::new()
3828            .with_max_concurrent(64)
3829            .with_rate_limit(100);
3830        let caps = CapabilitySet::new()
3831            .with_software(sw_input.clone())
3832            .with_limits(limits_input.clone());
3833        let sw: SoftwareCapabilities = (&caps).into();
3834        assert_eq!(sw, sw_input);
3835        let limits: ResourceLimits = (&caps).into();
3836        assert_eq!(limits, limits_input);
3837    }
3838    #[test]
3839    fn views_struct_returns_all_five_projections() {
3840        // Pin: `views()` returns the five typed projections together,
3841        // each lazily decoded on first access. Cheaper than reaching
3842        // for the From impls when the consumer reads more than one
3843        // axis (the OnceCell cache hits subsequent reads).
3844        let caps = sample_capability_set();
3845        let views = caps.views();
3846        // Round-trip via builder → views — assert the projection
3847        // is non-default for the fields the sample populates.
3848        assert!(views.hardware().memory_gb > 0);
3849        assert!(!views.models().is_empty());
3850        assert!(!views.tools().is_empty());
3851    }
3852    #[test]
3853    fn lazy_view_handle_caches_per_projection() {
3854        // Phase 1 of `CAPABILITY_ENHANCEMENTS_PLAN.md`: each
3855        // projection is decoded at most once per handle. A second
3856        // read of the same projection returns the cached value
3857        // (proven via pointer-equality on the borrowed reference).
3858        let caps = sample_capability_set();
3859        let views = caps.views();
3860        let hw_ptr_1 = views.hardware() as *const _;
3861        let hw_ptr_2 = views.hardware() as *const _;
3862        assert_eq!(hw_ptr_1, hw_ptr_2, "hardware projection must be cached");
3863        let models_ptr_1 = views.models() as *const _;
3864        let models_ptr_2 = views.models() as *const _;
3865        assert_eq!(
3866            models_ptr_1, models_ptr_2,
3867            "models projection must be cached",
3868        );
3869    }
3870    // ========================================================================
3871    // Phase A.5.1: typed-tag access methods + wire-format snapshots.
3872    // ========================================================================
3873
3874    #[test]
3875    fn typed_tags_method_round_trips() {
3876        // `CapabilitySet::typed_tags()` and `from_typed_tags()`
3877        // are the future access pattern; pin the round-trip
3878        // contract here as inherent-method tests, mirroring the
3879        // standalone-function pin in `tag_codec`.
3880        let caps = sample_capability_set();
3881        let tag_set = caps.typed_tags();
3882        let caps2 = CapabilitySet::from_typed_tags(&tag_set);
3883        // Phase A.5.N.3: round-trip is via the canonical tag set;
3884        // compare projections (tool schemas live in metadata so
3885        // they don't survive `from_typed_tags`, which gets only
3886        // the bare tag set).
3887        let v1 = caps.views();
3888        let v2 = caps2.views();
3889        assert_eq!(v1.hardware(), v2.hardware());
3890        assert_eq!(v1.models(), v2.models());
3891        assert_eq!(v1.resource_limits(), v2.resource_limits());
3892        // Tools' non-schema fields round-trip; schemas are dropped
3893        // (`from_typed_tags` produces empty metadata by design).
3894        let v1_tools = v1.tools();
3895        let v2_tools = v2.tools();
3896        assert_eq!(v1_tools.len(), v2_tools.len());
3897        for (a, b) in v1_tools.iter().zip(v2_tools.iter()) {
3898            assert_eq!(a.tool_id, b.tool_id);
3899            assert_eq!(a.name, b.name);
3900            assert_eq!(a.version, b.version);
3901        }
3902    }
3903    #[test]
3904    fn typed_tags_default_capability_set_is_empty() {
3905        // Pinned: a default CapabilitySet's typed-tag set is empty.
3906        // Future Phase A.5.2's wire-format change (omitting
3907        // empty-tag-set sets from the wire) depends on this.
3908        let caps = CapabilitySet::default();
3909        assert!(caps.typed_tags().is_empty());
3910    }
3911    // ========================================================================
3912    // CapabilitySet::diff tests (Phase 1 of CAPABILITY_ENHANCEMENTS_PLAN.md).
3913    // ========================================================================
3914
3915    #[test]
3916    fn diff_empty_vs_empty_is_empty() {
3917        let prev = CapabilitySet::default();
3918        let curr = CapabilitySet::default();
3919        let diff = curr.diff(&prev);
3920        assert!(diff.is_empty());
3921        assert!(diff.added_tags.is_empty());
3922        assert!(diff.removed_tags.is_empty());
3923        assert!(diff.changed_metadata.is_empty());
3924    }
3925    #[test]
3926    fn diff_against_empty_reports_full_added() {
3927        let prev = CapabilitySet::default();
3928        let curr = CapabilitySet::new()
3929            .add_tag("inference")
3930            .with_metadata("intent", "ml-training");
3931        let diff = curr.diff(&prev);
3932        assert!(!diff.is_empty());
3933        assert_eq!(diff.added_tags.len(), 1);
3934        let inference_tag = Tag::parse("inference").unwrap();
3935        assert!(diff.added_tags.contains(&inference_tag));
3936        assert!(diff.removed_tags.is_empty());
3937        assert_eq!(diff.changed_metadata.len(), 1);
3938        assert!(matches!(
3939            &diff.changed_metadata[0],
3940            MetadataChange::Added { key, value }
3941                if key == "intent" && value == "ml-training"
3942        ));
3943    }
3944    #[test]
3945    fn diff_added_and_removed_tags_are_separated() {
3946        // Distinct sets: prev has {a, b}, curr has {b, c}.
3947        // Diff must show added={c}, removed={a}; b is unchanged.
3948        let prev = CapabilitySet::new().add_tag("a").add_tag("b");
3949        let curr = CapabilitySet::new().add_tag("b").add_tag("c");
3950        let diff = curr.diff(&prev);
3951        let added: Vec<_> = diff.added_tags.iter().map(|t| t.to_string()).collect();
3952        let removed: Vec<_> = diff.removed_tags.iter().map(|t| t.to_string()).collect();
3953        assert_eq!(added, vec!["c".to_string()]);
3954        assert_eq!(removed, vec!["a".to_string()]);
3955    }
3956    #[test]
3957    fn diff_ignores_separator_form_on_axis_value_tags() {
3958        // Regression for CR-3: `Tag::AxisValue` PartialEq distinguishes
3959        // `=` vs `:`. A naive `HashSet::difference` would land two
3960        // semantically-identical tags as both Added and Removed.
3961        // The structural `DiffEngine::diff` was patched in 38612b61;
3962        // the companion `CapabilitySet::diff` API was not.
3963        use crate::adapter::net::behavior::tag::{AxisSeparator, Tag, TaxonomyAxis};
3964        let mut prev = CapabilitySet::new();
3965        prev.tags.insert(Tag::AxisValue {
3966            axis: TaxonomyAxis::Software,
3967            key: "os".to_string(),
3968            value: "linux".to_string(),
3969            separator: AxisSeparator::Eq,
3970        });
3971        let mut curr = CapabilitySet::new();
3972        curr.tags.insert(Tag::AxisValue {
3973            axis: TaxonomyAxis::Software,
3974            key: "os".to_string(),
3975            value: "linux".to_string(),
3976            separator: AxisSeparator::Colon,
3977        });
3978        let diff = curr.diff(&prev);
3979        assert!(
3980            diff.added_tags.is_empty(),
3981            "added tags should be empty for separator-only difference, got {:?}",
3982            diff.added_tags
3983        );
3984        assert!(
3985            diff.removed_tags.is_empty(),
3986            "removed tags should be empty for separator-only difference, got {:?}",
3987            diff.removed_tags
3988        );
3989    }
3990    #[test]
3991    fn diff_metadata_updated_for_value_change() {
3992        let prev = CapabilitySet::new().with_metadata("intent", "ml-training");
3993        let curr = CapabilitySet::new().with_metadata("intent", "embedding");
3994        let diff = curr.diff(&prev);
3995        assert!(diff.added_tags.is_empty());
3996        assert!(diff.removed_tags.is_empty());
3997        assert_eq!(diff.changed_metadata.len(), 1);
3998        match &diff.changed_metadata[0] {
3999            MetadataChange::Updated {
4000                key,
4001                prev_value,
4002                new_value,
4003            } => {
4004                assert_eq!(key, "intent");
4005                assert_eq!(prev_value, "ml-training");
4006                assert_eq!(new_value, "embedding");
4007            }
4008            other => panic!("expected Updated, got {other:?}"),
4009        }
4010    }
4011    #[test]
4012    fn diff_metadata_key_rename_is_remove_plus_add_not_update() {
4013        // Pinned: a key rename surfaces as Removed + Added, NOT
4014        // as Updated. Key identity changes are semantically
4015        // distinct from value-of-same-key changes.
4016        let prev = CapabilitySet::new().with_metadata("old-key", "v");
4017        let curr = CapabilitySet::new().with_metadata("new-key", "v");
4018        let diff = curr.diff(&prev);
4019        assert_eq!(diff.changed_metadata.len(), 2);
4020        // BTreeMap iteration is sorted, so "new-key" comes before "old-key".
4021        let kinds: Vec<_> = diff
4022            .changed_metadata
4023            .iter()
4024            .map(|c| match c {
4025                MetadataChange::Added { key, .. } => format!("added:{key}"),
4026                MetadataChange::Removed { key, .. } => format!("removed:{key}"),
4027                MetadataChange::Updated { key, .. } => format!("updated:{key}"),
4028            })
4029            .collect();
4030        assert!(
4031            kinds.contains(&"added:new-key".to_string())
4032                && kinds.contains(&"removed:old-key".to_string()),
4033            "expected Added(new-key) + Removed(old-key); got {kinds:?}"
4034        );
4035    }
4036    #[test]
4037    fn diff_changed_metadata_preserves_btreemap_ordering() {
4038        // BTreeMap iteration order is stable + sorted. The diff
4039        // walk emits changes in key order so consumers can rely
4040        // on deterministic output.
4041        let prev = CapabilitySet::default();
4042        let curr = CapabilitySet::new()
4043            .with_metadata("zebra", "z")
4044            .with_metadata("alpha", "a")
4045            .with_metadata("middle", "m");
4046        let diff = curr.diff(&prev);
4047        let keys: Vec<_> = diff
4048            .changed_metadata
4049            .iter()
4050            .map(|c| match c {
4051                MetadataChange::Added { key, .. }
4052                | MetadataChange::Removed { key, .. }
4053                | MetadataChange::Updated { key, .. } => key.clone(),
4054            })
4055            .collect();
4056        assert_eq!(keys, vec!["alpha", "middle", "zebra"]);
4057    }
4058    #[test]
4059    fn diff_round_trips_via_apply_diff_on_canonical_diff_engine() {
4060        // Property-style: applying the structural DiffEngine ops
4061        // computed from `prev → curr` produces a CapabilitySet
4062        // whose tags + metadata match `curr`. The two diff surfaces
4063        // (this method's set/map diff, DiffEngine's structural ops)
4064        // are different shapes of the same change information; this
4065        // test pins they agree on the underlying state transition.
4066        use crate::adapter::net::behavior::diff::{CapabilityDiff, DiffEngine};
4067
4068        let prev = CapabilitySet::new()
4069            .add_tag("inference")
4070            .with_metadata("intent", "old");
4071        let curr = prev
4072            .clone()
4073            .add_tag("training")
4074            .with_metadata("intent", "new")
4075            .with_metadata("colocate-with", "chain-a");
4076        // DiffEngine produces structural ops; apply them to prev
4077        // and assert tags + metadata match curr (state convergence).
4078        let ops = DiffEngine::diff(&prev, &curr);
4079        let applied =
4080            DiffEngine::apply_with_version(&prev, 1, &CapabilityDiff::new(1, 1, 2, ops), true)
4081                .unwrap();
4082        assert_eq!(applied.tags, curr.tags);
4083        // DiffEngine doesn't emit metadata ops yet; metadata diff
4084        // ships separately and is consumed by event-driven listeners,
4085        // not by the diff-apply propagation path. Pin the contract
4086        // here so a future DiffEngine extension that adds metadata
4087        // ops doesn't accidentally regress this surface.
4088        let cset_diff = curr.diff(&prev);
4089        assert!(!cset_diff.is_empty());
4090        assert_eq!(cset_diff.changed_metadata.len(), 2);
4091    }
4092    #[test]
4093    fn wire_format_serialization_snapshot() {
4094        // Pin the post-Phase-A.5.N.3 wire format. CapabilitySet
4095        // ships exactly two top-level keys now: `tags` (the
4096        // canonical tag-set, holding axis-prefixed + reserved +
4097        // legacy entries as a JSON string array via Tag's
4098        // custom serde) and `metadata` (a free-form key-value
4099        // map). Hardware / software / models / tools / limits
4100        // fields no longer exist on the wire — their content is
4101        // encoded as tags.
4102        let caps = CapabilitySet::new()
4103            .with_hardware(HardwareCapabilities::new().with_cpu(8, 16))
4104            .add_tag("inference");
4105        let json = String::from_utf8(caps.to_bytes()).unwrap();
4106        assert!(json.contains("\"tags\":"), "missing tags field: {json}");
4107        assert!(
4108            json.contains("\"metadata\":"),
4109            "missing metadata field: {json}"
4110        );
4111        // The legacy untyped tag rides through unchanged.
4112        assert!(json.contains("\"inference\""), "missing legacy tag: {json}");
4113        // Hardware fields are encoded as axis tags inside `tags`.
4114        assert!(
4115            json.contains("\"hardware.cpu_cores=8\""),
4116            "missing hardware.cpu_cores=8 tag: {json}",
4117        );
4118        assert!(
4119            json.contains("\"hardware.cpu_threads=16\""),
4120            "missing hardware.cpu_threads=16 tag: {json}",
4121        );
4122        // Old top-level typed-struct keys are gone.
4123        assert!(
4124            !json.contains("\"hardware\":"),
4125            "stale hardware key: {json}"
4126        );
4127        assert!(
4128            !json.contains("\"software\":"),
4129            "stale software key: {json}"
4130        );
4131        assert!(!json.contains("\"models\":"), "stale models key: {json}");
4132        assert!(!json.contains("\"tools\":"), "stale tools key: {json}");
4133        assert!(!json.contains("\"limits\":"), "stale limits key: {json}");
4134    }
4135    #[test]
4136    fn wire_format_round_trips_through_json() {
4137        // Pinned: a CapabilitySet round-trips through `to_bytes` →
4138        // `from_bytes`. Phase A.5.N.2's wire format change must
4139        // preserve this property — a CapabilitySet built via the
4140        // typed builder methods then serialized then deserialized
4141        // produces an equal value. Test against a non-trivial
4142        // capability set to exercise every field.
4143        let caps = sample_capability_set();
4144        let bytes = caps.to_bytes();
4145        let caps2 = CapabilitySet::from_bytes(&bytes).expect("round-trip parses");
4146        assert_eq!(caps, caps2);
4147    }
4148
4149    #[test]
4150    fn compact_wire_format_round_trips_and_interops_with_json() {
4151        // Pinned: postcard-format round-trip preserves the
4152        // CapabilitySet AND a single `from_bytes` accepts both
4153        // encodings. Rollout from JSON to compact requires that any
4154        // peer running this code can read either format.
4155        let caps = sample_capability_set();
4156        let json_bytes = caps.to_bytes();
4157        let compact_bytes = caps.to_bytes_compact();
4158        assert_eq!(json_bytes.first(), Some(&b'{'));
4159        assert_eq!(compact_bytes.first(), Some(&COMPACT_FORMAT_TAG));
4160        assert!(
4161            compact_bytes.len() < json_bytes.len(),
4162            "compact ({} bytes) should be smaller than JSON ({} bytes)",
4163            compact_bytes.len(),
4164            json_bytes.len()
4165        );
4166        let from_json = CapabilitySet::from_bytes(&json_bytes).expect("json parses");
4167        let from_compact = CapabilitySet::from_bytes(&compact_bytes).expect("compact parses");
4168        assert_eq!(caps, from_json);
4169        assert_eq!(caps, from_compact);
4170    }
4171
4172    #[test]
4173    fn from_bytes_rejects_unknown_format_tag() {
4174        // Any leading byte other than `b'{'` or COMPACT_FORMAT_TAG
4175        // is a forward-compat unknown version — we reject loudly
4176        // rather than mis-decode.
4177        assert!(CapabilitySet::from_bytes(&[0xFF]).is_none());
4178        assert!(CapabilitySet::from_bytes(&[]).is_none());
4179        // Empty postcard body after the version tag is also invalid.
4180        assert!(CapabilitySet::from_bytes(&[COMPACT_FORMAT_TAG]).is_none());
4181    }
4182
4183    #[test]
4184    fn typed_tags_includes_legacy_string_tags() {
4185        // Pinned: legacy `Vec<String>` tags appear in the typed-
4186        // tag set as `Tag::Legacy` / `Tag::Reserved` / parsed
4187        // axis tags. Downstream code reading via `typed_tags()`
4188        // sees them all uniformly.
4189        use crate::adapter::net::behavior::tag::Tag as TagT;
4190        let caps = CapabilitySet::new()
4191            .add_tag("inference")
4192            .with_tenant_scope("acme");
4193        let tag_set = caps.typed_tags();
4194        // "inference" → Legacy
4195        assert!(tag_set
4196            .iter()
4197            .any(|t| matches!(t, TagT::Legacy(s) if s == "inference")));
4198        // "scope:tenant:acme" → Reserved
4199        assert!(tag_set
4200            .iter()
4201            .any(|t| matches!(t, TagT::Reserved { prefix, body }
4202                if prefix == "scope:" && body == "tenant:acme")));
4203    }
4204}