Skip to main content

mongreldb_cluster/
node.rs

1//! Node identity and node descriptor types (spec section 11.1, S2A-001;
2//! section 12.1 node descriptor; section 13.7 locality tiers).
3//!
4//! Every node persists a [`NodeIdentity`] at
5//! `<node-data>/cluster-meta/identity.json` on first boot. The identity binds
6//! the node to exactly one cluster for the lifetime of the directory: S2A-001
7//! requires that a node cannot join two clusters without an explicit
8//! wipe/reprovision, so bootstrap and join workflows fail closed with
9//! [`ClusterError::ClusterIdentityMismatch`] when a persisted identity names a
10//! different cluster. [`wipe_identity`] is the only reset and returns a typed
11//! [`WipedMarker`] report for the caller to audit; nothing is logged from
12//! this module.
13//!
14//! All durable writes are atomic: a unique temporary file is written and
15//! fsynced, then atomically renamed into place (or hard-linked for
16//! create-if-absent creation), followed by a directory fsync — the same idiom
17//! the storage core uses for its catalog checkpoints, backed by the shared
18//! bottom-layer durability helper. Loading verifies
19//! the format version and the payload: unknown versions, unknown fields,
20//! corrupt payloads, and reserved all-zero identifiers all fail closed (spec
21//! section 4.10).
22
23use std::collections::BTreeSet;
24use std::fmt;
25use std::fs::{self, File, OpenOptions};
26use std::io::{self, Read, Write};
27use std::path::{Path, PathBuf};
28use std::str::FromStr;
29use std::sync::atomic::{AtomicU64, Ordering};
30use std::time::{SystemTime, UNIX_EPOCH};
31
32use mongreldb_types::hlc::HlcTimestamp;
33use mongreldb_types::ids::{ClusterId, NodeId};
34use serde::{Deserialize, Serialize};
35
36/// The identity format version this build writes.
37pub const NODE_IDENTITY_FORMAT_VERSION: u32 = 1;
38/// The oldest identity format version this build accepts.
39pub const MIN_SUPPORTED_NODE_IDENTITY_FORMAT_VERSION: u32 = 1;
40/// Name of the per-node cluster metadata directory under the node data dir.
41pub const CLUSTER_META_DIR: &str = "cluster-meta";
42/// Name of the persisted identity file inside [`CLUSTER_META_DIR`].
43pub const IDENTITY_FILENAME: &str = "identity.json";
44/// Upper bound on a single cluster-metadata file.
45pub(crate) const MAX_META_BYTES: u64 = 16 * 1024 * 1024;
46
47/// Caller-supplied source of cryptographic randomness used to mint
48/// identifiers; production passes `getrandom::getrandom`, tests pass a
49/// deterministic filler.
50pub type Csprng<'a> = &'a mut dyn FnMut(&mut [u8]) -> Result<(), getrandom::Error>;
51
52/// The one error type of the cluster bootstrap surface.
53#[derive(Debug, thiserror::Error)]
54pub enum ClusterError {
55    /// A persisted identity binds this node to a different cluster than the
56    /// bootstrap/join being attempted (S2A-001). Only
57    /// [`wipe_identity`] resets the binding.
58    #[error(
59        "cluster identity mismatch: persisted identity belongs to cluster {persisted}, \
60         cannot bootstrap or join cluster {requested}; wipe the node identity to reprovision"
61    )]
62    ClusterIdentityMismatch {
63        /// Cluster the persisted identity belongs to.
64        persisted: ClusterId,
65        /// Cluster the attempted operation targeted.
66        requested: ClusterId,
67    },
68    /// A durable metadata file carries a format version outside the
69    /// supported range (spec section 4.10: fail closed).
70    #[error("unsupported format version {found} in {file} (supported {min}..={max})")]
71    UnsupportedFormatVersion {
72        /// Metadata file kind (`identity.json`, `cluster.json`, ...).
73        file: &'static str,
74        /// Version found in the file.
75        found: u32,
76        /// Oldest version this build accepts.
77        min: u32,
78        /// Newest version this build accepts.
79        max: u32,
80    },
81    /// A durable metadata file failed structural verification: undecodable
82    /// payload, unknown fields, or a reserved all-zero identifier.
83    #[error("cluster metadata file {file} failed verification: {detail}")]
84    CorruptMetadata {
85        /// Metadata file kind (`identity.json`, `cluster.json`, ...).
86        file: &'static str,
87        /// What failed verification.
88        detail: String,
89    },
90    /// Cluster metadata I/O failed.
91    #[error("cluster metadata I/O error: {0}")]
92    Io(#[from] std::io::Error),
93    /// The caller-supplied CSPRNG failed.
94    #[error("operating-system CSPRNG failed: {0}")]
95    Rng(String),
96    /// The operation is deliberately not implemented yet; fails closed.
97    #[error("unsupported operation: {0}")]
98    Unsupported(&'static str),
99    /// Caller-supplied trust material failed validation.
100    #[error("invalid trust material: {0}")]
101    InvalidTrustMaterial(&'static str),
102    /// A `cluster join` invite failed validation.
103    #[error("invalid join invite: {0}")]
104    InvalidInvite(&'static str),
105    /// The node is already bootstrapped; re-running init/join is rejected.
106    #[error(
107        "cluster is already bootstrapped on this node (cluster {cluster_id}); \
108         wipe the node identity to reprovision"
109    )]
110    AlreadyBootstrapped {
111        /// Cluster the persisted bootstrap record belongs to.
112        cluster_id: ClusterId,
113    },
114    /// No bootstrap record exists in this directory yet.
115    #[error(
116        "cluster metadata is not initialized in this directory; run cluster init or join first"
117    )]
118    NotInitialized,
119    /// Another bootstrap workflow holds the bootstrap lock file.
120    ///
121    /// Recovery (review **N7**): if no bootstrap is running, remove the
122    /// stale `cluster-meta/bootstrap.lock` (content is pid + timestamp for
123    /// diagnosis) and retry `cluster init` / `join` / `drain`.
124    #[error(
125        "another bootstrap workflow holds the lock {0}; if no init/join/drain is running, \
126         remove that lock file (it holds pid+timestamp) and retry"
127    )]
128    BootstrapInProgress(PathBuf),
129    /// The target node is absent from the local membership record.
130    #[error("node {node} is not present in the cluster membership record")]
131    NodeNotFound {
132        /// The node that was looked up.
133        node: NodeId,
134    },
135    /// The requested node state transition is not permitted.
136    #[error("invalid node state transition for node {node}: {from} -> {to}")]
137    InvalidNodeStateTransition {
138        /// The node whose state was to change.
139        node: NodeId,
140        /// Current persisted state.
141        from: NodeState,
142        /// Requested target state.
143        to: NodeState,
144    },
145    /// `node remove` was attempted without the matching confirmation token.
146    #[error("invalid node-removal confirmation token")]
147    InvalidConfirmationToken,
148}
149
150/// Persisted node identity (spec section 11.1, S2A-001).
151///
152/// Serialized as versioned JSON; `format_version` is part of the struct per
153/// S2A-001 and is verified on every load. Unknown fields and unknown versions
154/// fail closed (spec section 4.10).
155#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(deny_unknown_fields)]
157pub struct NodeIdentity {
158    /// Cluster this node is bound to for the lifetime of its data directory.
159    pub cluster_id: ClusterId,
160    /// This node's identifier within the cluster.
161    pub node_id: NodeId,
162    /// Wall-clock time the identity was minted (informational).
163    pub created_at: HlcTimestamp,
164    /// Durable format version; see [`NODE_IDENTITY_FORMAT_VERSION`].
165    pub format_version: u32,
166}
167
168impl NodeIdentity {
169    /// Load and verify the persisted identity, if present.
170    ///
171    /// Returns `Ok(None)` when no identity has been minted yet. A present but
172    /// undecodable, unknown-version, or reserved-identifier file fails closed
173    /// with a typed error; it is never silently replaced.
174    pub fn load(node_data: &Path) -> Result<Option<Self>, ClusterError> {
175        let path = cluster_meta_dir(node_data).join(IDENTITY_FILENAME);
176        let Some(bytes) = read_meta_file(&path)? else {
177            return Ok(None);
178        };
179        let identity: Self = decode_json(IDENTITY_FILENAME, &bytes)?;
180        if identity.format_version < MIN_SUPPORTED_NODE_IDENTITY_FORMAT_VERSION
181            || identity.format_version > NODE_IDENTITY_FORMAT_VERSION
182        {
183            return Err(ClusterError::UnsupportedFormatVersion {
184                file: IDENTITY_FILENAME,
185                found: identity.format_version,
186                min: MIN_SUPPORTED_NODE_IDENTITY_FORMAT_VERSION,
187                max: NODE_IDENTITY_FORMAT_VERSION,
188            });
189        }
190        if identity.cluster_id == ClusterId::ZERO || identity.node_id == NodeId::ZERO {
191            return Err(ClusterError::CorruptMetadata {
192                file: IDENTITY_FILENAME,
193                detail: "reserved all-zero identifier".to_owned(),
194            });
195        }
196        Ok(Some(identity))
197    }
198
199    /// Load the persisted identity, or mint and persist a fresh one on first
200    /// boot (`cluster_id` and `node_id` drawn from `csprng`).
201    ///
202    /// Concurrent first boots on one directory race on an atomic
203    /// create-if-absent publish; the loser loads and returns the winner's
204    /// identity, so the result is stable.
205    pub fn load_or_create(node_data: &Path, csprng: Csprng<'_>) -> Result<Self, ClusterError> {
206        if let Some(identity) = Self::load(node_data)? {
207            return Ok(identity);
208        }
209        let cluster_id = ClusterId::from_bytes(mint_id(csprng)?);
210        match Self::create(node_data, cluster_id, csprng)? {
211            CreateOutcome::Created(identity) => Ok(identity),
212            CreateOutcome::AlreadyExists => {
213                Self::load(node_data)?.ok_or(ClusterError::CorruptMetadata {
214                    file: IDENTITY_FILENAME,
215                    detail: "identity vanished after create race".to_owned(),
216                })
217            }
218        }
219    }
220
221    /// Provision an identity for a specific cluster (bootstrap/join path).
222    ///
223    /// - No persisted identity: mint a fresh `node_id` and persist.
224    /// - Persisted identity for the same cluster: verified and returned
225    ///   unchanged (idempotent retry).
226    /// - Persisted identity for a different cluster: fails closed with
227    ///   [`ClusterError::ClusterIdentityMismatch`] (S2A-001).
228    pub(crate) fn provision(
229        node_data: &Path,
230        cluster_id: ClusterId,
231        csprng: Csprng<'_>,
232    ) -> Result<Self, ClusterError> {
233        match Self::create(node_data, cluster_id, csprng)? {
234            CreateOutcome::Created(identity) => Ok(identity),
235            CreateOutcome::AlreadyExists => {
236                let identity = Self::load(node_data)?.ok_or(ClusterError::CorruptMetadata {
237                    file: IDENTITY_FILENAME,
238                    detail: "identity vanished after create race".to_owned(),
239                })?;
240                if identity.cluster_id != cluster_id {
241                    return Err(ClusterError::ClusterIdentityMismatch {
242                        persisted: identity.cluster_id,
243                        requested: cluster_id,
244                    });
245                }
246                Ok(identity)
247            }
248        }
249    }
250
251    fn create(
252        node_data: &Path,
253        cluster_id: ClusterId,
254        csprng: Csprng<'_>,
255    ) -> Result<CreateOutcome, ClusterError> {
256        let identity = Self {
257            cluster_id,
258            node_id: NodeId::from_bytes(mint_id(csprng)?),
259            created_at: wall_clock_now(),
260            format_version: NODE_IDENTITY_FORMAT_VERSION,
261        };
262        let bytes = encode_json(IDENTITY_FILENAME, &identity)?;
263        let meta_dir = cluster_meta_dir(node_data);
264        fs::create_dir_all(&meta_dir)?;
265        Ok(
266            match create_meta_file(&meta_dir, IDENTITY_FILENAME, &bytes)? {
267                true => CreateOutcome::Created(identity),
268                false => CreateOutcome::AlreadyExists,
269            },
270        )
271    }
272}
273
274enum CreateOutcome {
275    Created(NodeIdentity),
276    AlreadyExists,
277}
278
279/// Typed audit report returned by [`wipe_identity`]; the caller decides how
280/// to log it (this module never logs).
281#[derive(Clone, Debug, PartialEq, Eq)]
282pub struct WipedMarker {
283    /// Cluster the wiped identity belonged to.
284    pub wiped_cluster_id: ClusterId,
285    /// Node id of the wiped identity.
286    pub wiped_node_id: NodeId,
287    /// Wall-clock time the wipe ran (informational).
288    pub wiped_at: HlcTimestamp,
289    /// Durable files removed by the wipe, relative to the node data dir.
290    pub removed_files: Vec<PathBuf>,
291}
292
293/// Explicitly wipe this node's cluster provisioning state: the identity plus
294/// any bootstrap records under `cluster-meta/`.
295///
296/// This is the only reset that lets the node join a different cluster
297/// (S2A-001). Returns `Ok(None)` when no identity was persisted. The returned
298/// [`WipedMarker`] is the audit trail of what was destroyed.
299pub fn wipe_identity(node_data: &Path) -> Result<Option<WipedMarker>, ClusterError> {
300    let Some(identity) = NodeIdentity::load(node_data)? else {
301        return Ok(None);
302    };
303    let meta_dir = cluster_meta_dir(node_data);
304    let mut removed_files = Vec::new();
305    for filename in [
306        IDENTITY_FILENAME,
307        crate::bootstrap::CLUSTER_RECORD_FILENAME,
308        crate::bootstrap::TRUST_FILENAME,
309        crate::bootstrap::JOIN_RECORD_FILENAME,
310    ] {
311        let path = meta_dir.join(filename);
312        match fs::remove_file(&path) {
313            Ok(()) => removed_files.push(path),
314            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
315            Err(error) => return Err(error.into()),
316        }
317    }
318    sync_dir(&meta_dir)?;
319    Ok(Some(WipedMarker {
320        wiped_cluster_id: identity.cluster_id,
321        wiped_node_id: identity.node_id,
322        wiped_at: wall_clock_now(),
323        removed_files,
324    }))
325}
326
327/// Lifecycle state of a cluster node (spec section 12.1). Declaration order
328/// is frozen; enum values are never reused (spec section 4.10).
329#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
330pub enum NodeState {
331    /// Joined but not yet accepted into membership.
332    Bootstrapping,
333    /// Full member serving traffic.
334    Up,
335    /// Leaving the cluster; replicas and leases are moved off first.
336    Draining,
337    /// Permanently removed from the cluster.
338    Decommissioned,
339    /// Temporarily unreachable; expected to return.
340    Down,
341}
342
343impl fmt::Display for NodeState {
344    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
345        let name = match self {
346            Self::Bootstrapping => "Bootstrapping",
347            Self::Up => "Up",
348            Self::Draining => "Draining",
349            Self::Decommissioned => "Decommissioned",
350            Self::Down => "Down",
351        };
352        f.write_str(name)
353    }
354}
355
356/// Error returned when parsing a textual [`Locality`] fails.
357#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
358pub enum LocalityParseError {
359    /// A comma-separated tier was not of the form `key=value`.
360    #[error("invalid locality tier `{0}`: expected `key=value`")]
361    InvalidTier(String),
362    /// A tier had an empty key or value.
363    #[error("locality tier `{0}` has an empty key or value")]
364    EmptyComponent(String),
365    /// The same locality key appeared twice.
366    #[error("duplicate locality key `{0}`")]
367    DuplicateKey(String),
368}
369
370/// Ordered locality tiers of a node, coarsest first, following the
371/// `region, availability zone, rack, node` hierarchy of spec section 13.7.
372///
373/// The canonical text form is comma-separated `key=value` pairs, matching the
374/// node configuration file (`locality = "region=us-central,zone=a"`, spec
375/// section 16.1). Parsing trims surrounding whitespace, rejects malformed,
376/// empty, and duplicate tiers, and preserves tier order.
377#[derive(Clone, Debug, Default, PartialEq, Eq)]
378pub struct Locality {
379    tiers: Vec<(String, String)>,
380}
381
382impl Locality {
383    /// The ordered `(key, value)` tiers, coarsest first.
384    pub fn tiers(&self) -> &[(String, String)] {
385        &self.tiers
386    }
387
388    /// The value of one tier key, if present.
389    pub fn get(&self, key: &str) -> Option<&str> {
390        self.tiers
391            .iter()
392            .find(|(k, _)| k == key)
393            .map(|(_, v)| v.as_str())
394    }
395}
396
397impl FromStr for Locality {
398    type Err = LocalityParseError;
399
400    fn from_str(text: &str) -> Result<Self, Self::Err> {
401        let mut tiers = Vec::new();
402        for tier in text.split(',') {
403            let tier = tier.trim();
404            if tier.is_empty() {
405                continue;
406            }
407            let (key, value) = tier
408                .split_once('=')
409                .ok_or_else(|| LocalityParseError::InvalidTier(tier.to_owned()))?;
410            let key = key.trim();
411            let value = value.trim();
412            if key.is_empty() || value.is_empty() {
413                return Err(LocalityParseError::EmptyComponent(tier.to_owned()));
414            }
415            if tiers.iter().any(|(k, _): &(String, String)| k == key) {
416                return Err(LocalityParseError::DuplicateKey(key.to_owned()));
417            }
418            tiers.push((key.to_owned(), value.to_owned()));
419        }
420        Ok(Self { tiers })
421    }
422}
423
424impl fmt::Display for Locality {
425    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
426        for (index, (key, value)) in self.tiers.iter().enumerate() {
427            if index > 0 {
428                f.write_str(",")?;
429            }
430            write!(f, "{key}={value}")?;
431        }
432        Ok(())
433    }
434}
435
436impl Serialize for Locality {
437    /// Serializes as the canonical `key=value,...` string in every format.
438    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
439        serializer.serialize_str(&self.to_string())
440    }
441}
442
443impl<'de> Deserialize<'de> for Locality {
444    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
445        let text = String::deserialize(deserializer)?;
446        text.parse().map_err(serde::de::Error::custom)
447    }
448}
449
450/// Advertised capacity of one node, used by placement (spec section 12.1).
451#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
452#[serde(deny_unknown_fields)]
453pub struct NodeCapacity {
454    /// Logical CPU cores.
455    pub cpu: u32,
456    /// Usable memory in bytes.
457    pub memory_bytes: u64,
458    /// Usable disk in bytes.
459    pub disk_bytes: u64,
460}
461
462/// Build version a node advertises (spec section 11.8).
463#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
464#[serde(deny_unknown_fields)]
465pub struct BuildVersion {
466    /// Engine package version (`CARGO_PKG_VERSION` at build time).
467    pub version: String,
468    /// Source revision, when the build recorded `MONGRELDB_GIT_SHA`.
469    pub git_sha: Option<String>,
470}
471
472impl BuildVersion {
473    /// The version of the running binary.
474    pub fn current() -> Self {
475        Self {
476            version: env!("CARGO_PKG_VERSION").to_owned(),
477            git_sha: option_env!("MONGRELDB_GIT_SHA").map(str::to_owned),
478        }
479    }
480}
481
482/// Oldest wire-protocol version this build accepts (spec section 11.8).
483///
484/// Mirrors `MIN_SUPPORTED_PROTOCOL_VERSION` in
485/// `crates/mongreldb-protocol/src/envelope.rs`; the cluster crate
486/// deliberately does not depend on the protocol crate, so the value is
487/// declared here and must track it (join-time overlap checks fail closed on
488/// drift).
489pub const PROTOCOL_VERSION_MIN: u32 = 1;
490/// Newest wire-protocol version this build speaks.
491///
492/// Mirrors `PROTOCOL_VERSION` in
493/// `crates/mongreldb-protocol/src/envelope.rs`; see [`PROTOCOL_VERSION_MIN`].
494pub const PROTOCOL_VERSION_MAX: u32 = 1;
495/// Oldest committed-command log format this build reads: the FND-003
496/// `CommandEnvelope` minimum in `mongreldb-log`.
497pub const LOG_FORMAT_VERSION_MIN: u32 = mongreldb_log::envelope::MIN_SUPPORTED_FORMAT_VERSION;
498/// Newest committed-command log format this build writes: the FND-003
499/// `CommandEnvelope` version in `mongreldb-log`.
500pub const LOG_FORMAT_VERSION_MAX: u32 = mongreldb_log::envelope::COMMAND_ENVELOPE_FORMAT_VERSION;
501/// Oldest replicated snapshot format this build reads.
502///
503/// Mirrors the private log-store `FORMAT_VERSION` in
504/// `crates/mongreldb-consensus/src/storage.rs`, which frames the snapshot
505/// records this build exchanges; the authoritative minimum lands in
506/// `mongreldb-consensus` when snapshot envelopes are versioned per
507/// ADR-0010.
508pub const SNAPSHOT_FORMAT_VERSION_MIN: u32 = 1;
509/// Newest replicated snapshot format this build writes.
510///
511/// Mirrors the private log-store `FORMAT_VERSION` in
512/// `crates/mongreldb-consensus/src/storage.rs`; the authoritative maximum
513/// lands in `mongreldb-consensus` alongside snapshot envelope versioning
514/// (ADR-0010 migration step 3).
515pub const SNAPSHOT_FORMAT_VERSION_MAX: u32 = 1;
516
517/// Version and format compatibility envelope every node advertises (spec
518/// section 11.8; ADR-0010 decision 5).
519///
520/// Ranges are inclusive. The derived [`Default`] describes a descriptor
521/// written before Stage 2H that never carried an advertisement: its zeroed
522/// ranges overlap only other zeroed ranges, so a missing advertisement fails
523/// closed against any real one (spec section 4.10).
524#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
525#[serde(deny_unknown_fields)]
526pub struct VersionInfo {
527    /// Engine package version of the running binary (`CARGO_PKG_VERSION`).
528    pub binary_version: String,
529    /// Oldest wire-protocol version accepted.
530    pub protocol_min: u32,
531    /// Newest wire-protocol version spoken.
532    pub protocol_max: u32,
533    /// Oldest committed-command log format read.
534    pub log_format_min: u32,
535    /// Newest committed-command log format written.
536    pub log_format_max: u32,
537    /// Oldest replicated snapshot format read.
538    pub snapshot_format_min: u32,
539    /// Newest replicated snapshot format written.
540    pub snapshot_format_max: u32,
541    /// Names of cluster features this build can activate; the advertised set
542    /// gates feature activation (spec section 11.8 step 5).
543    pub feature_set: BTreeSet<String>,
544}
545
546impl VersionInfo {
547    /// The advertisement of the running binary, sourced from the build
548    /// environment and this crate's declared format ranges.
549    pub fn current() -> Self {
550        Self {
551            binary_version: env!("CARGO_PKG_VERSION").to_owned(),
552            protocol_min: PROTOCOL_VERSION_MIN,
553            protocol_max: PROTOCOL_VERSION_MAX,
554            log_format_min: LOG_FORMAT_VERSION_MIN,
555            log_format_max: LOG_FORMAT_VERSION_MAX,
556            snapshot_format_min: SNAPSHOT_FORMAT_VERSION_MIN,
557            snapshot_format_max: SNAPSHOT_FORMAT_VERSION_MAX,
558            feature_set: crate::meta::FeatureRegistry::current().feature_names(),
559        }
560    }
561
562    /// Verify that this node and `other` can interoperate in one cluster.
563    ///
564    /// Compatibility requires every advertised range pair to overlap: with
565    /// the N/N-1 support window (spec section 17) an N binary accepts
566    /// `[N-1, N]` and an N-1 binary accepts `[N-1, N-1]`, which overlap, while
567    /// a two-version skew does not. Failures fail closed with a typed
568    /// [`Incompatibility`] naming the first non-overlapping range.
569    pub fn is_compatible_with(&self, other: &Self) -> Result<(), Incompatibility> {
570        if !ranges_overlap(
571            self.protocol_min,
572            self.protocol_max,
573            other.protocol_min,
574            other.protocol_max,
575        ) {
576            return Err(Incompatibility::ProtocolVersion {
577                ours: (self.protocol_min, self.protocol_max),
578                theirs: (other.protocol_min, other.protocol_max),
579            });
580        }
581        if !ranges_overlap(
582            self.log_format_min,
583            self.log_format_max,
584            other.log_format_min,
585            other.log_format_max,
586        ) {
587            return Err(Incompatibility::LogFormat {
588                ours: (self.log_format_min, self.log_format_max),
589                theirs: (other.log_format_min, other.log_format_max),
590            });
591        }
592        if !ranges_overlap(
593            self.snapshot_format_min,
594            self.snapshot_format_max,
595            other.snapshot_format_min,
596            other.snapshot_format_max,
597        ) {
598            return Err(Incompatibility::SnapshotFormat {
599                ours: (self.snapshot_format_min, self.snapshot_format_max),
600                theirs: (other.snapshot_format_min, other.snapshot_format_max),
601            });
602        }
603        Ok(())
604    }
605}
606
607/// Inclusive ranges `[a_min, a_max]` and `[b_min, b_max]` overlap. Malformed
608/// ranges (`min > max`) never overlap, so they fail closed.
609fn ranges_overlap(a_min: u32, a_max: u32, b_min: u32, b_max: u32) -> bool {
610    a_min <= a_max && b_min <= b_max && a_min <= b_max && b_min <= a_max
611}
612
613/// One advertised range pair does not overlap; compatibility checks fail
614/// closed with the first mismatch found (ADR-0010: never silently degrade).
615#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
616pub enum Incompatibility {
617    /// Wire-protocol ranges do not overlap.
618    #[error(
619        "wire-protocol version ranges do not overlap: {ours:?} vs {theirs:?} (min, max inclusive)"
620    )]
621    ProtocolVersion {
622        /// This node's `(min, max)` protocol range.
623        ours: (u32, u32),
624        /// The other node's `(min, max)` protocol range.
625        theirs: (u32, u32),
626    },
627    /// Committed-command log format ranges do not overlap.
628    #[error("log format ranges do not overlap: {ours:?} vs {theirs:?} (min, max inclusive)")]
629    LogFormat {
630        /// This node's `(min, max)` log format range.
631        ours: (u32, u32),
632        /// The other node's `(min, max)` log format range.
633        theirs: (u32, u32),
634    },
635    /// Replicated snapshot format ranges do not overlap.
636    #[error("snapshot format ranges do not overlap: {ours:?} vs {theirs:?} (min, max inclusive)")]
637    SnapshotFormat {
638        /// This node's `(min, max)` snapshot format range.
639        ours: (u32, u32),
640        /// The other node's `(min, max)` snapshot format range.
641        theirs: (u32, u32),
642    },
643}
644
645/// One cluster member as advertised by the meta control plane (spec section
646/// 12.1). Defined with the Stage 2A bootstrap workflows; the meta group takes
647/// ownership of the replicated copy in Stage 3A.
648#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
649#[serde(deny_unknown_fields)]
650pub struct NodeDescriptor {
651    /// The member's node identifier.
652    pub node_id: NodeId,
653    /// Advertised RPC address (`host:port`).
654    pub rpc_address: String,
655    /// Ordered locality tiers (spec section 13.7).
656    pub locality: Locality,
657    /// Advertised capacity.
658    pub capacity: NodeCapacity,
659    /// Lifecycle state.
660    pub state: NodeState,
661    /// Advertised build version.
662    pub version: BuildVersion,
663    /// Advertised version/format compatibility envelope (spec section 11.8).
664    ///
665    /// Deserialized with [`VersionInfo::default`] when absent so descriptors
666    /// written before Stage 2H still decode; the default's zeroed ranges
667    /// fail closed against any real advertisement (spec section 4.10).
668    #[serde(default)]
669    pub version_info: VersionInfo,
670}
671
672/// `<node-data>/cluster-meta`.
673pub(crate) fn cluster_meta_dir(node_data: &Path) -> PathBuf {
674    node_data.join(CLUSTER_META_DIR)
675}
676
677/// Wall-clock time as an HLC timestamp (logical and tiebreaker zero); used
678/// only for informational `created_at`/`wiped_at` markers, never for commit
679/// timestamps.
680pub(crate) fn wall_clock_now() -> HlcTimestamp {
681    let physical_micros = SystemTime::now()
682        .duration_since(UNIX_EPOCH)
683        .map(|duration| u64::try_from(duration.as_micros()).unwrap_or(u64::MAX))
684        .unwrap_or(0);
685    HlcTimestamp {
686        physical_micros,
687        logical: 0,
688        node_tiebreaker: 0,
689    }
690}
691
692/// Mint one non-zero 128-bit identifier from the CSPRNG. The all-zero value
693/// is reserved, so it is redrawn rather than persisted.
694pub(crate) fn mint_id(csprng: Csprng<'_>) -> Result<[u8; 16], ClusterError> {
695    loop {
696        let mut bytes = [0u8; 16];
697        csprng(&mut bytes).map_err(|error| ClusterError::Rng(error.to_string()))?;
698        if bytes != [0u8; 16] {
699            return Ok(bytes);
700        }
701    }
702}
703
704/// Serialize one metadata value as pretty JSON.
705pub(crate) fn encode_json<T: Serialize>(
706    file: &'static str,
707    value: &T,
708) -> Result<Vec<u8>, ClusterError> {
709    serde_json::to_vec_pretty(value).map_err(|error| ClusterError::CorruptMetadata {
710        file,
711        detail: format!("encode: {error}"),
712    })
713}
714
715/// Deserialize one metadata value, rejecting trailing bytes and unknown
716/// fields (via `deny_unknown_fields` on the target type).
717pub(crate) fn decode_json<T: for<'de> Deserialize<'de>>(
718    file: &'static str,
719    bytes: &[u8],
720) -> Result<T, ClusterError> {
721    serde_json::from_slice(bytes).map_err(|error| ClusterError::CorruptMetadata {
722        file,
723        detail: format!("decode: {error}"),
724    })
725}
726
727/// Read one metadata file, returning `Ok(None)` when it is absent. Files
728/// larger than [`MAX_META_BYTES`] fail closed.
729pub(crate) fn read_meta_file(path: &Path) -> Result<Option<Vec<u8>>, ClusterError> {
730    let file = match File::open(path) {
731        Ok(file) => file,
732        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
733        Err(error) => return Err(error.into()),
734    };
735    let length = file.metadata()?.len();
736    if length > MAX_META_BYTES {
737        return Err(ClusterError::CorruptMetadata {
738            file: "cluster-meta",
739            detail: format!(
740                "{} exceeds the {} byte limit",
741                path.display(),
742                MAX_META_BYTES
743            ),
744        });
745    }
746    let mut bytes = Vec::with_capacity(length as usize);
747    file.take(MAX_META_BYTES + 1).read_to_end(&mut bytes)?;
748    if bytes.len() as u64 != length {
749        return Err(ClusterError::CorruptMetadata {
750            file: "cluster-meta",
751            detail: format!("{} changed while reading", path.display()),
752        });
753    }
754    Ok(Some(bytes))
755}
756
757/// Atomically replace `<dir>/<filename>`: unique synced temporary file,
758/// atomic rename, directory fsync (the storage core's catalog idiom).
759pub(crate) fn write_meta_atomic(dir: &Path, filename: &str, bytes: &[u8]) -> io::Result<()> {
760    let temporary = write_temp_file(dir, filename, bytes)?;
761    let result = fs::rename(&temporary, dir.join(filename));
762    match result {
763        Ok(()) => sync_dir(dir),
764        Err(error) => {
765            let _ = fs::remove_file(&temporary);
766            Err(error)
767        }
768    }
769}
770
771/// Atomically create `<dir>/<filename>` only if it does not exist (synced
772/// temporary file published by a hard link, which fails if the destination
773/// already exists). Returns `Ok(false)` when the file was already present.
774pub(crate) fn create_meta_file(dir: &Path, filename: &str, bytes: &[u8]) -> io::Result<bool> {
775    let temporary = write_temp_file(dir, filename, bytes)?;
776    let result = fs::hard_link(&temporary, dir.join(filename));
777    let _ = fs::remove_file(&temporary);
778    match result {
779        Ok(()) => {
780            sync_dir(dir)?;
781            Ok(true)
782        }
783        Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(false),
784        Err(error) => Err(error),
785    }
786}
787
788/// Write `bytes` to a unique temporary file beside `filename` and fsync it.
789fn write_temp_file(dir: &Path, filename: &str, bytes: &[u8]) -> io::Result<PathBuf> {
790    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
791    loop {
792        let unique = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
793        let temporary = dir.join(format!(".{filename}.tmp-{}-{unique}", std::process::id()));
794        let mut file = match OpenOptions::new()
795            .write(true)
796            .create_new(true)
797            .open(&temporary)
798        {
799            Ok(file) => file,
800            // Another writer took this exact name; draw a fresh one.
801            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
802            Err(error) => return Err(error),
803        };
804        let result = file.write_all(bytes).and_then(|()| file.sync_all());
805        drop(file);
806        match result {
807            Ok(()) => return Ok(temporary),
808            Err(error) => {
809                let _ = fs::remove_file(&temporary);
810                return Err(error);
811            }
812        }
813    }
814}
815
816/// Fsync a directory so a rename/link inside it becomes durable.
817pub(crate) fn sync_dir(dir: &Path) -> io::Result<()> {
818    mongreldb_types::durability::sync_directory(dir)
819}
820
821#[cfg(test)]
822mod tests {
823    use super::*;
824
825    fn test_csprng() -> impl FnMut(&mut [u8]) -> Result<(), getrandom::Error> {
826        let mut counter = 0u64;
827        move |buf: &mut [u8]| {
828            for chunk in buf.chunks_mut(8) {
829                counter += 1;
830                let bytes = counter.to_le_bytes();
831                chunk.copy_from_slice(&bytes[..chunk.len()]);
832            }
833            Ok(())
834        }
835    }
836
837    fn minted_identity(node_data: &Path) -> NodeIdentity {
838        NodeIdentity::load_or_create(node_data, &mut test_csprng()).expect("mint identity")
839    }
840
841    #[test]
842    fn first_boot_mints_and_persists_identity() {
843        let dir = tempfile::tempdir().unwrap();
844        let identity = minted_identity(dir.path());
845        assert_ne!(identity.cluster_id, ClusterId::ZERO);
846        assert_ne!(identity.node_id, NodeId::ZERO);
847        assert_eq!(identity.format_version, NODE_IDENTITY_FORMAT_VERSION);
848        assert!(dir
849            .path()
850            .join(CLUSTER_META_DIR)
851            .join(IDENTITY_FILENAME)
852            .is_file());
853    }
854
855    #[test]
856    fn reload_returns_the_same_identity() {
857        let dir = tempfile::tempdir().unwrap();
858        let first = minted_identity(dir.path());
859        let second = NodeIdentity::load_or_create(dir.path(), &mut test_csprng())
860            .expect("second boot loads, never re-mints");
861        assert_eq!(first, second);
862        assert_eq!(NodeIdentity::load(dir.path()).unwrap(), Some(first));
863    }
864
865    #[test]
866    fn minted_cluster_and_node_ids_differ() {
867        let dir = tempfile::tempdir().unwrap();
868        let identity = minted_identity(dir.path());
869        assert_ne!(identity.cluster_id.as_bytes(), identity.node_id.as_bytes());
870    }
871
872    #[test]
873    fn load_on_empty_dir_returns_none() {
874        let dir = tempfile::tempdir().unwrap();
875        assert_eq!(NodeIdentity::load(dir.path()).unwrap(), None);
876    }
877
878    #[test]
879    fn unknown_format_version_fails_closed() {
880        let dir = tempfile::tempdir().unwrap();
881        minted_identity(dir.path());
882        let mut value: serde_json::Value = serde_json::from_slice(
883            &std::fs::read(dir.path().join(CLUSTER_META_DIR).join(IDENTITY_FILENAME)).unwrap(),
884        )
885        .unwrap();
886        value["format_version"] = serde_json::json!(99);
887        std::fs::write(
888            dir.path().join(CLUSTER_META_DIR).join(IDENTITY_FILENAME),
889            serde_json::to_vec(&value).unwrap(),
890        )
891        .unwrap();
892        let error = NodeIdentity::load(dir.path()).unwrap_err();
893        assert!(
894            matches!(
895                error,
896                ClusterError::UnsupportedFormatVersion {
897                    file: IDENTITY_FILENAME,
898                    found: 99,
899                    ..
900                }
901            ),
902            "unexpected error: {error}"
903        );
904        // A tampered file is never silently replaced.
905        assert!(NodeIdentity::load_or_create(dir.path(), &mut test_csprng()).is_err());
906    }
907
908    #[test]
909    fn corrupt_payload_fails_closed() {
910        let dir = tempfile::tempdir().unwrap();
911        let meta = dir.path().join(CLUSTER_META_DIR);
912        std::fs::create_dir_all(&meta).unwrap();
913        std::fs::write(meta.join(IDENTITY_FILENAME), b"{ not json").unwrap();
914        let error = NodeIdentity::load(dir.path()).unwrap_err();
915        assert!(matches!(
916            error,
917            ClusterError::CorruptMetadata {
918                file: IDENTITY_FILENAME,
919                ..
920            }
921        ));
922    }
923
924    #[test]
925    fn unknown_fields_fail_closed() {
926        let dir = tempfile::tempdir().unwrap();
927        minted_identity(dir.path());
928        let path = dir.path().join(CLUSTER_META_DIR).join(IDENTITY_FILENAME);
929        let mut value: serde_json::Value =
930            serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
931        value["unexpected"] = serde_json::json!(1);
932        std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
933        let error = NodeIdentity::load(dir.path()).unwrap_err();
934        assert!(matches!(
935            error,
936            ClusterError::CorruptMetadata {
937                file: IDENTITY_FILENAME,
938                ..
939            }
940        ));
941    }
942
943    #[test]
944    fn reserved_zero_identifiers_fail_closed() {
945        let dir = tempfile::tempdir().unwrap();
946        let meta = dir.path().join(CLUSTER_META_DIR);
947        std::fs::create_dir_all(&meta).unwrap();
948        let identity = NodeIdentity {
949            cluster_id: ClusterId::ZERO,
950            node_id: NodeId::new_random(),
951            created_at: HlcTimestamp::ZERO,
952            format_version: NODE_IDENTITY_FORMAT_VERSION,
953        };
954        std::fs::write(
955            meta.join(IDENTITY_FILENAME),
956            serde_json::to_vec(&identity).unwrap(),
957        )
958        .unwrap();
959        let error = NodeIdentity::load(dir.path()).unwrap_err();
960        assert!(matches!(
961            error,
962            ClusterError::CorruptMetadata {
963                file: IDENTITY_FILENAME,
964                ..
965            }
966        ));
967    }
968
969    #[test]
970    fn provision_rejects_a_different_cluster() {
971        let dir = tempfile::tempdir().unwrap();
972        let cluster_a = ClusterId::new_random();
973        let identity = NodeIdentity::provision(dir.path(), cluster_a, &mut test_csprng()).unwrap();
974        assert_eq!(identity.cluster_id, cluster_a);
975        // Same cluster: idempotent, returns the persisted identity.
976        let again = NodeIdentity::provision(dir.path(), cluster_a, &mut test_csprng()).unwrap();
977        assert_eq!(again, identity);
978        // Different cluster: fails closed (S2A-001).
979        let error =
980            NodeIdentity::provision(dir.path(), ClusterId::new_random(), &mut test_csprng())
981                .unwrap_err();
982        assert!(
983            matches!(
984                error,
985                ClusterError::ClusterIdentityMismatch { persisted, .. } if persisted == cluster_a
986            ),
987            "unexpected error: {error}"
988        );
989    }
990
991    #[test]
992    fn wipe_is_the_only_reset() {
993        let dir = tempfile::tempdir().unwrap();
994        let cluster_a = ClusterId::new_random();
995        NodeIdentity::provision(dir.path(), cluster_a, &mut test_csprng()).unwrap();
996        let marker = wipe_identity(dir.path())
997            .unwrap()
998            .expect("identity was persisted");
999        assert_eq!(marker.wiped_cluster_id, cluster_a);
1000        assert!(marker
1001            .removed_files
1002            .iter()
1003            .any(|path| path.ends_with(IDENTITY_FILENAME)));
1004        assert_eq!(NodeIdentity::load(dir.path()).unwrap(), None);
1005        // Second wipe is a no-op.
1006        assert_eq!(wipe_identity(dir.path()).unwrap(), None);
1007        // After a wipe the node may join another cluster.
1008        let cluster_b = ClusterId::new_random();
1009        let identity = NodeIdentity::provision(dir.path(), cluster_b, &mut test_csprng()).unwrap();
1010        assert_eq!(identity.cluster_id, cluster_b);
1011    }
1012
1013    #[test]
1014    fn locality_round_trips_canonical_text() {
1015        let locality: Locality = "region=us-central,zone=a".parse().unwrap();
1016        assert_eq!(locality.tiers().len(), 2);
1017        assert_eq!(locality.get("region"), Some("us-central"));
1018        assert_eq!(locality.get("zone"), Some("a"));
1019        assert_eq!(locality.get("rack"), None);
1020        assert_eq!(locality.to_string(), "region=us-central,zone=a");
1021        // Full section 13.7 hierarchy with lenient whitespace.
1022        let full: Locality = " region=r1 , zone=z , rack=rk , node=n ".parse().unwrap();
1023        assert_eq!(full.to_string(), "region=r1,zone=z,rack=rk,node=n");
1024        // Empty text is an empty locality.
1025        assert!("".parse::<Locality>().unwrap().tiers().is_empty());
1026    }
1027
1028    #[test]
1029    fn locality_rejects_malformed_input() {
1030        assert!(matches!(
1031            "region".parse::<Locality>(),
1032            Err(LocalityParseError::InvalidTier(_))
1033        ));
1034        assert!(matches!(
1035            "region=".parse::<Locality>(),
1036            Err(LocalityParseError::EmptyComponent(_))
1037        ));
1038        assert!(matches!(
1039            "=a".parse::<Locality>(),
1040            Err(LocalityParseError::EmptyComponent(_))
1041        ));
1042        assert!(matches!(
1043            "region=a,region=b".parse::<Locality>(),
1044            Err(LocalityParseError::DuplicateKey(_))
1045        ));
1046    }
1047
1048    #[test]
1049    fn locality_serializes_as_canonical_string() {
1050        let locality: Locality = "region=us-central,zone=a".parse().unwrap();
1051        let json = serde_json::to_string(&locality).unwrap();
1052        assert_eq!(json, "\"region=us-central,zone=a\"");
1053        let back: Locality = serde_json::from_str(&json).unwrap();
1054        assert_eq!(back, locality);
1055    }
1056
1057    #[test]
1058    fn build_version_comes_from_the_build_environment() {
1059        let version = BuildVersion::current();
1060        assert_eq!(version.version, env!("CARGO_PKG_VERSION"));
1061    }
1062
1063    #[test]
1064    fn node_state_serializes_by_stable_variant_name() {
1065        assert_eq!(serde_json::to_string(&NodeState::Up).unwrap(), "\"Up\"");
1066        assert_eq!(
1067            serde_json::to_string(&NodeState::Decommissioned).unwrap(),
1068            "\"Decommissioned\""
1069        );
1070        let back: NodeState = serde_json::from_str("\"Draining\"").unwrap();
1071        assert_eq!(back, NodeState::Draining);
1072    }
1073
1074    #[test]
1075    fn concurrent_first_boots_agree_on_one_identity() {
1076        let dir = tempfile::tempdir().unwrap();
1077        let path = dir.path().to_path_buf();
1078        let barrier = std::sync::Barrier::new(4);
1079        std::thread::scope(|scope| {
1080            let handles: Vec<_> = (0..4)
1081                .map(|_| {
1082                    scope.spawn(|| {
1083                        barrier.wait();
1084                        NodeIdentity::load_or_create(&path, &mut test_csprng())
1085                    })
1086                })
1087                .collect();
1088            let identities: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1089            let first = identities[0].as_ref().unwrap().clone();
1090            for identity in &identities {
1091                assert_eq!(identity.as_ref().unwrap(), &first);
1092            }
1093        });
1094    }
1095
1096    fn version_info(protocol: (u32, u32), log: (u32, u32), snapshot: (u32, u32)) -> VersionInfo {
1097        VersionInfo {
1098            binary_version: "test".to_owned(),
1099            protocol_min: protocol.0,
1100            protocol_max: protocol.1,
1101            log_format_min: log.0,
1102            log_format_max: log.1,
1103            snapshot_format_min: snapshot.0,
1104            snapshot_format_max: snapshot.1,
1105            feature_set: BTreeSet::new(),
1106        }
1107    }
1108
1109    fn sample_descriptor() -> NodeDescriptor {
1110        NodeDescriptor {
1111            node_id: NodeId::new_random(),
1112            rpc_address: "127.0.0.1:7100".to_owned(),
1113            locality: "region=us-central,zone=a".parse().unwrap(),
1114            capacity: NodeCapacity::default(),
1115            state: NodeState::Up,
1116            version: BuildVersion::current(),
1117            version_info: VersionInfo::current(),
1118        }
1119    }
1120
1121    #[test]
1122    fn current_version_info_advertises_a_self_compatible_window() {
1123        let current = VersionInfo::current();
1124        assert_eq!(current.binary_version, env!("CARGO_PKG_VERSION"));
1125        assert!(current.protocol_min <= current.protocol_max);
1126        assert!(current.log_format_min <= current.log_format_max);
1127        assert!(current.snapshot_format_min <= current.snapshot_format_max);
1128        // The declared ranges mirror the format authorities of record.
1129        assert_eq!(
1130            current.log_format_min,
1131            mongreldb_log::envelope::MIN_SUPPORTED_FORMAT_VERSION
1132        );
1133        assert_eq!(
1134            current.log_format_max,
1135            mongreldb_log::envelope::COMMAND_ENVELOPE_FORMAT_VERSION
1136        );
1137        current.is_compatible_with(&current).unwrap();
1138    }
1139
1140    #[test]
1141    fn compatibility_is_range_overlap_in_every_dimension() {
1142        let base = version_info((1, 2), (1, 2), (1, 2));
1143        // Identical and nested ranges overlap.
1144        base.is_compatible_with(&base.clone()).unwrap();
1145        base.is_compatible_with(&version_info((2, 2), (1, 1), (1, 2)))
1146            .unwrap();
1147        // Each dimension is checked; the first mismatch names the range.
1148        let error = base
1149            .is_compatible_with(&version_info((3, 4), (1, 2), (1, 2)))
1150            .unwrap_err();
1151        assert!(matches!(error, Incompatibility::ProtocolVersion { .. }));
1152        let error = base
1153            .is_compatible_with(&version_info((1, 2), (3, 4), (1, 2)))
1154            .unwrap_err();
1155        assert!(matches!(error, Incompatibility::LogFormat { .. }));
1156        let error = base
1157            .is_compatible_with(&version_info((1, 2), (1, 2), (3, 4)))
1158            .unwrap_err();
1159        assert!(matches!(error, Incompatibility::SnapshotFormat { .. }));
1160        // Malformed ranges (min > max) fail closed.
1161        let error = base
1162            .is_compatible_with(&version_info((2, 1), (1, 2), (1, 2)))
1163            .unwrap_err();
1164        assert!(matches!(error, Incompatibility::ProtocolVersion { .. }));
1165    }
1166
1167    #[test]
1168    fn n_and_n_minus_1_interoperate_but_n_plus_1_skew_does_not() {
1169        // Section 17 window: the N binary accepts [N-1, N], the N-1 binary
1170        // accepts [N-1, N-1]; both directions overlap.
1171        let n_minus_1 = version_info((1, 1), (1, 1), (1, 1));
1172        let n = version_info((1, 2), (1, 2), (1, 2));
1173        n.is_compatible_with(&n_minus_1).unwrap();
1174        n_minus_1.is_compatible_with(&n).unwrap();
1175        // A two-version skew does not overlap and fails closed.
1176        let n_plus_1 = version_info((2, 3), (2, 3), (2, 3));
1177        assert!(matches!(
1178            n_minus_1.is_compatible_with(&n_plus_1).unwrap_err(),
1179            Incompatibility::ProtocolVersion { .. }
1180        ));
1181    }
1182
1183    #[test]
1184    fn missing_advertisement_fails_closed_against_a_real_one() {
1185        // A descriptor written before Stage 2H decodes with zeroed ranges,
1186        // which overlap no real advertisement (section 4.10).
1187        let legacy = VersionInfo::default();
1188        let error = legacy
1189            .is_compatible_with(&VersionInfo::current())
1190            .unwrap_err();
1191        assert!(matches!(error, Incompatibility::ProtocolVersion { .. }));
1192        // Two legacy descriptors are mutually compatible: range overlap is
1193        // symmetric and says nothing about unsent fields.
1194        legacy.is_compatible_with(&VersionInfo::default()).unwrap();
1195    }
1196
1197    #[test]
1198    fn descriptor_without_version_info_decodes_with_a_default() {
1199        let descriptor = sample_descriptor();
1200        let mut value = serde_json::to_value(&descriptor).unwrap();
1201        // A Stage 2A descriptor carried no version advertisement.
1202        value.as_object_mut().unwrap().remove("version_info");
1203        let decoded: NodeDescriptor = serde_json::from_value(value).unwrap();
1204        assert_eq!(decoded.version_info, VersionInfo::default());
1205        assert_eq!(decoded.node_id, descriptor.node_id);
1206    }
1207
1208    #[test]
1209    fn descriptor_with_version_info_round_trips() {
1210        let descriptor = sample_descriptor();
1211        let json = serde_json::to_vec(&descriptor).unwrap();
1212        let decoded: NodeDescriptor = serde_json::from_slice(&json).unwrap();
1213        assert_eq!(decoded, descriptor);
1214    }
1215
1216    #[test]
1217    fn descriptor_still_fails_closed_on_unknown_fields() {
1218        let descriptor = sample_descriptor();
1219        let mut value = serde_json::to_value(&descriptor).unwrap();
1220        value["unexpected"] = serde_json::json!(1);
1221        assert!(serde_json::from_value::<NodeDescriptor>(value).is_err());
1222    }
1223}