Skip to main content

net/adapter/net/subnet/
control.rs

1//! Signed subnet control facts — S5 of
2//! `docs/internal/plans/SUBNET_AUTH_PLAN.md` (D8).
3//!
4//! V1 defines exactly four independently signed facts:
5//!
6//! - [`SubnetDescriptor`] — "this authority-qualified path exists
7//!   under this topology epoch";
8//! - [`GatewayAdvertisement`] — "this entity (at this routing id)
9//!   serves as a gateway for this subtree";
10//! - [`SubnetExportPolicy`] — "exactly these channels are exported at
11//!   this subtree's boundary";
12//! - [`SubnetRevocationFloor`] — the S2 revocation floor, distributed
13//!   unchanged: a floor that arrives as a control fact is the same
14//!   bytes, same domain, same verification as one an operator
15//!   provisions locally.
16//!
17//! Every fact carries its [`SubnetRef`] scope, `topology_epoch`, a
18//! `revision` scoped per `(SubnetRef, fact kind)`, an issuer, and a
19//! domain-separated ed25519 signature. Unknown versions, unknown kind
20//! tags, wrong lengths, and trailing bytes all fail closed.
21//!
22//! **Arrival path changes no verification rule.** A fact may arrive
23//! through a configured channel, local provisioning, or a
24//! configuration bundle; each path hands the same bytes to the same
25//! verifier. Channel membership and publication NEVER establish fact
26//! authority — only a signature by a configured root of the fact's
27//! own authority does. A hostile publisher on the control channel can
28//! therefore inject bytes, and those bytes are inert.
29//!
30//! **Replay and reorder never roll state backward.** The
31//! [`SubnetControlStore`] applies each fact kind monotonically by
32//! revision within `(authority, topology_epoch, path)`; a replayed or
33//! reordered fact is a no-op, and the revision streams of different
34//! kinds are independent — a newer [`GatewayAdvertisement`] cannot
35//! suppress a current [`SubnetExportPolicy`].
36//!
37//! Floors are deliberately NOT stored here: they flow into the S2
38//! [`SubnetFloorRegistry`](super::auth::SubnetFloorRegistry), whose
39//! `(scope, topology_epoch)`-monotonic application and
40//! `subnet_auth_epoch` bump are already the revocation contract
41//! (bounded-stale: a verifier may honor an older grant until the
42//! newer floor ARRIVES — this module is the arrival machinery).
43
44use dashmap::DashMap;
45use ed25519_dalek::Signature;
46
47use super::auth::{
48    read_32, read_u32, read_u64, SubnetAuthError, SubnetAuthorityConfig, SubnetRef,
49    SubnetRevocationFloor, MAX_TOKEN_CLOCK_SKEW_SECS,
50};
51use super::id::TopologySubnetId;
52use crate::adapter::net::channel::ChannelHash;
53use crate::adapter::net::identity::{EntityId, EntityKeypair};
54
55/// Domain prefix for the descriptor's ed25519 transcript.
56pub const SUBNET_DESCRIPTOR_SIG_DOMAIN: &[u8] = b"net.subnet.descriptor.v1";
57/// Domain prefix for the gateway advertisement's ed25519 transcript.
58pub const SUBNET_GATEWAY_AD_SIG_DOMAIN: &[u8] = b"net.subnet.gateway-ad.v1";
59/// Domain prefix for the export policy's ed25519 transcript.
60pub const SUBNET_EXPORT_POLICY_SIG_DOMAIN: &[u8] = b"net.subnet.export-policy.v1";
61
62/// Ceiling on the number of exported channels one policy fact may
63/// name. The policy replaces wholesale (like gateway credential
64/// sets), so the bound is per-fact, and decode fails closed above it.
65pub const MAX_EXPORTED_CHANNELS: usize = 16;
66
67// ---------------------------------------------------------------------------
68// Fact kinds
69// ---------------------------------------------------------------------------
70
71/// The four V1 fact kinds, as wire tags. Strict: any other tag is
72/// [`SubnetAuthError::InvalidFormat`].
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
74#[repr(u8)]
75pub enum SubnetFactKind {
76    /// [`SubnetDescriptor`].
77    Descriptor = 1,
78    /// [`GatewayAdvertisement`].
79    GatewayAdvertisement = 2,
80    /// [`SubnetExportPolicy`].
81    ExportPolicy = 3,
82    /// [`SubnetRevocationFloor`], distributed as a fact.
83    RevocationFloor = 4,
84}
85
86impl SubnetFactKind {
87    /// Strict tag decode; unknown tags fail closed.
88    pub fn try_from_tag(tag: u8) -> Result<Self, SubnetAuthError> {
89        match tag {
90            1 => Ok(Self::Descriptor),
91            2 => Ok(Self::GatewayAdvertisement),
92            3 => Ok(Self::ExportPolicy),
93            4 => Ok(Self::RevocationFloor),
94            _ => Err(SubnetAuthError::InvalidFormat),
95        }
96    }
97}
98
99// ---------------------------------------------------------------------------
100// SubnetDescriptor
101// ---------------------------------------------------------------------------
102
103/// Root-signed declaration that an authority-qualified path exists
104/// under a topology epoch (D1: reparenting or reinterpreting a path
105/// creates a NEW epoch; adding a fresh descendant under a stable
106/// parent does not).
107///
108/// Deliberately carries no name string or metadata: the descriptor is
109/// the "this path is live under epoch E" fact, and the packet path's
110/// zero-string-parse budget (D9) starts with the facts themselves.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct SubnetDescriptor {
113    /// Wire version; only `1` decodes.
114    pub version: u8,
115    /// The declared authority-qualified path.
116    pub scope: SubnetRef,
117    /// Topology epoch the declaration belongs to.
118    pub topology_epoch: u32,
119    /// Signing root.
120    pub issuer: EntityId,
121    /// Per-`(SubnetRef, kind)` ordering revision; replay/reorder safe.
122    pub revision: u64,
123    /// Advisory issue timestamp (unix seconds). A descriptor is
124    /// superseded by revision, not by expiry.
125    pub issued_at: u64,
126    /// ed25519 over [`SUBNET_DESCRIPTOR_SIG_DOMAIN`] ‖ payload.
127    pub signature: [u8; 64],
128}
129
130impl SubnetDescriptor {
131    /// version 1 + authority 32 + path 4 + epoch 4 + issuer 32 +
132    /// revision 8 + issued_at 8.
133    pub const SIGNED_PAYLOAD_SIZE: usize = 89;
134    /// Payload + 64-byte signature.
135    pub const WIRE_SIZE: usize = Self::SIGNED_PAYLOAD_SIZE + 64;
136    const SIGNING_INPUT_SIZE: usize =
137        SUBNET_DESCRIPTOR_SIG_DOMAIN.len() + Self::SIGNED_PAYLOAD_SIZE;
138
139    /// Issue signed by `root_keypair` (`issuer` is set from it).
140    pub fn try_issue(
141        root_keypair: &EntityKeypair,
142        scope: SubnetRef,
143        topology_epoch: u32,
144        revision: u64,
145        issued_at: u64,
146    ) -> Result<Self, SubnetAuthError> {
147        let mut fact = Self {
148            version: 1,
149            scope,
150            topology_epoch,
151            issuer: root_keypair.entity_id().clone(),
152            revision,
153            issued_at,
154            signature: [0u8; 64],
155        };
156        let sig = root_keypair
157            .try_sign(&fact.signing_input())
158            .map_err(|_| SubnetAuthError::InvalidSignature)?;
159        fact.signature = sig.to_bytes();
160        Ok(fact)
161    }
162
163    fn signed_payload(&self) -> [u8; Self::SIGNED_PAYLOAD_SIZE] {
164        let mut buf = [0u8; Self::SIGNED_PAYLOAD_SIZE];
165        let mut off = 0;
166        buf[off] = self.version;
167        off += 1;
168        buf[off..off + 32].copy_from_slice(self.scope.authority.as_bytes());
169        off += 32;
170        buf[off..off + 4].copy_from_slice(&self.scope.path.raw().to_le_bytes());
171        off += 4;
172        buf[off..off + 4].copy_from_slice(&self.topology_epoch.to_le_bytes());
173        off += 4;
174        buf[off..off + 32].copy_from_slice(self.issuer.as_bytes());
175        off += 32;
176        buf[off..off + 8].copy_from_slice(&self.revision.to_le_bytes());
177        off += 8;
178        buf[off..off + 8].copy_from_slice(&self.issued_at.to_le_bytes());
179        buf
180    }
181
182    fn signing_input(&self) -> [u8; Self::SIGNING_INPUT_SIZE] {
183        let mut buf = [0u8; Self::SIGNING_INPUT_SIZE];
184        buf[..SUBNET_DESCRIPTOR_SIG_DOMAIN.len()].copy_from_slice(SUBNET_DESCRIPTOR_SIG_DOMAIN);
185        buf[SUBNET_DESCRIPTOR_SIG_DOMAIN.len()..].copy_from_slice(&self.signed_payload());
186        buf
187    }
188
189    /// Wire form: payload ‖ signature.
190    pub fn to_bytes(&self) -> Vec<u8> {
191        let mut out = Vec::with_capacity(Self::WIRE_SIZE);
192        out.extend_from_slice(&self.signed_payload());
193        out.extend_from_slice(&self.signature);
194        out
195    }
196
197    /// Strict decode; signature NOT verified here.
198    pub fn from_bytes(bytes: &[u8]) -> Result<Self, SubnetAuthError> {
199        if bytes.len() != Self::WIRE_SIZE {
200            return Err(SubnetAuthError::InvalidFormat);
201        }
202        let mut off = 0;
203        let version = bytes[off];
204        off += 1;
205        if version != 1 {
206            return Err(SubnetAuthError::InvalidFormat);
207        }
208        let authority = EntityId::from_bytes(read_32(bytes, &mut off));
209        let path = TopologySubnetId::from_raw(read_u32(bytes, &mut off));
210        let topology_epoch = read_u32(bytes, &mut off);
211        let issuer = EntityId::from_bytes(read_32(bytes, &mut off));
212        let revision = read_u64(bytes, &mut off);
213        let issued_at = read_u64(bytes, &mut off);
214        let mut signature = [0u8; 64];
215        signature.copy_from_slice(&bytes[off..off + 64]);
216        Ok(Self {
217            version,
218            scope: SubnetRef { authority, path },
219            topology_epoch,
220            issuer,
221            revision,
222            issued_at,
223            signature,
224        })
225    }
226
227    /// Signature verification against `self.issuer` (whether that
228    /// issuer is a configured root is the store's decision).
229    pub fn verify(&self) -> Result<(), SubnetAuthError> {
230        let sig = Signature::from_bytes(&self.signature);
231        self.issuer
232            .verify(&self.signing_input(), &sig)
233            .map_err(|_| SubnetAuthError::InvalidSignature)
234    }
235}
236
237// ---------------------------------------------------------------------------
238// GatewayAdvertisement
239// ---------------------------------------------------------------------------
240
241/// Root-signed advertisement that one entity serves as a gateway for
242/// a subtree.
243///
244/// An advertisement is DISCOVERY, not authority: it tells members
245/// where a gateway is, and nothing more. The advertised entity still
246/// proves its own forwarding rights from self-held credentials
247/// (`install_subnet_gateway_credentials`, D6) — an advertisement for
248/// an entity holding no `ROUTE`/`EXPORT` grant advertises a gateway
249/// that can forward nothing.
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub struct GatewayAdvertisement {
252    /// Wire version; only `1` decodes.
253    pub version: u8,
254    /// Subtree the gateway serves.
255    pub scope: SubnetRef,
256    /// Topology epoch the advertisement belongs to.
257    pub topology_epoch: u32,
258    /// Signing root.
259    pub issuer: EntityId,
260    /// The advertised gateway's full entity identity.
261    pub gateway: EntityId,
262    /// The advertised gateway's routing id (derived `NodeId`). A
263    /// convenience for reaching it; never an identity claim — the
264    /// full `EntityId` above is the identity.
265    pub gateway_node: u64,
266    /// Per-`(SubnetRef, kind)` ordering revision; replay/reorder safe.
267    pub revision: u64,
268    /// Validity window start (unix seconds).
269    pub not_before: u64,
270    /// Validity window end (unix seconds, exclusive).
271    pub not_after: u64,
272    /// ed25519 over [`SUBNET_GATEWAY_AD_SIG_DOMAIN`] ‖ payload.
273    pub signature: [u8; 64],
274}
275
276impl GatewayAdvertisement {
277    /// version 1 + authority 32 + path 4 + epoch 4 + issuer 32 +
278    /// gateway 32 + gateway_node 8 + revision 8 + not_before 8 +
279    /// not_after 8.
280    pub const SIGNED_PAYLOAD_SIZE: usize = 137;
281    /// Payload + 64-byte signature.
282    pub const WIRE_SIZE: usize = Self::SIGNED_PAYLOAD_SIZE + 64;
283    const SIGNING_INPUT_SIZE: usize =
284        SUBNET_GATEWAY_AD_SIG_DOMAIN.len() + Self::SIGNED_PAYLOAD_SIZE;
285
286    /// Issue signed by `root_keypair` (`issuer` is set from it).
287    /// `not_after <= not_before` is refused at issue as at decode.
288    #[expect(
289        clippy::too_many_arguments,
290        reason = "explicit wire fields; a params struct would only rename them"
291    )]
292    pub fn try_issue(
293        root_keypair: &EntityKeypair,
294        scope: SubnetRef,
295        topology_epoch: u32,
296        gateway: EntityId,
297        gateway_node: u64,
298        revision: u64,
299        not_before: u64,
300        not_after: u64,
301    ) -> Result<Self, SubnetAuthError> {
302        if not_after <= not_before {
303            return Err(SubnetAuthError::InvalidValidityWindow);
304        }
305        let mut fact = Self {
306            version: 1,
307            scope,
308            topology_epoch,
309            issuer: root_keypair.entity_id().clone(),
310            gateway,
311            gateway_node,
312            revision,
313            not_before,
314            not_after,
315            signature: [0u8; 64],
316        };
317        let sig = root_keypair
318            .try_sign(&fact.signing_input())
319            .map_err(|_| SubnetAuthError::InvalidSignature)?;
320        fact.signature = sig.to_bytes();
321        Ok(fact)
322    }
323
324    fn signed_payload(&self) -> [u8; Self::SIGNED_PAYLOAD_SIZE] {
325        let mut buf = [0u8; Self::SIGNED_PAYLOAD_SIZE];
326        let mut off = 0;
327        buf[off] = self.version;
328        off += 1;
329        buf[off..off + 32].copy_from_slice(self.scope.authority.as_bytes());
330        off += 32;
331        buf[off..off + 4].copy_from_slice(&self.scope.path.raw().to_le_bytes());
332        off += 4;
333        buf[off..off + 4].copy_from_slice(&self.topology_epoch.to_le_bytes());
334        off += 4;
335        buf[off..off + 32].copy_from_slice(self.issuer.as_bytes());
336        off += 32;
337        buf[off..off + 32].copy_from_slice(self.gateway.as_bytes());
338        off += 32;
339        buf[off..off + 8].copy_from_slice(&self.gateway_node.to_le_bytes());
340        off += 8;
341        buf[off..off + 8].copy_from_slice(&self.revision.to_le_bytes());
342        off += 8;
343        buf[off..off + 8].copy_from_slice(&self.not_before.to_le_bytes());
344        off += 8;
345        buf[off..off + 8].copy_from_slice(&self.not_after.to_le_bytes());
346        buf
347    }
348
349    fn signing_input(&self) -> [u8; Self::SIGNING_INPUT_SIZE] {
350        let mut buf = [0u8; Self::SIGNING_INPUT_SIZE];
351        buf[..SUBNET_GATEWAY_AD_SIG_DOMAIN.len()].copy_from_slice(SUBNET_GATEWAY_AD_SIG_DOMAIN);
352        buf[SUBNET_GATEWAY_AD_SIG_DOMAIN.len()..].copy_from_slice(&self.signed_payload());
353        buf
354    }
355
356    /// Wire form: payload ‖ signature.
357    pub fn to_bytes(&self) -> Vec<u8> {
358        let mut out = Vec::with_capacity(Self::WIRE_SIZE);
359        out.extend_from_slice(&self.signed_payload());
360        out.extend_from_slice(&self.signature);
361        out
362    }
363
364    /// Strict decode; signature NOT verified here.
365    pub fn from_bytes(bytes: &[u8]) -> Result<Self, SubnetAuthError> {
366        if bytes.len() != Self::WIRE_SIZE {
367            return Err(SubnetAuthError::InvalidFormat);
368        }
369        let mut off = 0;
370        let version = bytes[off];
371        off += 1;
372        if version != 1 {
373            return Err(SubnetAuthError::InvalidFormat);
374        }
375        let authority = EntityId::from_bytes(read_32(bytes, &mut off));
376        let path = TopologySubnetId::from_raw(read_u32(bytes, &mut off));
377        let topology_epoch = read_u32(bytes, &mut off);
378        let issuer = EntityId::from_bytes(read_32(bytes, &mut off));
379        let gateway = EntityId::from_bytes(read_32(bytes, &mut off));
380        let gateway_node = read_u64(bytes, &mut off);
381        let revision = read_u64(bytes, &mut off);
382        let not_before = read_u64(bytes, &mut off);
383        let not_after = read_u64(bytes, &mut off);
384        if not_after <= not_before {
385            return Err(SubnetAuthError::InvalidValidityWindow);
386        }
387        let mut signature = [0u8; 64];
388        signature.copy_from_slice(&bytes[off..off + 64]);
389        Ok(Self {
390            version,
391            scope: SubnetRef { authority, path },
392            topology_epoch,
393            issuer,
394            gateway,
395            gateway_node,
396            revision,
397            not_before,
398            not_after,
399            signature,
400        })
401    }
402
403    /// Signature verification against `self.issuer`.
404    pub fn verify(&self) -> Result<(), SubnetAuthError> {
405        let sig = Signature::from_bytes(&self.signature);
406        self.issuer
407            .verify(&self.signing_input(), &sig)
408            .map_err(|_| SubnetAuthError::InvalidSignature)
409    }
410
411    /// Window check with saturating skew, the family discipline.
412    pub fn check_time_bounds_at(&self, now: u64, skew_secs: u64) -> Result<(), SubnetAuthError> {
413        if skew_secs > MAX_TOKEN_CLOCK_SKEW_SECS {
414            return Err(SubnetAuthError::ClockSkewTooLarge);
415        }
416        if now < self.not_before.saturating_sub(skew_secs) {
417            return Err(SubnetAuthError::NotYetValid);
418        }
419        if now >= self.not_after.saturating_add(skew_secs) {
420            return Err(SubnetAuthError::Expired);
421        }
422        Ok(())
423    }
424}
425
426// ---------------------------------------------------------------------------
427// SubnetExportPolicy
428// ---------------------------------------------------------------------------
429
430/// Root-signed statement of EXACTLY which channels are exported at a
431/// subtree's boundary.
432///
433/// The set replaces wholesale — like a gateway credential set — so a
434/// revoked export cannot survive inside a merged remainder. An empty
435/// set is meaningful: "nothing is exported here".
436///
437/// Like the advertisement, this is policy DISTRIBUTION, not export
438/// authority: the boundary gateway still needs its own `EXPORT`
439/// credential at the boundary scope (D6). The fact tells a gateway
440/// what the authority wants exported; the credential is what lets it.
441#[derive(Debug, Clone, PartialEq, Eq)]
442pub struct SubnetExportPolicy {
443    /// Wire version; only `1` decodes.
444    pub version: u8,
445    /// The boundary subtree the policy applies to.
446    pub scope: SubnetRef,
447    /// Topology epoch the policy belongs to.
448    pub topology_epoch: u32,
449    /// Signing root.
450    pub issuer: EntityId,
451    /// Canonical 64-bit channel hashes exported at this boundary.
452    /// At most [`MAX_EXPORTED_CHANNELS`]; order is not significant
453    /// but IS signed, so decode preserves it.
454    pub exported_channels: Vec<ChannelHash>,
455    /// Per-`(SubnetRef, kind)` ordering revision; replay/reorder safe.
456    pub revision: u64,
457    /// Validity window start (unix seconds).
458    pub not_before: u64,
459    /// Validity window end (unix seconds, exclusive).
460    pub not_after: u64,
461    /// ed25519 over [`SUBNET_EXPORT_POLICY_SIG_DOMAIN`] ‖ payload.
462    pub signature: [u8; 64],
463}
464
465impl SubnetExportPolicy {
466    /// version 1 + authority 32 + path 4 + epoch 4 + issuer 32 +
467    /// count 1, before the per-channel hashes and the fixed tail.
468    const FIXED_HEAD_SIZE: usize = 74;
469    /// revision 8 + not_before 8 + not_after 8.
470    const FIXED_TAIL_SIZE: usize = 24;
471
472    /// Wire size for a policy naming `count` channels.
473    pub const fn wire_size(count: usize) -> usize {
474        Self::FIXED_HEAD_SIZE + count * 8 + Self::FIXED_TAIL_SIZE + 64
475    }
476
477    /// Issue signed by `root_keypair` (`issuer` is set from it).
478    pub fn try_issue(
479        root_keypair: &EntityKeypair,
480        scope: SubnetRef,
481        topology_epoch: u32,
482        exported_channels: Vec<ChannelHash>,
483        revision: u64,
484        not_before: u64,
485        not_after: u64,
486    ) -> Result<Self, SubnetAuthError> {
487        if exported_channels.len() > MAX_EXPORTED_CHANNELS {
488            return Err(SubnetAuthError::InvalidFormat);
489        }
490        if not_after <= not_before {
491            return Err(SubnetAuthError::InvalidValidityWindow);
492        }
493        let mut fact = Self {
494            version: 1,
495            scope,
496            topology_epoch,
497            issuer: root_keypair.entity_id().clone(),
498            exported_channels,
499            revision,
500            not_before,
501            not_after,
502            signature: [0u8; 64],
503        };
504        let sig = root_keypair
505            .try_sign(&fact.signing_input())
506            .map_err(|_| SubnetAuthError::InvalidSignature)?;
507        fact.signature = sig.to_bytes();
508        Ok(fact)
509    }
510
511    /// Variable-width payload (the channel list is length-prefixed by
512    /// a single strict count byte).
513    fn signed_payload(&self) -> Vec<u8> {
514        // Reachable with a >255 list only through a struct literal —
515        // `try_issue` and `from_bytes` both enforce
516        // `MAX_EXPORTED_CHANNELS` (16). The `as u8` would truncate
517        // the count byte for such a value and break the round trip
518        // (the payload length still differs, so no signature
519        // collision is constructible); assert the invariant instead
520        // of encoding a self-inconsistent payload.
521        debug_assert!(
522            self.exported_channels.len() <= MAX_EXPORTED_CHANNELS,
523            "exported_channels ({}) exceeds MAX_EXPORTED_CHANNELS — \
524             constructed around try_issue?",
525            self.exported_channels.len(),
526        );
527        let mut buf = Vec::with_capacity(Self::wire_size(self.exported_channels.len()) - 64);
528        buf.push(self.version);
529        buf.extend_from_slice(self.scope.authority.as_bytes());
530        buf.extend_from_slice(&self.scope.path.raw().to_le_bytes());
531        buf.extend_from_slice(&self.topology_epoch.to_le_bytes());
532        buf.extend_from_slice(self.issuer.as_bytes());
533        buf.push(self.exported_channels.len() as u8);
534        for hash in &self.exported_channels {
535            buf.extend_from_slice(&hash.to_le_bytes());
536        }
537        buf.extend_from_slice(&self.revision.to_le_bytes());
538        buf.extend_from_slice(&self.not_before.to_le_bytes());
539        buf.extend_from_slice(&self.not_after.to_le_bytes());
540        buf
541    }
542
543    fn signing_input(&self) -> Vec<u8> {
544        let payload = self.signed_payload();
545        let mut buf = Vec::with_capacity(SUBNET_EXPORT_POLICY_SIG_DOMAIN.len() + payload.len());
546        buf.extend_from_slice(SUBNET_EXPORT_POLICY_SIG_DOMAIN);
547        buf.extend_from_slice(&payload);
548        buf
549    }
550
551    /// Wire form: payload ‖ signature.
552    pub fn to_bytes(&self) -> Vec<u8> {
553        let mut out = self.signed_payload();
554        out.extend_from_slice(&self.signature);
555        out
556    }
557
558    /// Strict decode; signature NOT verified here. The count byte
559    /// must match the exact remaining length — a count that disagrees
560    /// with the buffer is a forgery attempt or corruption either way.
561    pub fn from_bytes(bytes: &[u8]) -> Result<Self, SubnetAuthError> {
562        if bytes.len() < Self::wire_size(0) {
563            return Err(SubnetAuthError::InvalidFormat);
564        }
565        let mut off = 0;
566        let version = bytes[off];
567        off += 1;
568        if version != 1 {
569            return Err(SubnetAuthError::InvalidFormat);
570        }
571        let authority = EntityId::from_bytes(read_32(bytes, &mut off));
572        let path = TopologySubnetId::from_raw(read_u32(bytes, &mut off));
573        let topology_epoch = read_u32(bytes, &mut off);
574        let issuer = EntityId::from_bytes(read_32(bytes, &mut off));
575        let count = bytes[off] as usize;
576        off += 1;
577        if count > MAX_EXPORTED_CHANNELS || bytes.len() != Self::wire_size(count) {
578            return Err(SubnetAuthError::InvalidFormat);
579        }
580        let mut exported_channels = Vec::with_capacity(count);
581        for _ in 0..count {
582            exported_channels.push(read_u64(bytes, &mut off));
583        }
584        let revision = read_u64(bytes, &mut off);
585        let not_before = read_u64(bytes, &mut off);
586        let not_after = read_u64(bytes, &mut off);
587        if not_after <= not_before {
588            return Err(SubnetAuthError::InvalidValidityWindow);
589        }
590        let mut signature = [0u8; 64];
591        signature.copy_from_slice(&bytes[off..off + 64]);
592        Ok(Self {
593            version,
594            scope: SubnetRef { authority, path },
595            topology_epoch,
596            issuer,
597            exported_channels,
598            revision,
599            not_before,
600            not_after,
601            signature,
602        })
603    }
604
605    /// Signature verification against `self.issuer`.
606    pub fn verify(&self) -> Result<(), SubnetAuthError> {
607        let sig = Signature::from_bytes(&self.signature);
608        self.issuer
609            .verify(&self.signing_input(), &sig)
610            .map_err(|_| SubnetAuthError::InvalidSignature)
611    }
612
613    /// Window check with saturating skew, the family discipline.
614    pub fn check_time_bounds_at(&self, now: u64, skew_secs: u64) -> Result<(), SubnetAuthError> {
615        if skew_secs > MAX_TOKEN_CLOCK_SKEW_SECS {
616            return Err(SubnetAuthError::ClockSkewTooLarge);
617        }
618        if now < self.not_before.saturating_sub(skew_secs) {
619            return Err(SubnetAuthError::NotYetValid);
620        }
621        if now >= self.not_after.saturating_add(skew_secs) {
622            return Err(SubnetAuthError::Expired);
623        }
624        Ok(())
625    }
626}
627
628// ---------------------------------------------------------------------------
629// The tagged wire envelope
630// ---------------------------------------------------------------------------
631
632/// One decoded control fact, any kind.
633#[derive(Debug, Clone, PartialEq, Eq)]
634pub enum SubnetControlFact {
635    /// A [`SubnetDescriptor`].
636    Descriptor(SubnetDescriptor),
637    /// A [`GatewayAdvertisement`].
638    GatewayAdvertisement(GatewayAdvertisement),
639    /// A [`SubnetExportPolicy`].
640    ExportPolicy(SubnetExportPolicy),
641    /// A [`SubnetRevocationFloor`] — same bytes and domain as the S2
642    /// artifact, so distribution and local provisioning verify
643    /// identically.
644    RevocationFloor(SubnetRevocationFloor),
645}
646
647impl SubnetControlFact {
648    /// This fact's wire kind.
649    pub fn kind(&self) -> SubnetFactKind {
650        match self {
651            Self::Descriptor(_) => SubnetFactKind::Descriptor,
652            Self::GatewayAdvertisement(_) => SubnetFactKind::GatewayAdvertisement,
653            Self::ExportPolicy(_) => SubnetFactKind::ExportPolicy,
654            Self::RevocationFloor(_) => SubnetFactKind::RevocationFloor,
655        }
656    }
657
658    /// The fact's authority-qualified scope.
659    pub fn scope(&self) -> &SubnetRef {
660        match self {
661            Self::Descriptor(f) => &f.scope,
662            Self::GatewayAdvertisement(f) => &f.scope,
663            Self::ExportPolicy(f) => &f.scope,
664            Self::RevocationFloor(f) => &f.scope,
665        }
666    }
667
668    /// Wire form: one kind tag byte ‖ the fact's own wire bytes.
669    pub fn to_bytes(&self) -> Vec<u8> {
670        let body = match self {
671            Self::Descriptor(f) => f.to_bytes(),
672            Self::GatewayAdvertisement(f) => f.to_bytes(),
673            Self::ExportPolicy(f) => f.to_bytes(),
674            Self::RevocationFloor(f) => f.to_bytes(),
675        };
676        let mut out = Vec::with_capacity(1 + body.len());
677        out.push(self.kind() as u8);
678        out.extend_from_slice(&body);
679        out
680    }
681
682    /// Strict decode: unknown tag, wrong body length, or a malformed
683    /// body all fail closed. Signatures are NOT verified here.
684    pub fn from_bytes(bytes: &[u8]) -> Result<Self, SubnetAuthError> {
685        let (&tag, body) = bytes.split_first().ok_or(SubnetAuthError::InvalidFormat)?;
686        match SubnetFactKind::try_from_tag(tag)? {
687            SubnetFactKind::Descriptor => SubnetDescriptor::from_bytes(body).map(Self::Descriptor),
688            SubnetFactKind::GatewayAdvertisement => {
689                GatewayAdvertisement::from_bytes(body).map(Self::GatewayAdvertisement)
690            }
691            SubnetFactKind::ExportPolicy => {
692                SubnetExportPolicy::from_bytes(body).map(Self::ExportPolicy)
693            }
694            SubnetFactKind::RevocationFloor => {
695                SubnetRevocationFloor::from_bytes(body).map(Self::RevocationFloor)
696            }
697        }
698    }
699}
700
701/// What applying one control fact did — the kind it decoded to, and
702/// whether state changed (`false` = the designed replay/reorder
703/// no-op).
704#[derive(Debug, Clone, Copy, PartialEq, Eq)]
705pub struct SubnetControlOutcome {
706    /// The decoded fact kind.
707    pub kind: SubnetFactKind,
708    /// Whether any state changed.
709    pub applied: bool,
710}
711
712// ---------------------------------------------------------------------------
713// The monotonic store
714// ---------------------------------------------------------------------------
715
716/// Key: (authority bytes, topology epoch, path). Facts for different
717/// epochs never interact — an epoch is a reinterpretation boundary
718/// (D1), so revision streams restart cleanly on the far side of one
719/// and a lagging node's facts for a future epoch sit inert until it
720/// advances.
721type FactKey = ([u8; 32], u32, u32);
722
723/// Verified control-fact state, applied monotonically by revision per
724/// `(SubnetRef, fact kind)`.
725///
726/// Floors are NOT held here — they flow into the S2 floor registry.
727/// This store carries the three descriptive kinds, each in its own
728/// map so their revision streams cannot interact: a newer gateway
729/// advertisement can never suppress a current export policy.
730#[derive(Debug, Default)]
731pub struct SubnetControlStore {
732    descriptors: DashMap<FactKey, SubnetDescriptor>,
733    gateways: DashMap<FactKey, GatewayAdvertisement>,
734    exports: DashMap<FactKey, SubnetExportPolicy>,
735}
736
737impl SubnetControlStore {
738    /// Empty store.
739    pub fn new() -> Self {
740        Self::default()
741    }
742
743    /// Verify and apply one non-floor fact under `config`'s trust.
744    ///
745    /// The checks are the floor-registry discipline, applied
746    /// uniformly regardless of how the bytes arrived:
747    ///
748    /// 1. the fact's authority equals the config's ([`SubnetAuthError::WrongAuthority`]);
749    /// 2. the config anchors at least one root ([`SubnetAuthError::UnknownAuthority`]);
750    /// 3. the issuer is a configured root ([`SubnetAuthError::IssuerNotAuthorized`])
751    ///    — channel membership, publication, or any other arrival
752    ///    privilege establishes nothing here;
753    /// 4. the domain-separated signature verifies;
754    /// 5. kinds with a validity window are inside it (with skew);
755    /// 6. the revision strictly exceeds the stored one for this
756    ///    `(SubnetRef, kind)` — otherwise `Ok(false)`: a replayed or
757    ///    reordered fact is a no-op, never a rollback.
758    ///
759    /// Returns `Ok(true)` iff state changed. Floors are refused here
760    /// ([`SubnetAuthError::InvalidFormat`]) — route them through the
761    /// floor registry, which owns revocation ordering and the auth
762    /// epoch.
763    pub fn apply(
764        &self,
765        fact: &SubnetControlFact,
766        config: &SubnetAuthorityConfig,
767        now: u64,
768        skew_secs: u64,
769    ) -> Result<bool, SubnetAuthError> {
770        if fact.scope().authority != config.authority {
771            return Err(SubnetAuthError::WrongAuthority);
772        }
773        if config.roots.is_empty() {
774            return Err(SubnetAuthError::UnknownAuthority);
775        }
776        match fact {
777            SubnetControlFact::Descriptor(f) => {
778                if !config.roots.contains(&f.issuer) {
779                    return Err(SubnetAuthError::IssuerNotAuthorized);
780                }
781                f.verify()?;
782                Ok(Self::apply_monotonic(
783                    &self.descriptors,
784                    key_of(&f.scope, f.topology_epoch),
785                    f,
786                    |s| s.revision,
787                ))
788            }
789            SubnetControlFact::GatewayAdvertisement(f) => {
790                if !config.roots.contains(&f.issuer) {
791                    return Err(SubnetAuthError::IssuerNotAuthorized);
792                }
793                f.verify()?;
794                f.check_time_bounds_at(now, skew_secs)?;
795                Ok(Self::apply_monotonic(
796                    &self.gateways,
797                    key_of(&f.scope, f.topology_epoch),
798                    f,
799                    |s| s.revision,
800                ))
801            }
802            SubnetControlFact::ExportPolicy(f) => {
803                if !config.roots.contains(&f.issuer) {
804                    return Err(SubnetAuthError::IssuerNotAuthorized);
805                }
806                f.verify()?;
807                f.check_time_bounds_at(now, skew_secs)?;
808                Ok(Self::apply_monotonic(
809                    &self.exports,
810                    key_of(&f.scope, f.topology_epoch),
811                    f,
812                    |s| s.revision,
813                ))
814            }
815            SubnetControlFact::RevocationFloor(_) => Err(SubnetAuthError::InvalidFormat),
816        }
817    }
818
819    /// The one write shape: install iff vacant or strictly newer by
820    /// revision, under the entry guard, so two concurrent arrivals
821    /// cannot interleave a rollback.
822    fn apply_monotonic<T: Clone>(
823        map: &DashMap<FactKey, T>,
824        key: FactKey,
825        fact: &T,
826        revision_of: impl Fn(&T) -> u64,
827    ) -> bool {
828        let mut changed = false;
829        map.entry(key)
830            .and_modify(|stored| {
831                if revision_of(fact) > revision_of(stored) {
832                    *stored = fact.clone();
833                    changed = true;
834                }
835            })
836            .or_insert_with(|| {
837                changed = true;
838                fact.clone()
839            });
840        changed
841    }
842
843    /// The current descriptor for a scope under an epoch.
844    pub fn descriptor_for(
845        &self,
846        authority: &EntityId,
847        topology_epoch: u32,
848        path: TopologySubnetId,
849    ) -> Option<SubnetDescriptor> {
850        self.descriptors
851            .get(&(*authority.as_bytes(), topology_epoch, path.raw()))
852            .map(|e| e.clone())
853    }
854
855    /// The current, unexpired gateway advertisement for a scope under
856    /// an epoch. Expiry is enforced at read as well as at apply: an
857    /// advertisement that aged out while stored stops being served
858    /// without needing a tombstoning write.
859    pub fn gateway_for(
860        &self,
861        authority: &EntityId,
862        topology_epoch: u32,
863        path: TopologySubnetId,
864        now: u64,
865        skew_secs: u64,
866    ) -> Option<GatewayAdvertisement> {
867        self.gateways
868            .get(&(*authority.as_bytes(), topology_epoch, path.raw()))
869            .filter(|e| e.check_time_bounds_at(now, skew_secs).is_ok())
870            .map(|e| e.clone())
871    }
872
873    /// The current, unexpired export policy for a scope under an
874    /// epoch.
875    pub fn export_policy_for(
876        &self,
877        authority: &EntityId,
878        topology_epoch: u32,
879        path: TopologySubnetId,
880        now: u64,
881        skew_secs: u64,
882    ) -> Option<SubnetExportPolicy> {
883        self.exports
884            .get(&(*authority.as_bytes(), topology_epoch, path.raw()))
885            .filter(|e| e.check_time_bounds_at(now, skew_secs).is_ok())
886            .map(|e| e.clone())
887    }
888
889    /// Drop facts for epochs BELOW `current_epoch` — they can never
890    /// be read again (reads are epoch-exact and epochs only advance).
891    /// Facts for the current or a future epoch are kept: a lagging
892    /// node may hold facts it cannot yet see.
893    pub fn purge_stale_epochs(&self, current_epoch: u32) -> usize {
894        let before = self.descriptors.len() + self.gateways.len() + self.exports.len();
895        self.descriptors.retain(|k, _| k.1 >= current_epoch);
896        self.gateways.retain(|k, _| k.1 >= current_epoch);
897        self.exports.retain(|k, _| k.1 >= current_epoch);
898        before - (self.descriptors.len() + self.gateways.len() + self.exports.len())
899    }
900}
901
902fn key_of(scope: &SubnetRef, topology_epoch: u32) -> FactKey {
903    (
904        *scope.authority.as_bytes(),
905        topology_epoch,
906        scope.path.raw(),
907    )
908}
909
910#[cfg(test)]
911mod tests {
912    #![allow(clippy::unwrap_used)]
913
914    use super::*;
915
916    fn root() -> EntityKeypair {
917        EntityKeypair::generate()
918    }
919
920    fn scope_of(authority: &EntityKeypair, path: u32) -> SubnetRef {
921        SubnetRef {
922            authority: authority.entity_id().clone(),
923            path: TopologySubnetId::from_raw(path),
924        }
925    }
926
927    fn config_of(authority: &EntityKeypair, roots: &[&EntityKeypair]) -> SubnetAuthorityConfig {
928        SubnetAuthorityConfig {
929            authority: authority.entity_id().clone(),
930            roots: roots.iter().map(|r| r.entity_id().clone()).collect(),
931            maximum_grant_lifetime_secs: 3600,
932        }
933    }
934
935    const NOW: u64 = 1_700_000_000;
936    const SKEW: u64 = 30;
937
938    fn descriptor(root: &EntityKeypair, path: u32, revision: u64) -> SubnetControlFact {
939        SubnetControlFact::Descriptor(
940            SubnetDescriptor::try_issue(root, scope_of(root, path), 1, revision, NOW).unwrap(),
941        )
942    }
943
944    fn gateway_ad(root: &EntityKeypair, path: u32, revision: u64) -> SubnetControlFact {
945        SubnetControlFact::GatewayAdvertisement(
946            GatewayAdvertisement::try_issue(
947                root,
948                scope_of(root, path),
949                1,
950                EntityKeypair::generate().entity_id().clone(),
951                0xBEEF,
952                revision,
953                NOW - 10,
954                NOW + 3600,
955            )
956            .unwrap(),
957        )
958    }
959
960    fn export_policy(
961        root: &EntityKeypair,
962        path: u32,
963        revision: u64,
964        channels: Vec<ChannelHash>,
965    ) -> SubnetControlFact {
966        SubnetControlFact::ExportPolicy(
967            SubnetExportPolicy::try_issue(
968                root,
969                scope_of(root, path),
970                1,
971                channels,
972                revision,
973                NOW - 10,
974                NOW + 3600,
975            )
976            .unwrap(),
977        )
978    }
979
980    #[test]
981    fn every_kind_round_trips_through_the_tagged_wire() {
982        let root = root();
983        let facts = [
984            descriptor(&root, 0x0101, 7),
985            gateway_ad(&root, 0x0101, 7),
986            export_policy(&root, 0x0101, 7, vec![0xAAAA, 0xBBBB]),
987            SubnetControlFact::RevocationFloor(
988                SubnetRevocationFloor::try_issue(&root, scope_of(&root, 0x0101), 1, 3, 7, NOW)
989                    .unwrap(),
990            ),
991        ];
992        for fact in &facts {
993            let bytes = fact.to_bytes();
994            let decoded = SubnetControlFact::from_bytes(&bytes).unwrap();
995            assert_eq!(&decoded, fact);
996        }
997    }
998
999    #[test]
1000    fn unknown_tags_versions_and_lengths_fail_closed() {
1001        let root = root();
1002        let good = descriptor(&root, 1, 1).to_bytes();
1003
1004        // Unknown kind tag.
1005        let mut bad_tag = good.clone();
1006        bad_tag[0] = 9;
1007        assert_eq!(
1008            SubnetControlFact::from_bytes(&bad_tag),
1009            Err(SubnetAuthError::InvalidFormat)
1010        );
1011        // Unknown version inside the body.
1012        let mut bad_version = good.clone();
1013        bad_version[1] = 2;
1014        assert_eq!(
1015            SubnetControlFact::from_bytes(&bad_version),
1016            Err(SubnetAuthError::InvalidFormat)
1017        );
1018        // Truncation and trailing bytes.
1019        assert_eq!(
1020            SubnetControlFact::from_bytes(&good[..good.len() - 1]),
1021            Err(SubnetAuthError::InvalidFormat)
1022        );
1023        let mut trailing = good.clone();
1024        trailing.push(0);
1025        assert_eq!(
1026            SubnetControlFact::from_bytes(&trailing),
1027            Err(SubnetAuthError::InvalidFormat)
1028        );
1029        // Empty input.
1030        assert_eq!(
1031            SubnetControlFact::from_bytes(&[]),
1032            Err(SubnetAuthError::InvalidFormat)
1033        );
1034    }
1035
1036    #[test]
1037    fn an_export_count_disagreeing_with_the_buffer_fails_closed() {
1038        let root = root();
1039        let bytes = export_policy(&root, 1, 1, vec![0xAAAA, 0xBBBB]).to_bytes();
1040        // Lower the count byte: total length no longer matches.
1041        let mut shrunk = bytes.clone();
1042        shrunk[1 + SubnetExportPolicy::FIXED_HEAD_SIZE - 1] = 1;
1043        assert_eq!(
1044            SubnetControlFact::from_bytes(&shrunk),
1045            Err(SubnetAuthError::InvalidFormat)
1046        );
1047        // A count beyond the ceiling.
1048        let mut oversized = bytes;
1049        oversized[1 + SubnetExportPolicy::FIXED_HEAD_SIZE - 1] = (MAX_EXPORTED_CHANNELS + 1) as u8;
1050        assert_eq!(
1051            SubnetControlFact::from_bytes(&oversized),
1052            Err(SubnetAuthError::InvalidFormat)
1053        );
1054    }
1055
1056    #[test]
1057    fn an_unsigned_or_tampered_fact_changes_no_state() {
1058        let root = root();
1059        let store = SubnetControlStore::new();
1060        let config = config_of(&root, &[&root]);
1061
1062        // Zeroed signature.
1063        let SubnetControlFact::Descriptor(mut plain) = descriptor(&root, 1, 1) else {
1064            unreachable!()
1065        };
1066        plain.signature = [0u8; 64];
1067        assert_eq!(
1068            store.apply(&SubnetControlFact::Descriptor(plain), &config, NOW, SKEW),
1069            Err(SubnetAuthError::InvalidSignature)
1070        );
1071
1072        // Payload tampered after signing (revision inflated).
1073        let SubnetControlFact::Descriptor(mut tampered) = descriptor(&root, 1, 1) else {
1074            unreachable!()
1075        };
1076        tampered.revision = 99;
1077        assert_eq!(
1078            store.apply(&SubnetControlFact::Descriptor(tampered), &config, NOW, SKEW),
1079            Err(SubnetAuthError::InvalidSignature)
1080        );
1081
1082        assert!(store
1083            .descriptor_for(&config.authority, 1, TopologySubnetId::from_raw(1))
1084            .is_none());
1085    }
1086
1087    #[test]
1088    fn a_wrong_authority_or_non_root_issuer_is_inert() {
1089        let root_a = root();
1090        let root_b = root();
1091        let store = SubnetControlStore::new();
1092        let config_a = config_of(&root_a, &[&root_a]);
1093
1094        // Authority B's fact against authority A's config.
1095        assert_eq!(
1096            store.apply(&descriptor(&root_b, 1, 1), &config_a, NOW, SKEW),
1097            Err(SubnetAuthError::WrongAuthority)
1098        );
1099
1100        // Correct authority, but the issuer is not a configured root:
1101        // a valid signature by ANYONE ELSE — however privileged on
1102        // the arrival path — establishes nothing.
1103        let outsider = root();
1104        let fact = SubnetDescriptor::try_issue(&outsider, scope_of(&root_a, 1), 1, 1, NOW).unwrap();
1105        assert_eq!(
1106            store.apply(&SubnetControlFact::Descriptor(fact), &config_a, NOW, SKEW),
1107            Err(SubnetAuthError::IssuerNotAuthorized)
1108        );
1109
1110        // Empty roots fail closed even for the authority's own fact.
1111        let empty = SubnetAuthorityConfig {
1112            authority: root_a.entity_id().clone(),
1113            roots: vec![],
1114            maximum_grant_lifetime_secs: 3600,
1115        };
1116        assert_eq!(
1117            store.apply(&descriptor(&root_a, 1, 1), &empty, NOW, SKEW),
1118            Err(SubnetAuthError::UnknownAuthority)
1119        );
1120
1121        assert!(store
1122            .descriptor_for(root_a.entity_id(), 1, TopologySubnetId::from_raw(1))
1123            .is_none());
1124    }
1125
1126    #[test]
1127    fn revisions_are_monotonic_per_scope_and_kind() {
1128        let root = root();
1129        let store = SubnetControlStore::new();
1130        let config = config_of(&root, &[&root]);
1131
1132        assert!(store
1133            .apply(&descriptor(&root, 1, 5), &config, NOW, SKEW)
1134            .unwrap());
1135        // Replay and regression are no-ops, not errors and not writes.
1136        assert!(!store
1137            .apply(&descriptor(&root, 1, 5), &config, NOW, SKEW)
1138            .unwrap());
1139        assert!(!store
1140            .apply(&descriptor(&root, 1, 4), &config, NOW, SKEW)
1141            .unwrap());
1142        // Strictly newer applies.
1143        assert!(store
1144            .apply(&descriptor(&root, 1, 6), &config, NOW, SKEW)
1145            .unwrap());
1146        assert_eq!(
1147            store
1148                .descriptor_for(&config.authority, 1, TopologySubnetId::from_raw(1))
1149                .unwrap()
1150                .revision,
1151            6
1152        );
1153        // A different path is an independent stream.
1154        assert!(store
1155            .apply(&descriptor(&root, 2, 1), &config, NOW, SKEW)
1156            .unwrap());
1157    }
1158
1159    #[test]
1160    fn a_newer_gateway_fact_does_not_suppress_an_export_policy() {
1161        let root = root();
1162        let store = SubnetControlStore::new();
1163        let config = config_of(&root, &[&root]);
1164
1165        assert!(store
1166            .apply(
1167                &export_policy(&root, 1, 1, vec![0xAAAA]),
1168                &config,
1169                NOW,
1170                SKEW
1171            )
1172            .unwrap());
1173        // A gateway fact at a far higher revision, same scope.
1174        assert!(store
1175            .apply(&gateway_ad(&root, 1, 99), &config, NOW, SKEW)
1176            .unwrap());
1177
1178        // The export policy is still served…
1179        let policy = store
1180            .export_policy_for(
1181                &config.authority,
1182                1,
1183                TopologySubnetId::from_raw(1),
1184                NOW,
1185                SKEW,
1186            )
1187            .unwrap();
1188        assert_eq!(policy.exported_channels, vec![0xAAAA]);
1189        // …and its OWN revision stream still advances from 1, not 99.
1190        assert!(store
1191            .apply(
1192                &export_policy(&root, 1, 2, vec![0xBBBB]),
1193                &config,
1194                NOW,
1195                SKEW
1196            )
1197            .unwrap());
1198    }
1199
1200    #[test]
1201    fn replay_and_reorder_converge_to_max_revision_state() {
1202        let root = root();
1203        let config = config_of(&root, &[&root]);
1204        let facts = [
1205            descriptor(&root, 1, 3),
1206            descriptor(&root, 1, 1),
1207            descriptor(&root, 1, 2),
1208            gateway_ad(&root, 1, 2),
1209            gateway_ad(&root, 1, 1),
1210            export_policy(&root, 1, 2, vec![0xCC]),
1211            export_policy(&root, 1, 1, vec![0xDD]),
1212        ];
1213        // Two adversarial orders, then a full replay of everything.
1214        for order in [[0usize, 1, 2, 3, 4, 5, 6], [6, 5, 4, 3, 2, 1, 0]] {
1215            let store = SubnetControlStore::new();
1216            for &i in &order {
1217                let _ = store.apply(&facts[i], &config, NOW, SKEW).unwrap();
1218            }
1219            for &i in &order {
1220                assert!(
1221                    !store.apply(&facts[i], &config, NOW, SKEW).unwrap(),
1222                    "a full replay must change nothing"
1223                );
1224            }
1225            let path = TopologySubnetId::from_raw(1);
1226            assert_eq!(
1227                store
1228                    .descriptor_for(&config.authority, 1, path)
1229                    .unwrap()
1230                    .revision,
1231                3
1232            );
1233            assert_eq!(
1234                store
1235                    .gateway_for(&config.authority, 1, path, NOW, SKEW)
1236                    .unwrap()
1237                    .revision,
1238                2
1239            );
1240            assert_eq!(
1241                store
1242                    .export_policy_for(&config.authority, 1, path, NOW, SKEW)
1243                    .unwrap()
1244                    .exported_channels,
1245                vec![0xCC]
1246            );
1247        }
1248    }
1249
1250    #[test]
1251    fn windowed_kinds_expire_at_read_and_refuse_at_apply() {
1252        let root = root();
1253        let store = SubnetControlStore::new();
1254        let config = config_of(&root, &[&root]);
1255
1256        assert!(store
1257            .apply(&gateway_ad(&root, 1, 1), &config, NOW, SKEW)
1258            .unwrap());
1259        let path = TopologySubnetId::from_raw(1);
1260        assert!(store
1261            .gateway_for(&config.authority, 1, path, NOW, SKEW)
1262            .is_some());
1263        // Read after the window: served no longer.
1264        assert!(store
1265            .gateway_for(&config.authority, 1, path, NOW + 7200, SKEW)
1266            .is_none());
1267        // Apply outside the window: refused.
1268        assert_eq!(
1269            store.apply(&gateway_ad(&root, 2, 1), &config, NOW + 7200, SKEW),
1270            Err(SubnetAuthError::Expired)
1271        );
1272    }
1273
1274    #[test]
1275    fn floors_are_routed_to_the_registry_not_stored_here() {
1276        let root = root();
1277        let store = SubnetControlStore::new();
1278        let config = config_of(&root, &[&root]);
1279        let floor = SubnetControlFact::RevocationFloor(
1280            SubnetRevocationFloor::try_issue(&root, scope_of(&root, 1), 1, 3, 1, NOW).unwrap(),
1281        );
1282        assert_eq!(
1283            store.apply(&floor, &config, NOW, SKEW),
1284            Err(SubnetAuthError::InvalidFormat),
1285            "the store must not become a second revocation authority"
1286        );
1287    }
1288
1289    #[test]
1290    fn purging_stale_epochs_keeps_current_and_future_facts() {
1291        let root = root();
1292        let store = SubnetControlStore::new();
1293        let config = config_of(&root, &[&root]);
1294
1295        let at_epoch = |epoch: u32, path: u32| {
1296            SubnetControlFact::Descriptor(
1297                SubnetDescriptor::try_issue(&root, scope_of(&root, path), epoch, 1, NOW).unwrap(),
1298            )
1299        };
1300        assert!(store.apply(&at_epoch(1, 1), &config, NOW, SKEW).unwrap());
1301        assert!(store.apply(&at_epoch(2, 2), &config, NOW, SKEW).unwrap());
1302        assert!(store.apply(&at_epoch(3, 3), &config, NOW, SKEW).unwrap());
1303
1304        assert_eq!(store.purge_stale_epochs(2), 1);
1305        assert!(store
1306            .descriptor_for(&config.authority, 1, TopologySubnetId::from_raw(1))
1307            .is_none());
1308        assert!(store
1309            .descriptor_for(&config.authority, 2, TopologySubnetId::from_raw(2))
1310            .is_some());
1311        assert!(store
1312            .descriptor_for(&config.authority, 3, TopologySubnetId::from_raw(3))
1313            .is_some());
1314    }
1315}