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}