Skip to main content

rch_common/
bypass_record.rs

1//! Temporary-bypass record schema and persistent store
2//! (bd-session-history-remediation-ocv9i.1.2).
3//!
4//! When a worker hits a *transient* failure (SSH flake, disk pressure, a missing
5//! toolchain, stale telemetry, circuit churn, …) the recovery path quarantines
6//! it on the live eligibility axis ([`crate`] consumers call this a
7//! "temporary bypass") instead of mutating operator-desired inventory. This
8//! module owns the durable record of that quarantine.
9//!
10//! A [`BypassRecord`] is the persisted, status-surfaceable description of one
11//! bypassed worker: who it is (id/host/user), why it was bypassed (the precise
12//! [`BypassFailureClass`] plus a stable [`IncidentReasonCode`] for ledger
13//! correlation), the failure/probe bookkeeping (first/last failure, next probe,
14//! backoff, consecutive pass/fail counts), the last diagnostic summary, the
15//! auto-rejoin criteria, and whether local fallback was allowed for affected
16//! commands. It feeds `rch status`, `rch workers list --json`, incident-ledger
17//! events, and future admission explanations.
18//!
19//! The [`BypassRecordStore`] is a single-document JSON map keyed by worker id
20//! (one current record per worker), persisted atomically (temp-file + rename)
21//! and corruption-tolerant on load. Unlike the append-only
22//! [`crate::incident_ledger`] (an *event* log), the bypass store holds *current
23//! state*: a record is removed when its worker fully rejoins.
24//!
25//! ## Migration
26//!
27//! Historically, agents reacted to transient worker illness by *admin-disabling*
28//! the worker, which leaves the fleet permanently smaller after recovery. The
29//! [`migrate_disabled_worker`] strategy inspects a disabled worker's reason
30//! string: when it looks transient ([`classify_disable_reason`] matches a
31//! failure class) it produces a [`BypassRecord`] so the worker can auto-rejoin;
32//! otherwise the worker is kept disabled (a genuine or unknown operator intent
33//! must never be silently auto-undone).
34//!
35//! ## Privacy
36//!
37//! The record carries a bounded `last_diagnostic` summary and a small `details`
38//! map; it never stores raw command lines or secrets. `last_diagnostic` is
39//! truncated to [`MAX_DIAGNOSTIC_CHARS`].
40
41use std::collections::BTreeMap;
42use std::fs::{self, OpenOptions};
43use std::io::Write;
44use std::path::{Path, PathBuf};
45
46use schemars::generate::SchemaSettings;
47use schemars::{JsonSchema, Schema};
48use serde::{Deserialize, Serialize};
49
50use crate::incident::IncidentReasonCode;
51use crate::schema_versions::{SchemaComponent, current_version};
52
53/// Maximum retained length (in characters) of a record's diagnostic summary.
54/// Keeps records compact and avoids accidentally persisting large command
55/// output into the state file.
56pub const MAX_DIAGNOSTIC_CHARS: usize = 512;
57
58/// Concrete failure class that quarantined a worker into a temporary bypass.
59///
60/// Mirrors the raw session-history failure classes enumerated in the program
61/// validation contract (`docs/guides/session-history-remediation-validation.md`)
62/// so incident/status output can attribute a bypass to a specific root cause.
63///
64/// Serialized as `snake_case`. New variants must be **appended**, never
65/// reordered or renamed, so historical state/JSONL replays continue to
66/// deserialize (this enum crosses the daemon/status JSON boundary and is stored
67/// on disk).
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
69#[serde(rename_all = "snake_case")]
70pub enum BypassFailureClass {
71    /// SSH connection/auth failure to the worker.
72    Ssh,
73    /// `rch-wkr` missing, wrong path, or wrong user on the worker.
74    WorkerBinary,
75    /// Required runtime/toolchain/Rust target absent on the worker.
76    RuntimeToolchain,
77    /// Disk or inode pressure on the worker.
78    DiskInodePressure,
79    /// Worker telemetry is stale or its age is unknown.
80    StaleTelemetry,
81    /// Path/source sync (rsync) failure to the worker.
82    PathSync,
83    /// Artifact retrieval from the worker failed.
84    ArtifactRetrieval,
85    /// Worker's circuit breaker is open.
86    CircuitBreaker,
87    /// Worker OS/architecture does not match the build target.
88    OsArchMismatch,
89}
90
91impl BypassFailureClass {
92    /// Every failure class, in stable declaration order. Used for exhaustive
93    /// attribution tests and enumeration.
94    pub const ALL: &'static [BypassFailureClass] = &[
95        Self::Ssh,
96        Self::WorkerBinary,
97        Self::RuntimeToolchain,
98        Self::DiskInodePressure,
99        Self::StaleTelemetry,
100        Self::PathSync,
101        Self::ArtifactRetrieval,
102        Self::CircuitBreaker,
103        Self::OsArchMismatch,
104    ];
105
106    /// Stable `snake_case` wire identifier (matches the serde form).
107    #[must_use]
108    pub const fn as_str(self) -> &'static str {
109        match self {
110            Self::Ssh => "ssh",
111            Self::WorkerBinary => "worker_binary",
112            Self::RuntimeToolchain => "runtime_toolchain",
113            Self::DiskInodePressure => "disk_inode_pressure",
114            Self::StaleTelemetry => "stale_telemetry",
115            Self::PathSync => "path_sync",
116            Self::ArtifactRetrieval => "artifact_retrieval",
117            Self::CircuitBreaker => "circuit_breaker",
118            Self::OsArchMismatch => "os_arch_mismatch",
119        }
120    }
121
122    /// Operator-facing label for status surfaces.
123    #[must_use]
124    pub const fn label(self) -> &'static str {
125        match self {
126            Self::Ssh => "SSH connection failure",
127            Self::WorkerBinary => "worker binary missing/wrong path or user",
128            Self::RuntimeToolchain => "missing runtime/toolchain/target",
129            Self::DiskInodePressure => "disk/inode pressure",
130            Self::StaleTelemetry => "telemetry stale/age unknown",
131            Self::PathSync => "path/source sync failure",
132            Self::ArtifactRetrieval => "artifact retrieval failure",
133            Self::CircuitBreaker => "circuit breaker open",
134            Self::OsArchMismatch => "OS/arch mismatch",
135        }
136    }
137
138    /// Stable [`IncidentReasonCode`] this failure class maps to, so a bypass can
139    /// feed the incident ledger with the shared reason-code vocabulary.
140    ///
141    /// The mapping is total and reuses the existing registry rather than
142    /// inventing a parallel one. `Ssh` (a worker the daemon cannot even reach)
143    /// maps to [`IncidentReasonCode::HardPreflight`] — an unreachable candidate
144    /// hard-fails the very first preflight check.
145    #[must_use]
146    pub const fn incident_reason_code(self) -> IncidentReasonCode {
147        match self {
148            Self::Ssh => IncidentReasonCode::HardPreflight,
149            Self::WorkerBinary => IncidentReasonCode::WrongUserPathWorkerBinary,
150            Self::RuntimeToolchain => IncidentReasonCode::MissingRuntimeToolchainTarget,
151            Self::DiskInodePressure => IncidentReasonCode::DiskFull,
152            Self::StaleTelemetry => IncidentReasonCode::TelemetryStale,
153            Self::PathSync => IncidentReasonCode::RsyncVanishedFile,
154            Self::ArtifactRetrieval => IncidentReasonCode::ArtifactMiss,
155            Self::CircuitBreaker => IncidentReasonCode::CircuitOpen,
156            Self::OsArchMismatch => IncidentReasonCode::OsArchMismatch,
157        }
158    }
159
160    /// Parse a `snake_case` wire identifier back to a failure class.
161    #[must_use]
162    pub fn from_str_opt(s: &str) -> Option<Self> {
163        Self::ALL.iter().copied().find(|c| c.as_str() == s)
164    }
165}
166
167/// The quarantine state a bypassed worker is currently in. Mirrors the two
168/// transient eligibility states a record can describe: a worker with a live
169/// bypass record is either still bypassed or has passed recovery and awaits a
170/// canary build before full rejoin.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
172#[serde(rename_all = "snake_case")]
173pub enum BypassState {
174    /// Quarantined out of scheduling because of [`BypassRecord::failure_class`].
175    TemporaryBypass,
176    /// Passed its recovery probe(s); awaiting one canary build before rejoin.
177    RecoveredPendingCanary,
178}
179
180impl BypassState {
181    /// Human label for status surfaces.
182    #[must_use]
183    pub const fn label(self) -> &'static str {
184        match self {
185            Self::TemporaryBypass => "temporary bypass",
186            Self::RecoveredPendingCanary => "recovered, pending canary",
187        }
188    }
189}
190
191/// Exponential backoff window for recovery probing of a bypassed worker.
192///
193/// The record carries this state so the probe/canary loop (bead 1.3) can
194/// schedule the next probe deterministically; [`BypassBackoff::advanced`] is the
195/// pure doubling step used when a probe fails again.
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
197pub struct BypassBackoff {
198    /// Current backoff window in milliseconds (gap before the next probe).
199    pub current_ms: u64,
200    /// Number of probe attempts scheduled since the bypass began.
201    pub attempts: u32,
202    /// Ceiling for the backoff window in milliseconds.
203    pub max_ms: u64,
204}
205
206impl BypassBackoff {
207    /// Default initial backoff window (30s).
208    pub const DEFAULT_INITIAL_MS: u64 = 30_000;
209    /// Default backoff ceiling (15min).
210    pub const DEFAULT_MAX_MS: u64 = 900_000;
211
212    /// A fresh backoff: the default initial window, zero attempts.
213    #[must_use]
214    pub const fn initial() -> Self {
215        Self {
216            current_ms: Self::DEFAULT_INITIAL_MS,
217            attempts: 0,
218            max_ms: Self::DEFAULT_MAX_MS,
219        }
220    }
221
222    /// The next backoff after another failed probe: double the window (capped at
223    /// `max_ms`) and increment the attempt count.
224    #[must_use]
225    pub const fn advanced(self) -> Self {
226        let doubled = self.current_ms.saturating_mul(2);
227        let current_ms = if doubled > self.max_ms {
228            self.max_ms
229        } else {
230            doubled
231        };
232        Self {
233            current_ms,
234            attempts: self.attempts.saturating_add(1),
235            max_ms: self.max_ms,
236        }
237    }
238}
239
240impl Default for BypassBackoff {
241    fn default() -> Self {
242        Self::initial()
243    }
244}
245
246/// The criteria a bypassed worker must satisfy before auto-rejoining.
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
248pub struct AutoRejoinCriteria {
249    /// Consecutive successful recovery probes required before advancing.
250    pub required_consecutive_passes: u32,
251    /// Whether one canary build is required before full rejoin.
252    pub canary_required: bool,
253}
254
255impl Default for AutoRejoinCriteria {
256    fn default() -> Self {
257        Self {
258            required_consecutive_passes: 2,
259            canary_required: true,
260        }
261    }
262}
263
264/// A durable record of one temporarily-bypassed worker.
265///
266/// This is the persisted, status-surfaceable description of a transient
267/// quarantine. It is *not* operator inventory: an entry here never implies the
268/// operator disabled the worker, and clearing it lets the worker auto-rejoin.
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
270pub struct BypassRecord {
271    /// Schema version (`SchemaComponent::WorkerBypassRecord`).
272    pub schema_version: String,
273    /// Worker id (the store's key).
274    pub worker_id: String,
275    /// Worker host (for status display and exact-path validation).
276    pub host: String,
277    /// Worker SSH user (for status display and exact-path validation).
278    pub user: String,
279    /// Precise worker-lifecycle failure class that caused the bypass.
280    pub failure_class: BypassFailureClass,
281    /// Stable incident reason code for ledger correlation (serializes as
282    /// `RCH-Innn`). Derived from [`Self::failure_class`] at construction.
283    #[schemars(with = "String")]
284    pub reason_code: IncidentReasonCode,
285    /// Current quarantine state.
286    pub state: BypassState,
287    /// Unix epoch milliseconds of the first failure that began this bypass.
288    pub first_failure_unix_ms: u64,
289    /// Unix epoch milliseconds of the most recent failure.
290    pub last_failure_unix_ms: u64,
291    /// Unix epoch milliseconds when the next recovery probe is due.
292    pub next_probe_unix_ms: u64,
293    /// Exponential backoff state for recovery probing.
294    pub backoff: BypassBackoff,
295    /// Consecutive failed checks observed for this worker.
296    pub consecutive_failures: u32,
297    /// Consecutive successful recovery probes observed for this worker.
298    pub consecutive_passes: u32,
299    /// Short, bounded diagnostic summary (never a raw command or secret).
300    pub last_diagnostic: String,
301    /// Criteria the worker must meet before auto-rejoining.
302    pub auto_rejoin: AutoRejoinCriteria,
303    /// Whether local fallback was allowed for commands affected by this bypass.
304    pub local_fallback_allowed: bool,
305    /// Remote source/output roots from confirmed disk failures. Retained
306    /// through canary and restart so recovery cannot inspect another volume.
307    #[serde(default, skip_serializing_if = "Vec::is_empty")]
308    pub disk_roots: Vec<String>,
309    /// Compact, ordered free-form details (small, bounded; not for secrets).
310    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
311    pub details: BTreeMap<String, String>,
312}
313
314impl BypassRecord {
315    /// Begin a bypass: a worker just hit `failure_class` at
316    /// `first_failure_unix_ms`. The first recovery probe is scheduled one
317    /// initial-backoff window later.
318    #[must_use]
319    pub fn new(
320        worker_id: impl Into<String>,
321        host: impl Into<String>,
322        user: impl Into<String>,
323        failure_class: BypassFailureClass,
324        first_failure_unix_ms: u64,
325    ) -> Self {
326        let backoff = BypassBackoff::initial();
327        Self {
328            schema_version: bypass_record_schema_version().to_string(),
329            worker_id: worker_id.into(),
330            host: host.into(),
331            user: user.into(),
332            failure_class,
333            reason_code: failure_class.incident_reason_code(),
334            state: BypassState::TemporaryBypass,
335            first_failure_unix_ms,
336            last_failure_unix_ms: first_failure_unix_ms,
337            next_probe_unix_ms: first_failure_unix_ms.saturating_add(backoff.current_ms),
338            backoff,
339            consecutive_failures: 1,
340            consecutive_passes: 0,
341            last_diagnostic: String::new(),
342            auto_rejoin: AutoRejoinCriteria::default(),
343            local_fallback_allowed: true,
344            disk_roots: Vec::new(),
345            details: BTreeMap::new(),
346        }
347    }
348
349    /// Set the diagnostic summary (truncated to [`MAX_DIAGNOSTIC_CHARS`]).
350    #[must_use]
351    pub fn with_diagnostic(mut self, diagnostic: impl Into<String>) -> Self {
352        self.last_diagnostic = truncate_diagnostic(diagnostic.into());
353        self
354    }
355
356    /// Set whether local fallback was allowed for affected commands.
357    #[must_use]
358    pub fn with_local_fallback_allowed(mut self, allowed: bool) -> Self {
359        self.local_fallback_allowed = allowed;
360        self
361    }
362
363    /// Set the auto-rejoin criteria (builder style).
364    #[must_use]
365    pub fn with_auto_rejoin(mut self, criteria: AutoRejoinCriteria) -> Self {
366        self.auto_rejoin = criteria;
367        self
368    }
369
370    /// Insert a compact detail key/value (builder style).
371    #[must_use]
372    pub fn with_detail(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
373        self.details.insert(key.into(), value.into());
374        self
375    }
376
377    /// Record another failure for an already-bypassed worker: bump the failure
378    /// count, reset the pass count, advance the backoff, reschedule the next
379    /// probe, and return to [`BypassState::TemporaryBypass`].
380    ///
381    /// This is record bookkeeping only; deciding *when* to probe is the
382    /// probe/canary loop's job (bead 1.3).
383    pub fn record_failure(&mut self, now_unix_ms: u64, diagnostic: impl Into<String>) {
384        self.last_failure_unix_ms = now_unix_ms;
385        self.consecutive_failures = self.consecutive_failures.saturating_add(1);
386        self.consecutive_passes = 0;
387        self.backoff = self.backoff.advanced();
388        self.next_probe_unix_ms = now_unix_ms.saturating_add(self.backoff.current_ms);
389        self.state = BypassState::TemporaryBypass;
390        let diagnostic = diagnostic.into();
391        if !diagnostic.is_empty() {
392            self.last_diagnostic = truncate_diagnostic(diagnostic);
393        }
394    }
395
396    /// Record a successful recovery probe: bump the pass count, reset the
397    /// failure count, and reschedule the next probe. Returns `true` once the
398    /// worker has met [`AutoRejoinCriteria::required_consecutive_passes`]. When
399    /// the criteria require a canary, the state advances to
400    /// [`BypassState::RecoveredPendingCanary`]; otherwise the caller may rejoin
401    /// the worker (removing this record).
402    pub fn record_probe_pass(&mut self, now_unix_ms: u64) -> bool {
403        self.consecutive_passes = self.consecutive_passes.saturating_add(1);
404        self.consecutive_failures = 0;
405        self.next_probe_unix_ms = now_unix_ms.saturating_add(self.backoff.current_ms);
406        let met = self.consecutive_passes >= self.auto_rejoin.required_consecutive_passes;
407        if met && self.auto_rejoin.canary_required {
408            self.state = BypassState::RecoveredPendingCanary;
409        }
410        met
411    }
412
413    /// Whether a recovery probe is due at `now_unix_ms`.
414    #[must_use]
415    pub const fn probe_due(&self, now_unix_ms: u64) -> bool {
416        now_unix_ms >= self.next_probe_unix_ms
417    }
418}
419
420/// Truncate a diagnostic string to [`MAX_DIAGNOSTIC_CHARS`] on a char boundary,
421/// appending an ellipsis marker when truncated.
422#[must_use]
423pub fn truncate_diagnostic(mut s: String) -> String {
424    if s.chars().count() <= MAX_DIAGNOSTIC_CHARS {
425        return s;
426    }
427    let cut = s
428        .char_indices()
429        .nth(MAX_DIAGNOSTIC_CHARS)
430        .map_or(s.len(), |(i, _)| i);
431    s.truncate(cut);
432    s.push('…');
433    s
434}
435
436/// The current worker-bypass-record schema version.
437#[must_use]
438pub fn bypass_record_schema_version() -> &'static str {
439    current_version(SchemaComponent::WorkerBypassRecord)
440}
441
442/// Export the JSON Schema for [`BypassRecord`].
443#[must_use]
444pub fn bypass_record_schema() -> Schema {
445    SchemaSettings::draft07()
446        .into_generator()
447        .into_root_schema_for::<BypassRecord>()
448}
449
450// ---------------------------------------------------------------------------
451// Persistent store
452// ---------------------------------------------------------------------------
453
454/// On-disk wrapper document. A top-level object (not a bare array) so the file
455/// can grow new top-level fields without a breaking format change.
456#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
457struct BypassRecordFile {
458    /// Store-format schema version (matches the record schema version).
459    schema_version: String,
460    /// Current records (one per worker; deduplicated by worker id on load).
461    records: Vec<BypassRecord>,
462}
463
464/// Resolve the default store path: `${RCH_STATE_HOME}/bypass_records.json`,
465/// falling through the same XDG hierarchy as [`crate::incident_ledger`].
466#[must_use]
467pub fn default_bypass_record_path() -> PathBuf {
468    let base = std::env::var_os("RCH_STATE_HOME")
469        .map(PathBuf::from)
470        .filter(|p| !p.as_os_str().is_empty())
471        .or_else(|| {
472            std::env::var_os("XDG_STATE_HOME")
473                .map(|d| PathBuf::from(d).join("rch"))
474                .filter(|p| p.as_os_str().len() > "rch".len())
475        })
476        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state/rch")))
477        .unwrap_or_else(|| PathBuf::from("/tmp/rch"));
478    base.join("bypass_records.json")
479}
480
481/// A single-document, atomically-persisted, corruption-tolerant store of
482/// current [`BypassRecord`]s keyed by worker id.
483#[derive(Debug, Clone)]
484pub struct BypassRecordStore {
485    path: PathBuf,
486    records: BTreeMap<String, BypassRecord>,
487}
488
489impl BypassRecordStore {
490    /// An empty store bound to `path` (nothing read from disk).
491    #[must_use]
492    pub fn with_path(path: impl Into<PathBuf>) -> Self {
493        Self {
494            path: path.into(),
495            records: BTreeMap::new(),
496        }
497    }
498
499    /// Load the store from `path`. A missing or unparseable file yields an empty
500    /// store (the bypass state is reconstructable from live worker state and the
501    /// incident ledger), so a corrupt file never blocks daemon startup.
502    #[must_use]
503    pub fn load(path: impl Into<PathBuf>) -> Self {
504        let path = path.into();
505        let mut records = BTreeMap::new();
506        if let Ok(bytes) = fs::read(&path)
507            && let Ok(file) = serde_json::from_slice::<BypassRecordFile>(&bytes)
508        {
509            for record in file.records {
510                // Latest entry wins if a malformed file duplicated a worker id.
511                records.insert(record.worker_id.clone(), record);
512            }
513        }
514        Self { path, records }
515    }
516
517    /// The store file path.
518    #[must_use]
519    pub fn path(&self) -> &Path {
520        &self.path
521    }
522
523    /// The current record for a worker, if bypassed.
524    #[must_use]
525    pub fn get(&self, worker_id: &str) -> Option<&BypassRecord> {
526        self.records.get(worker_id)
527    }
528
529    /// Whether a worker currently has a bypass record.
530    #[must_use]
531    pub fn contains(&self, worker_id: &str) -> bool {
532        self.records.contains_key(worker_id)
533    }
534
535    /// All current records, ordered by worker id.
536    #[must_use]
537    pub fn all(&self) -> Vec<&BypassRecord> {
538        self.records.values().collect()
539    }
540
541    /// Number of bypassed workers.
542    #[must_use]
543    pub fn len(&self) -> usize {
544        self.records.len()
545    }
546
547    /// Whether any worker is currently bypassed.
548    #[must_use]
549    pub fn is_empty(&self) -> bool {
550        self.records.is_empty()
551    }
552
553    /// Insert or replace a worker's record and persist the store.
554    pub fn upsert(&mut self, record: BypassRecord) -> std::io::Result<()> {
555        self.records.insert(record.worker_id.clone(), record);
556        self.persist()
557    }
558
559    /// Remove a worker's record (e.g. after full rejoin) and persist. Returns
560    /// the removed record, if any.
561    pub fn remove(&mut self, worker_id: &str) -> std::io::Result<Option<BypassRecord>> {
562        let removed = self.records.remove(worker_id);
563        if removed.is_some() {
564            self.persist()?;
565        }
566        Ok(removed)
567    }
568
569    /// Atomically and durably publish the records. The file and directory
570    /// must reach storage before an owner-bound failure receipt is acknowledged.
571    fn persist(&self) -> std::io::Result<()> {
572        if let Some(parent) = self.path.parent()
573            && !parent.as_os_str().is_empty()
574        {
575            fs::create_dir_all(parent)?;
576        }
577        let doc = BypassRecordFile {
578            schema_version: bypass_record_schema_version().to_string(),
579            records: self.records.values().cloned().collect(),
580        };
581        let body = serde_json::to_vec_pretty(&doc)
582            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
583
584        let parent = self
585            .path
586            .parent()
587            .filter(|p| !p.as_os_str().is_empty())
588            .unwrap_or_else(|| Path::new("."));
589        let tmp = parent.join(format!(".bypass_records.{}.tmp", std::process::id()));
590        {
591            let mut tmp_file = OpenOptions::new()
592                .create(true)
593                .write(true)
594                .truncate(true)
595                .open(&tmp)?;
596            tmp_file.write_all(&body)?;
597            tmp_file.flush()?;
598            tmp_file.sync_all()?;
599        }
600        fs::rename(&tmp, &self.path)?;
601        #[cfg(unix)]
602        fs::File::open(parent)?.sync_all()?;
603        Ok(())
604    }
605}
606
607// ---------------------------------------------------------------------------
608// Durable admin disables (bd-8zxz7)
609// ---------------------------------------------------------------------------
610
611/// A durable record of an admin/system worker disable.
612///
613/// `handle_worker_disable` only flips in-memory lifecycle state, so every
614/// disable — an operator's `rch workers disable`, or the automatic
615/// cpu-capability-fault quarantine — silently evaporated on daemon restart
616/// (observed live 2026-08-09: ovh-b, quarantined for SIGILL-ing AVX2 build
617/// scripts, rejoined the pool after a `launchctl kickstart` and would have
618/// served one false build failure per restart). Unlike a [`BypassRecord`],
619/// an admin disable has NO recovery probing or auto-rejoin: hardware does not
620/// heal on restart, and an operator's explicit disable stands until an
621/// explicit enable removes the record.
622#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
623pub struct AdminDisableRecord {
624    /// Worker id (the store's key).
625    pub worker_id: String,
626    /// Disable reason as given by the operator or fault path, if any.
627    #[serde(default, skip_serializing_if = "Option::is_none")]
628    pub reason: Option<String>,
629    /// Unix epoch milliseconds when the disable was recorded.
630    pub disabled_unix_ms: u64,
631}
632
633/// On-disk document for [`AdminDisableStore`].
634#[derive(Debug, Serialize, Deserialize)]
635struct AdminDisableFile {
636    schema_version: u32,
637    records: Vec<AdminDisableRecord>,
638}
639
640const ADMIN_DISABLE_SCHEMA_VERSION: u32 = 1;
641
642/// Resolve the default store path: `${RCH_STATE_HOME}/admin_disables.json`,
643/// beside [`default_bypass_record_path`]'s `bypass_records.json`.
644#[must_use]
645pub fn default_admin_disable_path() -> PathBuf {
646    default_bypass_record_path().with_file_name("admin_disables.json")
647}
648
649/// A single-document, atomically-persisted store of current
650/// [`AdminDisableRecord`]s keyed by worker id. Same corruption tolerance as
651/// [`BypassRecordStore`]: a missing or unreadable file yields an empty store
652/// and never blocks daemon startup (the failure mode is "worker rejoins until
653/// re-disabled", the pre-existing behavior).
654#[derive(Debug)]
655pub struct AdminDisableStore {
656    path: PathBuf,
657    records: BTreeMap<String, AdminDisableRecord>,
658}
659
660impl AdminDisableStore {
661    /// An empty store bound to `path` (nothing read from disk).
662    #[must_use]
663    pub fn with_path(path: impl Into<PathBuf>) -> Self {
664        Self {
665            path: path.into(),
666            records: BTreeMap::new(),
667        }
668    }
669
670    /// Load the store from `path`; missing/corrupt files yield an empty store.
671    #[must_use]
672    pub fn load(path: impl Into<PathBuf>) -> Self {
673        let path = path.into();
674        let mut records = BTreeMap::new();
675        if let Ok(bytes) = fs::read(&path)
676            && let Ok(file) = serde_json::from_slice::<AdminDisableFile>(&bytes)
677        {
678            for record in file.records {
679                records.insert(record.worker_id.clone(), record);
680            }
681        }
682        Self { path, records }
683    }
684
685    /// The store file path.
686    #[must_use]
687    pub fn path(&self) -> &Path {
688        &self.path
689    }
690
691    /// The current record for a worker, if disabled.
692    #[must_use]
693    pub fn get(&self, worker_id: &str) -> Option<&AdminDisableRecord> {
694        self.records.get(worker_id)
695    }
696
697    /// All current records, ordered by worker id.
698    #[must_use]
699    pub fn all(&self) -> Vec<&AdminDisableRecord> {
700        self.records.values().collect()
701    }
702
703    /// Record a worker disable and persist the store.
704    pub fn upsert(&mut self, record: AdminDisableRecord) -> std::io::Result<()> {
705        self.records.insert(record.worker_id.clone(), record);
706        self.persist()
707    }
708
709    /// Remove a worker's record (operator enable) and persist. Returns the
710    /// removed record, if any.
711    pub fn remove(&mut self, worker_id: &str) -> std::io::Result<Option<AdminDisableRecord>> {
712        let removed = self.records.remove(worker_id);
713        if removed.is_some() {
714            self.persist()?;
715        }
716        Ok(removed)
717    }
718
719    /// Atomically write the current records to disk (temp-file + rename).
720    fn persist(&self) -> std::io::Result<()> {
721        if let Some(parent) = self.path.parent()
722            && !parent.as_os_str().is_empty()
723        {
724            fs::create_dir_all(parent)?;
725        }
726        let doc = AdminDisableFile {
727            schema_version: ADMIN_DISABLE_SCHEMA_VERSION,
728            records: self.records.values().cloned().collect(),
729        };
730        let body = serde_json::to_vec_pretty(&doc)
731            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
732
733        let parent = self.path.parent().unwrap_or_else(|| Path::new("."));
734        let tmp = parent.join(format!(".admin_disables.{}.tmp", std::process::id()));
735        {
736            let mut tmp_file = OpenOptions::new()
737                .create(true)
738                .write(true)
739                .truncate(true)
740                .open(&tmp)?;
741            tmp_file.write_all(&body)?;
742            tmp_file.flush()?;
743        }
744        match fs::rename(&tmp, &self.path) {
745            Ok(()) => Ok(()),
746            Err(e) => {
747                let _ = fs::remove_file(&tmp);
748                Err(e)
749            }
750        }
751    }
752}
753
754// ---------------------------------------------------------------------------
755// Migration: admin-disabled (for transient illness) -> temporary bypass
756// ---------------------------------------------------------------------------
757
758/// A snapshot of an admin-disabled worker, used to decide whether its disable
759/// was a reaction to transient illness (and thus migrate-able to a temporary
760/// bypass) or a deliberate operator action (and thus left disabled).
761#[derive(Debug, Clone, PartialEq, Eq)]
762pub struct DisabledWorkerSnapshot {
763    /// Worker id.
764    pub worker_id: String,
765    /// Worker host.
766    pub host: String,
767    /// Worker SSH user.
768    pub user: String,
769    /// The recorded disable reason, if any.
770    pub disabled_reason: Option<String>,
771    /// When the worker was disabled (Unix epoch milliseconds), if known.
772    pub disabled_at_unix_ms: Option<u64>,
773}
774
775/// The outcome of evaluating a disabled worker for migration.
776#[derive(Debug, Clone, PartialEq, Eq)]
777pub enum DisabledMigration {
778    /// The disable looked transient: migrate to this temporary-bypass record so
779    /// the worker can auto-rejoin once healthy again.
780    Migrate(Box<BypassRecord>),
781    /// The disable is a deliberate or unknown operator action: keep it disabled.
782    KeepDisabled {
783        /// Why migration was declined.
784        reason: String,
785    },
786}
787
788/// Heuristically classify a disable-reason string into a transient
789/// [`BypassFailureClass`]. Returns `None` when the reason does not look
790/// transient (or is empty), in which case the worker should stay disabled.
791///
792/// Matching is case-insensitive and substring-based; order is most-specific
793/// first so e.g. "no space left on device" is classified as disk pressure
794/// rather than something more generic.
795#[must_use]
796pub fn classify_disable_reason(reason: &str) -> Option<BypassFailureClass> {
797    let r = reason.to_ascii_lowercase();
798    if r.trim().is_empty() {
799        return None;
800    }
801    // Most-specific signals first.
802    const TABLE: &[(&[&str], BypassFailureClass)] = &[
803        (
804            &["no space", "enospc", "disk full", "inode", "disk pressure"],
805            BypassFailureClass::DiskInodePressure,
806        ),
807        (
808            &["toolchain", "rustup", "nightly", "missing target", "rustc"],
809            BypassFailureClass::RuntimeToolchain,
810        ),
811        (
812            &[
813                "rch-wkr",
814                "worker binary",
815                "wrong user",
816                "wrong path",
817                "no such file",
818            ],
819            BypassFailureClass::WorkerBinary,
820        ),
821        (&["circuit"], BypassFailureClass::CircuitBreaker),
822        (
823            &["telemetry", "stale", "heartbeat"],
824            BypassFailureClass::StaleTelemetry,
825        ),
826        (
827            &["rsync", "vanished", "path sync", "source sync"],
828            BypassFailureClass::PathSync,
829        ),
830        (&["artifact"], BypassFailureClass::ArtifactRetrieval),
831        (
832            &["os/arch", "architecture", "arch mismatch", "triple"],
833            BypassFailureClass::OsArchMismatch,
834        ),
835        (
836            &[
837                "ssh",
838                "connection refused",
839                "connection reset",
840                "connection failed",
841                "connection timed out",
842                "unreachable",
843                "timed out",
844                "auth",
845                "permission denied",
846            ],
847            BypassFailureClass::Ssh,
848        ),
849    ];
850    for (needles, class) in TABLE {
851        if needles.iter().any(|n| r.contains(n)) {
852            return Some(*class);
853        }
854    }
855    None
856}
857
858/// Decide whether an admin-disabled worker should be migrated to a temporary
859/// bypass. Workers disabled for a recognizably transient reason are migrated so
860/// they can auto-rejoin; deliberate or unrecognized disables are kept disabled.
861///
862/// `now_unix_ms` stamps the migrated record's probe schedule (probe promptly).
863#[must_use]
864pub fn migrate_disabled_worker(
865    snapshot: &DisabledWorkerSnapshot,
866    now_unix_ms: u64,
867) -> DisabledMigration {
868    let Some(reason) = snapshot.disabled_reason.as_deref() else {
869        return DisabledMigration::KeepDisabled {
870            reason: "no disable reason recorded — treat as deliberate operator disable".to_string(),
871        };
872    };
873    let Some(class) = classify_disable_reason(reason) else {
874        return DisabledMigration::KeepDisabled {
875            reason: format!("disable reason does not look transient: {reason:?}"),
876        };
877    };
878
879    let first_failure = snapshot.disabled_at_unix_ms.unwrap_or(now_unix_ms);
880    let mut record = BypassRecord::new(
881        &snapshot.worker_id,
882        &snapshot.host,
883        &snapshot.user,
884        class,
885        first_failure,
886    )
887    .with_diagnostic(format!("migrated from admin-disabled: {reason}"))
888    .with_detail("migrated_from", "admin_disabled");
889    // Probe promptly after migration rather than waiting a full initial backoff
890    // window from the original (possibly old) disable time.
891    record.next_probe_unix_ms = now_unix_ms;
892    DisabledMigration::Migrate(Box::new(record))
893}
894
895#[cfg(test)]
896mod tests {
897    use super::*;
898
899    const TS: u64 = 1_700_000_000_000;
900
901    fn record(class: BypassFailureClass) -> BypassRecord {
902        BypassRecord::new("css", "203.0.113.20", "ubuntu", class, TS)
903    }
904
905    #[test]
906    fn failure_class_wire_form_is_stable_snake_case() {
907        assert_eq!(BypassFailureClass::Ssh.as_str(), "ssh");
908        assert_eq!(
909            BypassFailureClass::DiskInodePressure.as_str(),
910            "disk_inode_pressure"
911        );
912        // serde form matches as_str().
913        for c in BypassFailureClass::ALL {
914            let json = serde_json::to_string(c).unwrap();
915            assert_eq!(json, format!("\"{}\"", c.as_str()));
916            assert_eq!(BypassFailureClass::from_str_opt(c.as_str()), Some(*c));
917        }
918        assert_eq!(BypassFailureClass::from_str_opt("nonsense"), None);
919    }
920
921    #[test]
922    fn failure_class_maps_to_an_incident_reason_for_every_variant() {
923        // Total mapping — every class has a stable reason code for the ledger.
924        for c in BypassFailureClass::ALL {
925            let code = c.incident_reason_code();
926            // Round-trips through the incident registry.
927            assert_eq!(
928                IncidentReasonCode::from_code_str(code.code()),
929                Some(code),
930                "reason code for {c:?} not in registry"
931            );
932        }
933        assert_eq!(
934            BypassFailureClass::Ssh.incident_reason_code(),
935            IncidentReasonCode::HardPreflight
936        );
937        assert_eq!(
938            BypassFailureClass::CircuitBreaker.incident_reason_code(),
939            IncidentReasonCode::CircuitOpen
940        );
941        assert_eq!(
942            BypassFailureClass::DiskInodePressure.incident_reason_code(),
943            IncidentReasonCode::DiskFull
944        );
945    }
946
947    #[test]
948    fn new_record_is_consistent_and_schema_stamped() {
949        let r = record(BypassFailureClass::Ssh);
950        assert_eq!(r.schema_version, bypass_record_schema_version());
951        assert_eq!(r.state, BypassState::TemporaryBypass);
952        assert_eq!(r.reason_code, IncidentReasonCode::HardPreflight);
953        assert_eq!(r.consecutive_failures, 1);
954        assert_eq!(r.consecutive_passes, 0);
955        assert_eq!(r.first_failure_unix_ms, TS);
956        assert_eq!(r.last_failure_unix_ms, TS);
957        assert_eq!(r.next_probe_unix_ms, TS + BypassBackoff::DEFAULT_INITIAL_MS);
958        assert!(r.local_fallback_allowed);
959    }
960
961    #[test]
962    fn record_serializes_required_fields_and_roundtrips() {
963        let r = record(BypassFailureClass::CircuitBreaker)
964            .with_diagnostic("circuit open after 5 failures")
965            .with_local_fallback_allowed(false)
966            .with_detail("attempts", "5");
967        let v = serde_json::to_value(&r).unwrap();
968        // Every field the bead requires must be present and machine-readable.
969        for key in [
970            "worker_id",
971            "host",
972            "user",
973            "failure_class",
974            "reason_code",
975            "state",
976            "first_failure_unix_ms",
977            "last_failure_unix_ms",
978            "next_probe_unix_ms",
979            "backoff",
980            "consecutive_failures",
981            "consecutive_passes",
982            "last_diagnostic",
983            "auto_rejoin",
984            "local_fallback_allowed",
985        ] {
986            assert!(v.get(key).is_some(), "missing record field {key}");
987        }
988        assert_eq!(v["failure_class"], "circuit_breaker");
989        assert_eq!(v["reason_code"], "RCH-I009");
990        assert_eq!(v["local_fallback_allowed"], false);
991        let back: BypassRecord = serde_json::from_value(v).unwrap();
992        assert_eq!(back, r);
993    }
994
995    #[test]
996    fn record_never_carries_raw_command_or_secret_fields() {
997        let v = serde_json::to_value(record(BypassFailureClass::Ssh)).unwrap();
998        let obj = v.as_object().unwrap();
999        for forbidden in ["command", "raw_command", "secret", "password", "token"] {
1000            assert!(
1001                !obj.contains_key(forbidden),
1002                "must not expose `{forbidden}`"
1003            );
1004        }
1005    }
1006
1007    #[test]
1008    fn diagnostic_is_truncated() {
1009        let long = "x".repeat(MAX_DIAGNOSTIC_CHARS + 50);
1010        let r = record(BypassFailureClass::Ssh).with_diagnostic(long);
1011        assert!(r.last_diagnostic.chars().count() <= MAX_DIAGNOSTIC_CHARS + 1);
1012        assert!(r.last_diagnostic.ends_with('…'));
1013        // Short diagnostics are untouched.
1014        let short = record(BypassFailureClass::Ssh).with_diagnostic("brief");
1015        assert_eq!(short.last_diagnostic, "brief");
1016    }
1017
1018    #[test]
1019    fn backoff_doubles_up_to_ceiling() {
1020        let mut b = BypassBackoff::initial();
1021        assert_eq!(b.current_ms, BypassBackoff::DEFAULT_INITIAL_MS);
1022        let mut last = b.current_ms;
1023        for _ in 0..20 {
1024            b = b.advanced();
1025            assert!(b.current_ms >= last);
1026            assert!(b.current_ms <= BypassBackoff::DEFAULT_MAX_MS);
1027            last = b.current_ms;
1028        }
1029        // Eventually pinned at the ceiling.
1030        assert_eq!(b.current_ms, BypassBackoff::DEFAULT_MAX_MS);
1031        assert_eq!(b.attempts, 20);
1032    }
1033
1034    #[test]
1035    fn record_failure_advances_backoff_and_resets_passes() {
1036        let mut r = record(BypassFailureClass::Ssh);
1037        r.consecutive_passes = 1;
1038        let before = r.backoff.current_ms;
1039        r.record_failure(TS + 1000, "ssh timed out");
1040        assert_eq!(r.consecutive_failures, 2);
1041        assert_eq!(r.consecutive_passes, 0);
1042        assert!(r.backoff.current_ms > before);
1043        assert_eq!(r.next_probe_unix_ms, TS + 1000 + r.backoff.current_ms);
1044        assert_eq!(r.last_diagnostic, "ssh timed out");
1045        assert_eq!(r.state, BypassState::TemporaryBypass);
1046    }
1047
1048    #[test]
1049    fn probe_pass_advances_to_canary_when_criteria_met() {
1050        let mut r = record(BypassFailureClass::Ssh);
1051        // Default criteria: 2 passes, canary required.
1052        assert!(!r.record_probe_pass(TS + 1));
1053        assert_eq!(r.state, BypassState::TemporaryBypass);
1054        assert!(r.record_probe_pass(TS + 2));
1055        assert_eq!(r.state, BypassState::RecoveredPendingCanary);
1056        assert_eq!(r.consecutive_passes, 2);
1057        assert_eq!(r.consecutive_failures, 0);
1058    }
1059
1060    #[test]
1061    fn probe_due_respects_schedule() {
1062        let r = record(BypassFailureClass::Ssh);
1063        assert!(!r.probe_due(r.next_probe_unix_ms - 1));
1064        assert!(r.probe_due(r.next_probe_unix_ms));
1065        assert!(r.probe_due(r.next_probe_unix_ms + 1));
1066    }
1067
1068    #[test]
1069    fn schema_export_names_the_record_and_fields() {
1070        let text = serde_json::to_string(&bypass_record_schema()).unwrap();
1071        assert!(text.contains("BypassRecord"));
1072        for f in [
1073            "failure_class",
1074            "reason_code",
1075            "next_probe_unix_ms",
1076            "auto_rejoin",
1077            "local_fallback_allowed",
1078        ] {
1079            assert!(text.contains(f), "schema omits {f}");
1080        }
1081    }
1082
1083    // ---- store ----
1084
1085    #[test]
1086    fn store_upsert_get_remove_and_persist() {
1087        let dir = tempfile::tempdir().unwrap();
1088        let path = dir.path().join("bypass_records.json");
1089        let mut store = BypassRecordStore::load(&path);
1090        assert!(store.is_empty());
1091
1092        store.upsert(record(BypassFailureClass::Ssh)).unwrap();
1093        store
1094            .upsert(BypassRecord::new(
1095                "bil",
1096                "203.0.113.21",
1097                "ubuntu",
1098                BypassFailureClass::DiskInodePressure,
1099                TS,
1100            ))
1101            .unwrap();
1102        assert_eq!(store.len(), 2);
1103        assert!(store.contains("css"));
1104        assert_eq!(
1105            store.get("css").unwrap().failure_class,
1106            BypassFailureClass::Ssh
1107        );
1108        // all() is ordered by worker id.
1109        let ids: Vec<&str> = store.all().iter().map(|r| r.worker_id.as_str()).collect();
1110        assert_eq!(ids, vec!["bil", "css"]);
1111
1112        let removed = store.remove("css").unwrap();
1113        assert_eq!(removed.unwrap().worker_id, "css");
1114        assert!(!store.contains("css"));
1115        assert!(store.remove("nope").unwrap().is_none());
1116    }
1117
1118    #[test]
1119    fn store_survives_reload_across_instances() {
1120        let dir = tempfile::tempdir().unwrap();
1121        let path = dir.path().join("bypass_records.json");
1122        {
1123            let mut store = BypassRecordStore::load(&path);
1124            store
1125                .upsert(record(BypassFailureClass::CircuitBreaker).with_diagnostic("circuit churn"))
1126                .unwrap();
1127        }
1128        let reopened = BypassRecordStore::load(&path);
1129        assert_eq!(reopened.len(), 1);
1130        let r = reopened.get("css").unwrap();
1131        assert_eq!(r.failure_class, BypassFailureClass::CircuitBreaker);
1132        assert_eq!(r.last_diagnostic, "circuit churn");
1133    }
1134
1135    #[test]
1136    fn store_tolerates_corrupt_or_missing_file() {
1137        // Missing file -> empty.
1138        let dir = tempfile::tempdir().unwrap();
1139        let missing = dir.path().join("nope.json");
1140        assert!(BypassRecordStore::load(&missing).is_empty());
1141
1142        // Corrupt file -> empty (does not panic / block startup).
1143        let corrupt = dir.path().join("corrupt.json");
1144        fs::write(&corrupt, b"{ not valid json").unwrap();
1145        assert!(BypassRecordStore::load(&corrupt).is_empty());
1146    }
1147
1148    #[test]
1149    fn default_path_ends_with_bypass_records_json() {
1150        assert!(default_bypass_record_path().ends_with("bypass_records.json"));
1151    }
1152
1153    // ---- migration ----
1154
1155    #[test]
1156    fn classify_disable_reason_recognizes_transient_classes() {
1157        assert_eq!(
1158            classify_disable_reason("ssh: connection refused"),
1159            Some(BypassFailureClass::Ssh)
1160        );
1161        assert_eq!(
1162            classify_disable_reason("No space left on device"),
1163            Some(BypassFailureClass::DiskInodePressure)
1164        );
1165        assert_eq!(
1166            classify_disable_reason("circuit breaker tripped"),
1167            Some(BypassFailureClass::CircuitBreaker)
1168        );
1169        assert_eq!(
1170            classify_disable_reason("missing rustup toolchain nightly"),
1171            Some(BypassFailureClass::RuntimeToolchain)
1172        );
1173        // Non-transient / deliberate / empty -> None.
1174        assert_eq!(classify_disable_reason("decommissioned by ops"), None);
1175        assert_eq!(classify_disable_reason(""), None);
1176        assert_eq!(classify_disable_reason("   "), None);
1177    }
1178
1179    #[test]
1180    fn migrate_transient_disable_produces_bypass_record() {
1181        let snap = DisabledWorkerSnapshot {
1182            worker_id: "css".to_string(),
1183            host: "203.0.113.20".to_string(),
1184            user: "ubuntu".to_string(),
1185            disabled_reason: Some("ssh connection timed out during probe".to_string()),
1186            disabled_at_unix_ms: Some(TS),
1187        };
1188        match migrate_disabled_worker(&snap, TS + 100_000) {
1189            DisabledMigration::Migrate(record) => {
1190                assert_eq!(record.worker_id, "css");
1191                assert_eq!(record.failure_class, BypassFailureClass::Ssh);
1192                assert_eq!(record.first_failure_unix_ms, TS);
1193                // Probe promptly at migration time, not a full backoff later.
1194                assert_eq!(record.next_probe_unix_ms, TS + 100_000);
1195                assert_eq!(
1196                    record.details.get("migrated_from").map(String::as_str),
1197                    Some("admin_disabled")
1198                );
1199                assert!(
1200                    record
1201                        .last_diagnostic
1202                        .contains("migrated from admin-disabled")
1203                );
1204            }
1205            other => panic!("expected migration, got {other:?}"),
1206        }
1207    }
1208
1209    #[test]
1210    fn migrate_deliberate_or_unknown_disable_keeps_disabled() {
1211        // No reason recorded -> keep disabled.
1212        let no_reason = DisabledWorkerSnapshot {
1213            worker_id: "css".to_string(),
1214            host: "h".to_string(),
1215            user: "u".to_string(),
1216            disabled_reason: None,
1217            disabled_at_unix_ms: Some(TS),
1218        };
1219        assert!(matches!(
1220            migrate_disabled_worker(&no_reason, TS),
1221            DisabledMigration::KeepDisabled { .. }
1222        ));
1223
1224        // Deliberate reason -> keep disabled.
1225        let deliberate = DisabledWorkerSnapshot {
1226            disabled_reason: Some("retired hardware".to_string()),
1227            ..no_reason
1228        };
1229        assert!(matches!(
1230            migrate_disabled_worker(&deliberate, TS),
1231            DisabledMigration::KeepDisabled { .. }
1232        ));
1233    }
1234
1235    #[test]
1236    fn admin_disable_store_roundtrips_through_disk() {
1237        let dir = tempfile::tempdir().unwrap();
1238        let path = dir.path().join("admin_disables.json");
1239        let mut store = AdminDisableStore::with_path(&path);
1240        store
1241            .upsert(AdminDisableRecord {
1242                worker_id: "ovh-b".to_string(),
1243                reason: Some("cpu-capability-fault (SIGILL)".to_string()),
1244                disabled_unix_ms: 1_700_000_000_000,
1245            })
1246            .unwrap();
1247
1248        let reloaded = AdminDisableStore::load(&path);
1249        let record = reloaded.get("ovh-b").expect("record survives reload");
1250        assert_eq!(
1251            record.reason.as_deref(),
1252            Some("cpu-capability-fault (SIGILL)")
1253        );
1254        assert_eq!(record.disabled_unix_ms, 1_700_000_000_000);
1255        assert_eq!(reloaded.all().len(), 1);
1256    }
1257
1258    #[test]
1259    fn admin_disable_store_remove_persists_and_reports() {
1260        let dir = tempfile::tempdir().unwrap();
1261        let path = dir.path().join("admin_disables.json");
1262        let mut store = AdminDisableStore::with_path(&path);
1263        store
1264            .upsert(AdminDisableRecord {
1265                worker_id: "w1".to_string(),
1266                reason: None,
1267                disabled_unix_ms: 1,
1268            })
1269            .unwrap();
1270        assert!(store.remove("w1").unwrap().is_some());
1271        assert!(store.remove("w1").unwrap().is_none());
1272        assert!(AdminDisableStore::load(&path).get("w1").is_none());
1273    }
1274
1275    #[test]
1276    fn admin_disable_store_tolerates_missing_and_corrupt_files() {
1277        let dir = tempfile::tempdir().unwrap();
1278        let missing = AdminDisableStore::load(dir.path().join("nope.json"));
1279        assert!(missing.all().is_empty());
1280
1281        let corrupt_path = dir.path().join("corrupt.json");
1282        std::fs::write(&corrupt_path, b"{ not json").unwrap();
1283        let corrupt = AdminDisableStore::load(&corrupt_path);
1284        assert!(corrupt.all().is_empty());
1285    }
1286
1287    #[test]
1288    fn admin_disable_default_path_is_beside_bypass_records() {
1289        let path = default_admin_disable_path();
1290        assert!(path.ends_with("admin_disables.json"));
1291        assert_eq!(path.parent(), default_bypass_record_path().parent());
1292    }
1293}