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