Skip to main content

ic_query/ic/node_status/
model.rs

1//! Module: ic::node_status::model
2//!
3//! Responsibility: public observed node-status requests, evidence, reports, and errors.
4//! Does not own: source calls, cache IO, projection, or rendering.
5//! Boundary: preserves raw Dashboard status data and explicit off-chain/cache provenance.
6
7use crate::ic::IcDashboardReportProvenance;
8use serde::{Deserialize as SerdeDeserialize, Serialize};
9use std::fmt;
10#[cfg(feature = "host")]
11use std::path::PathBuf;
12use thiserror::Error as ThisError;
13
14///
15/// IcNodeStatusScope
16///
17/// Dashboard collection scope retained by an observed node-status snapshot.
18///
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
21#[serde(rename_all = "snake_case")]
22pub enum IcNodeStatusScope {
23    /// Dashboard's default public-mainnet node set, excluding cloud-engine Type4 nodes.
24    DashboardMainnetDefault,
25}
26
27impl IcNodeStatusScope {
28    /// Return the stable serialized scope label.
29    #[must_use]
30    pub const fn as_str(self) -> &'static str {
31        match self {
32            Self::DashboardMainnetDefault => super::IC_NODE_STATUS_SCOPE,
33        }
34    }
35}
36
37///
38/// IcNodeOperationalStatus
39///
40/// Known Dashboard node-status classification used for filtering and counts.
41///
42
43#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
44#[serde(rename_all = "snake_case")]
45pub enum IcNodeOperationalStatus {
46    /// The Dashboard reports the node as up.
47    Up,
48    /// The Dashboard reports the node as down.
49    Down,
50    /// The Dashboard reports the node as administratively disabled.
51    Disabled,
52    /// The Dashboard reports degraded operation.
53    Degraded,
54    /// The raw Dashboard value is not recognized by this release.
55    Unknown,
56}
57
58impl IcNodeOperationalStatus {
59    /// Classify raw Dashboard status text without discarding unknown future values.
60    #[must_use]
61    pub fn from_raw(raw: &str) -> Self {
62        match raw {
63            "UP" => Self::Up,
64            "DOWN" => Self::Down,
65            "DISABLED" => Self::Disabled,
66            "DEGRADED" => Self::Degraded,
67            _ => Self::Unknown,
68        }
69    }
70
71    /// Return the stable lowercase display label.
72    #[must_use]
73    pub const fn as_str(self) -> &'static str {
74        match self {
75            Self::Up => "up",
76            Self::Down => "down",
77            Self::Disabled => "disabled",
78            Self::Degraded => "degraded",
79            Self::Unknown => "unknown",
80        }
81    }
82}
83
84impl fmt::Display for IcNodeOperationalStatus {
85    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
86        formatter.write_str(self.as_str())
87    }
88}
89
90///
91/// IcNodeStatusCounts
92///
93/// Exact status totals derived from raw observed node rows.
94///
95
96#[derive(Clone, Debug, Default, Eq, PartialEq, SerdeDeserialize, Serialize)]
97pub struct IcNodeStatusCounts {
98    /// Total observed rows in the group.
99    pub total: usize,
100    /// Rows classified as `UP`.
101    pub up: usize,
102    /// Rows classified as `DOWN`.
103    pub down: usize,
104    /// Rows classified as `DISABLED`.
105    pub disabled: usize,
106    /// Rows classified as `DEGRADED`.
107    pub degraded: usize,
108    /// Rows whose raw status is not recognized.
109    pub unknown: usize,
110}
111
112impl IcNodeStatusCounts {
113    /// Return every row not classified as raw Dashboard `UP`.
114    #[must_use]
115    pub const fn non_up(&self) -> usize {
116        self.total.saturating_sub(self.up)
117    }
118}
119
120///
121/// IcNodeAssignmentStatusCounts
122///
123/// Operational-status totals partitioned by mutually exclusive assignment class.
124///
125
126#[derive(Clone, Debug, Default, Eq, PartialEq, SerdeDeserialize, Serialize)]
127pub struct IcNodeAssignmentStatusCounts {
128    /// Status totals for nodes with an observed assigned Subnet.
129    pub assigned: IcNodeStatusCounts,
130    /// Status totals for rows whose raw node type is `UNASSIGNED`.
131    pub unassigned: IcNodeStatusCounts,
132    /// Status totals for rows whose raw node type is `API_BOUNDARY`.
133    pub api_boundary: IcNodeStatusCounts,
134    /// Status totals for rows without a known assignment class.
135    pub unknown: IcNodeStatusCounts,
136}
137
138///
139/// IcNodeStatusGroupCounts
140///
141/// Overall and assignment-partitioned status totals for one aggregate group.
142///
143
144#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
145pub struct IcNodeStatusGroupCounts {
146    /// Raw operational-status totals for the group.
147    pub statuses: IcNodeStatusCounts,
148    /// Raw operational-status totals partitioned by assignment class.
149    pub assignment_statuses: IcNodeAssignmentStatusCounts,
150}
151
152///
153/// IcNodeStatusRow
154///
155/// One raw node observation retained from the official Dashboard node resource.
156///
157
158#[derive(Clone, Debug, Eq, PartialEq, SerdeDeserialize, Serialize)]
159pub struct IcNodeStatusRow {
160    /// Canonical node principal.
161    pub node_id: String,
162    /// Canonical node-operator principal.
163    pub node_operator_id: String,
164    /// Canonical node-provider principal.
165    pub node_provider_id: String,
166    /// Off-chain Dashboard node-provider name.
167    pub node_provider_name: String,
168    /// Raw Dashboard node type.
169    pub node_type: String,
170    /// Raw Registry reward type reported by the Dashboard.
171    pub node_reward_type: String,
172    /// Raw Dashboard operational status.
173    pub status: String,
174    /// Raw Dashboard alert name when present.
175    pub alert_name: Option<String>,
176    /// Assigned Subnet principal when present.
177    pub subnet_id: Option<String>,
178    /// Cloud-engine Subnet principal when present in a future compatible scope.
179    pub cloud_engine_subnet_id: Option<String>,
180    /// Dashboard data-center identifier.
181    pub data_center_id: String,
182    /// Dashboard data-center name.
183    pub data_center_name: String,
184    /// Dashboard infrastructure owner label.
185    pub owner: String,
186    /// Raw Dashboard geographic region label.
187    pub region: String,
188    /// Observed GuestOS version when reported.
189    pub guestos_version: Option<String>,
190    /// Observed GuestOS trusted-execution status when reported.
191    pub guestos_tee_active: Option<bool>,
192    /// Observed node IP address when reported.
193    pub ip_address: Option<String>,
194    /// Observed IPv4-connectivity state when reported.
195    pub ipv4_connectivity_status: Option<bool>,
196    /// Dashboard hardware-generation label when reported.
197    pub node_hardware_generation: Option<String>,
198}
199
200impl IcNodeStatusRow {
201    /// Return the known classification of this row's raw status.
202    #[must_use]
203    pub fn operational_status(&self) -> IcNodeOperationalStatus {
204        IcNodeOperationalStatus::from_raw(&self.status)
205    }
206
207    /// Return whether the raw row is anything other than known `UP`.
208    #[must_use]
209    pub fn is_non_up(&self) -> bool {
210        self.operational_status() != IcNodeOperationalStatus::Up
211    }
212}
213
214///
215/// IcNodeStatusCacheEvidence
216///
217/// Caller-relative local cache evidence attached to a projected status report.
218///
219
220#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
221pub struct IcNodeStatusCacheEvidence {
222    /// Canonical local cache path used by the report.
223    pub cache_path: String,
224    /// Whether the cache is within the default age policy for this caller's time.
225    pub cache_fresh: bool,
226    /// Cache age at report construction.
227    pub age_seconds: u64,
228    /// Age threshold used to classify freshness.
229    pub stale_after_seconds: u64,
230}
231
232///
233/// IcNodeStatusObservation
234///
235/// Shared source, scope, and optional cache evidence for status reports.
236///
237
238#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
239pub struct IcNodeStatusObservation {
240    #[serde(flatten)]
241    /// Official Dashboard source provenance.
242    pub source: IcDashboardReportProvenance,
243    /// Explicit node collection scope.
244    pub scope: IcNodeStatusScope,
245    /// Whether cloud-engine nodes were included in the source collection.
246    pub cloud_engine_nodes_included: bool,
247    /// Local cache evidence, absent for a pure live snapshot.
248    pub cache: Option<IcNodeStatusCacheEvidence>,
249}
250
251///
252/// IcNodeStatusSnapshot
253///
254/// Complete canonical observed node snapshot used by every status projection.
255///
256
257#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
258pub struct IcNodeStatusSnapshot {
259    #[serde(flatten)]
260    /// Shared source and scope evidence.
261    pub observation: IcNodeStatusObservation,
262    /// Exact number of raw node rows.
263    pub node_count: usize,
264    /// Snapshot-wide overall and assignment-partitioned totals.
265    pub counts: IcNodeStatusGroupCounts,
266    /// Canonically ordered raw node rows.
267    pub nodes: Vec<IcNodeStatusRow>,
268}
269
270///
271/// IcNodeStatusSnapshotRequest
272///
273/// Request for one live finite Dashboard node-status snapshot.
274///
275
276#[derive(Clone, Debug, Eq, PartialEq)]
277pub struct IcNodeStatusSnapshotRequest {
278    /// Dashboard API v3 base endpoint.
279    pub source_endpoint: String,
280    /// Caller-supplied collection timestamp as Unix seconds.
281    pub now_unix_secs: u64,
282}
283
284impl IcNodeStatusSnapshotRequest {
285    /// Construct one live node-status snapshot request.
286    #[must_use]
287    pub fn new(source_endpoint: impl Into<String>, now_unix_secs: u64) -> Self {
288        Self {
289            source_endpoint: source_endpoint.into(),
290            now_unix_secs,
291        }
292    }
293}
294
295///
296/// IcNodeStatusSourceData
297///
298/// Untrusted raw node rows and provenance returned by a Dashboard source capability.
299///
300
301#[cfg(feature = "host")]
302#[derive(Clone, Debug, Eq, PartialEq)]
303pub struct IcNodeStatusSourceData {
304    /// Source-call provenance echoed by the source.
305    pub source: crate::ic::IcSourceRequest,
306    /// Explicit Dashboard collection scope.
307    pub scope: IcNodeStatusScope,
308    /// Whether the source claims cloud-engine node inclusion.
309    pub cloud_engine_nodes_included: bool,
310    /// Raw observed node rows.
311    pub nodes: Vec<IcNodeStatusRow>,
312}
313
314///
315/// IcNodeStatusView
316///
317/// Target and attention selection shared by node, Subnet, and provider views.
318///
319
320#[derive(Clone, Debug, Default, Eq, PartialEq)]
321pub struct IcNodeStatusView {
322    /// Optional exact identifier or unique principal prefix.
323    pub target: Option<String>,
324    /// Whether fully-up rows or groups are included without a target.
325    pub include_all: bool,
326}
327
328impl IcNodeStatusView {
329    /// Construct the default attention-only view.
330    #[must_use]
331    pub const fn attention() -> Self {
332        Self {
333            target: None,
334            include_all: false,
335        }
336    }
337
338    /// Select one exact identifier or unique principal prefix.
339    #[must_use]
340    pub fn with_target(mut self, target: impl Into<String>) -> Self {
341        self.target = Some(target.into());
342        self
343    }
344
345    /// Include fully-up rows as well as attention rows.
346    #[must_use]
347    pub const fn with_all(mut self, include_all: bool) -> Self {
348        self.include_all = include_all;
349        self
350    }
351}
352
353///
354/// IcNodeStatusReport
355///
356/// Node-level view over one complete observed status snapshot.
357///
358
359#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
360pub struct IcNodeStatusReport {
361    #[serde(flatten)]
362    /// Shared source, scope, and cache evidence.
363    pub observation: IcNodeStatusObservation,
364    /// Total rows in the complete source snapshot.
365    pub snapshot_node_count: usize,
366    /// Snapshot-wide overall and assignment-partitioned totals.
367    pub counts: IcNodeStatusGroupCounts,
368    /// Whether the view includes fully-up rows.
369    pub include_all: bool,
370    /// Target text supplied by the caller.
371    pub requested_target: Option<String>,
372    /// Canonical target resolved by exact or unique-prefix matching.
373    pub resolved_target: Option<String>,
374    /// Stable label describing how the target resolved.
375    pub resolved_from: Option<String>,
376    /// Number of returned node rows.
377    pub returned_node_count: usize,
378    /// Selected canonical node rows.
379    pub nodes: Vec<IcNodeStatusRow>,
380}
381
382///
383/// IcSubnetStatusRow
384///
385/// Observed operational counts and conservative threshold evidence for one Subnet.
386///
387
388#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
389pub struct IcSubnetStatusRow {
390    /// Canonical Subnet principal.
391    pub subnet_id: String,
392    /// Operational-status totals for observed assigned nodes.
393    pub statuses: IcNodeStatusCounts,
394    /// Byzantine fault count `floor((n - 1) / 3)` for the observed membership size.
395    pub fault_tolerance_node_count: usize,
396    /// Additional down nodes required for the down count to exceed the derived threshold.
397    pub additional_down_nodes_to_exceed_fault_tolerance: usize,
398    /// Additional non-up nodes required for the conservative count to exceed the threshold.
399    pub additional_non_up_nodes_to_exceed_fault_tolerance: usize,
400    /// Whether the observed down count already exceeds the derived threshold.
401    pub down_fault_tolerance_exceeded: bool,
402    /// Whether the observed conservative non-up count already exceeds the threshold.
403    pub conservative_non_up_fault_tolerance_exceeded: bool,
404    /// Raw non-up node evidence in canonical node order.
405    pub non_up_nodes: Vec<IcNodeStatusRow>,
406}
407
408///
409/// IcSubnetStatusReport
410///
411/// Subnet-level operational view over one complete observed node snapshot.
412///
413
414#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
415pub struct IcSubnetStatusReport {
416    #[serde(flatten)]
417    /// Shared source, scope, and cache evidence.
418    pub observation: IcNodeStatusObservation,
419    /// Total rows in the complete source snapshot.
420    pub snapshot_node_count: usize,
421    /// Snapshot rows with an assigned Subnet.
422    pub assigned_node_count: usize,
423    /// Number of observed Subnets before view filtering.
424    pub subnet_count: usize,
425    /// Number of observed Subnets containing a non-up node.
426    pub attention_subnet_count: usize,
427    /// Whether fully-up Subnets are included without a target.
428    pub include_all: bool,
429    /// Target text supplied by the caller.
430    pub requested_target: Option<String>,
431    /// Canonical Subnet target resolved by exact or unique-prefix matching.
432    pub resolved_target: Option<String>,
433    /// Stable label describing how the target resolved.
434    pub resolved_from: Option<String>,
435    /// Number of returned Subnet rows.
436    pub returned_subnet_count: usize,
437    /// Canonically ordered selected Subnet rows.
438    pub subnets: Vec<IcSubnetStatusRow>,
439}
440
441///
442/// IcNodeProviderStatusRow
443///
444/// Assignment and raw operational counts for one observed node provider.
445///
446
447#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
448pub struct IcNodeProviderStatusRow {
449    /// Canonical node-provider principal.
450    pub node_provider_id: String,
451    /// Off-chain Dashboard provider name.
452    pub node_provider_name: String,
453    /// Operational-status and assignment totals for the provider.
454    pub counts: IcNodeStatusGroupCounts,
455    /// Raw non-up node evidence in canonical node order.
456    pub non_up_nodes: Vec<IcNodeStatusRow>,
457}
458
459///
460/// IcNodeProviderStatusReport
461///
462/// Node-provider operational view over one complete observed node snapshot.
463///
464
465#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
466pub struct IcNodeProviderStatusReport {
467    #[serde(flatten)]
468    /// Shared source, scope, and cache evidence.
469    pub observation: IcNodeStatusObservation,
470    /// Total rows in the complete source snapshot.
471    pub snapshot_node_count: usize,
472    /// Number of observed provider principals before view filtering.
473    pub provider_count: usize,
474    /// Number of provider groups containing a non-up node.
475    pub attention_provider_count: usize,
476    /// Whether fully-up providers are included without a target.
477    pub include_all: bool,
478    /// Target text supplied by the caller.
479    pub requested_target: Option<String>,
480    /// Canonical provider target resolved by exact or unique-prefix matching.
481    pub resolved_target: Option<String>,
482    /// Stable label describing how the target resolved.
483    pub resolved_from: Option<String>,
484    /// Number of returned provider rows.
485    pub returned_provider_count: usize,
486    /// Canonically ordered selected provider rows.
487    pub providers: Vec<IcNodeProviderStatusRow>,
488}
489
490///
491/// IcNodeStatusProjectionError
492///
493/// Failure to resolve or project a requested observed status view.
494///
495
496#[derive(Clone, Debug, Eq, PartialEq, ThisError)]
497pub enum IcNodeStatusProjectionError {
498    /// Snapshot-level counts or canonical ordering do not match raw rows.
499    #[error("invalid observed node-status snapshot: {reason}")]
500    InvalidSnapshot {
501        /// Deterministic validation failure.
502        reason: String,
503    },
504    /// The supplied target is empty after trimming.
505    #[error("{kind} target must not be empty")]
506    EmptyTarget {
507        /// Projection kind being selected.
508        kind: &'static str,
509    },
510    /// No observed identifier matches the supplied target.
511    #[error("{kind} target {target:?} did not match the observed snapshot")]
512    UnknownTarget {
513        /// Projection kind being selected.
514        kind: &'static str,
515        /// Unmatched target text.
516        target: String,
517    },
518    /// More than one observed identifier matches the supplied prefix.
519    #[error("{kind} prefix {prefix:?} is ambiguous; matches: {matches:?}")]
520    AmbiguousTarget {
521        /// Projection kind being selected.
522        kind: &'static str,
523        /// Ambiguous principal prefix.
524        prefix: String,
525        /// Canonically ordered matching principals.
526        matches: Vec<String>,
527    },
528}
529
530///
531/// IcNodeStatusCacheRequest
532///
533/// Stable identity of one network-level observed node-status cache.
534///
535
536#[cfg(feature = "host")]
537#[derive(Clone, Debug, Eq, PartialEq)]
538pub struct IcNodeStatusCacheRequest {
539    /// Root directory containing all `ic-query` caches.
540    pub cache_root: PathBuf,
541    /// Requested network name.
542    pub network: String,
543}
544
545#[cfg(feature = "host")]
546impl IcNodeStatusCacheRequest {
547    /// Construct one observed node-status cache identity.
548    #[must_use]
549    pub fn new(cache_root: impl Into<PathBuf>, network: impl Into<String>) -> Self {
550        Self {
551            cache_root: cache_root.into(),
552            network: network.into(),
553        }
554    }
555}
556
557///
558/// IcNodeStatusRefreshRequest
559///
560/// Settings for one forced observed node-status snapshot refresh.
561///
562
563#[cfg(feature = "host")]
564#[derive(Clone, Debug, Eq, PartialEq)]
565pub struct IcNodeStatusRefreshRequest {
566    /// Stable cache identity.
567    pub cache: IcNodeStatusCacheRequest,
568    /// Explicit Dashboard API base endpoint.
569    pub source_endpoint: String,
570    /// Caller-supplied observation time as Unix seconds.
571    pub now_unix_secs: u64,
572    /// Age after which an abandoned refresh lock can be reclaimed.
573    pub lock_stale_after_seconds: u64,
574}
575
576#[cfg(feature = "host")]
577impl IcNodeStatusRefreshRequest {
578    /// Construct one forced node-status snapshot refresh request.
579    #[must_use]
580    pub fn new(
581        cache_root: impl Into<PathBuf>,
582        network: impl Into<String>,
583        source_endpoint: impl Into<String>,
584        now_unix_secs: u64,
585        lock_stale_after_seconds: u64,
586    ) -> Self {
587        Self {
588            cache: IcNodeStatusCacheRequest::new(cache_root, network),
589            source_endpoint: source_endpoint.into(),
590            now_unix_secs,
591            lock_stale_after_seconds,
592        }
593    }
594}
595
596///
597/// IcNodeStatusReadRequest
598///
599/// Cache-backed status-view request shared by node, Subnet, and provider reports.
600///
601
602#[cfg(feature = "host")]
603#[derive(Clone, Debug, Eq, PartialEq)]
604pub struct IcNodeStatusReadRequest {
605    /// Live and cache settings used when refresh is required.
606    pub refresh: IcNodeStatusRefreshRequest,
607    /// Projection target and attention selection.
608    pub view: IcNodeStatusView,
609    /// Whether to force a live replacement before reading.
610    pub force_refresh: bool,
611}
612
613#[cfg(feature = "host")]
614impl IcNodeStatusReadRequest {
615    /// Construct one stale-refresh status-view request.
616    #[must_use]
617    pub fn new(
618        cache_root: impl Into<PathBuf>,
619        network: impl Into<String>,
620        source_endpoint: impl Into<String>,
621        now_unix_secs: u64,
622    ) -> Self {
623        Self {
624            refresh: IcNodeStatusRefreshRequest::new(
625                cache_root,
626                network,
627                source_endpoint,
628                now_unix_secs,
629                super::DEFAULT_IC_NODE_STATUS_REFRESH_LOCK_STALE_SECONDS,
630            ),
631            view: IcNodeStatusView::attention(),
632            force_refresh: false,
633        }
634    }
635
636    /// Apply one target and attention selection.
637    #[must_use]
638    pub fn with_view(mut self, view: IcNodeStatusView) -> Self {
639        self.view = view;
640        self
641    }
642
643    /// Select a forced refresh before projecting the report.
644    #[must_use]
645    pub const fn with_force_refresh(mut self, force_refresh: bool) -> Self {
646        self.force_refresh = force_refresh;
647        self
648    }
649}
650
651///
652/// IcNodeStatusRefreshReport
653///
654/// Result of atomically replacing the complete observed node-status cache.
655///
656
657#[cfg(feature = "host")]
658#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
659pub struct IcNodeStatusRefreshReport {
660    /// Cache/report schema version.
661    pub schema_version: u32,
662    /// Network identity attached to the replacement.
663    pub network: String,
664    /// Explicit Dashboard endpoint queried.
665    pub source_endpoint: String,
666    /// Canonical UTC collection timestamp.
667    pub fetched_at: String,
668    /// Collector identity.
669    pub fetched_by: String,
670    /// Published cache path.
671    pub cache_path: String,
672    /// Sibling refresh-lock path.
673    pub refresh_lock_path: String,
674    /// Whether an existing complete snapshot was replaced.
675    pub replaced_existing_cache: bool,
676    /// Number of published node rows.
677    pub node_count: usize,
678    /// Published overall and assignment-partitioned status totals.
679    pub counts: IcNodeStatusGroupCounts,
680}
681
682///
683/// IcNodeStatusHostError
684///
685/// Host source, cache, and projection failures for observed node-status reports.
686///
687
688#[cfg(feature = "host")]
689#[derive(Debug, ThisError)]
690pub enum IcNodeStatusHostError {
691    /// The observed Dashboard source supports only public mainnet identity.
692    #[error("observed IC node status supports only the mainnet `ic` network, not {network:?}")]
693    UnsupportedNetwork {
694        /// Unsupported requested network.
695        network: String,
696    },
697    /// Live-source or source-validation failure.
698    #[error(transparent)]
699    Source(#[from] crate::ic::IcHostError),
700    /// Pure projection failure.
701    #[error(transparent)]
702    Projection(#[from] IcNodeStatusProjectionError),
703    /// Strict cache load found no complete snapshot.
704    #[error("observed node-status cache is missing at {}", path.display())]
705    MissingCache {
706        /// Expected cache path.
707        path: PathBuf,
708    },
709    /// Existing cache content could not be read.
710    #[error("failed to read observed node-status cache at {}: {source}", path.display())]
711    ReadCache {
712        /// Cache path that failed.
713        path: PathBuf,
714        /// Underlying filesystem failure.
715        source: std::io::Error,
716    },
717    /// Existing cache content was not valid JSON for the schema.
718    #[error("failed to parse observed node-status cache at {}: {source}", path.display())]
719    ParseCache {
720        /// Cache path that failed.
721        path: PathBuf,
722        /// Underlying JSON failure.
723        source: serde_json::Error,
724    },
725    /// Existing cache uses an unsupported schema version.
726    #[error("observed node-status cache schema {version} is unsupported; expected {expected}")]
727    UnsupportedCacheSchemaVersion {
728        /// Observed schema version.
729        version: u32,
730        /// Required current schema version.
731        expected: u32,
732    },
733    /// Existing cache belongs to another network.
734    #[error("observed node-status cache network is {actual:?}, expected {requested:?}")]
735    CacheNetworkMismatch {
736        /// Requested network.
737        requested: String,
738        /// Network stored by the cache.
739        actual: String,
740    },
741    /// Existing cache snapshot-key identity does not match its path.
742    #[error(
743        "observed node-status cache identity mismatch at {}: {field} is {actual:?}, expected {expected:?}",
744        path.display()
745    )]
746    CacheIdentityMismatch {
747        /// Cache path that failed validation.
748        path: PathBuf,
749        /// Identity field that differs.
750        field: &'static str,
751        /// Required identity value.
752        expected: String,
753        /// Stored identity value.
754        actual: String,
755    },
756    /// Existing cache is structurally readable but semantically invalid.
757    #[error("invalid observed node-status cache at {}: {reason}", path.display())]
758    InvalidCache {
759        /// Cache path that failed validation.
760        path: PathBuf,
761        /// Deterministic validation failure.
762        reason: String,
763    },
764    /// A complete replacement could not be serialized.
765    #[error("failed to serialize observed node-status cache at {}: {source}", path.display())]
766    SerializeCache {
767        /// Target cache path.
768        path: PathBuf,
769        /// Underlying JSON serialization failure.
770        source: serde_json::Error,
771    },
772    /// Shared atomic cache or refresh-lock operation failed.
773    #[error(transparent)]
774    Cache(#[from] crate::cache_file::HostCacheError),
775}