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    /// Compact, ordered free-form details (small, bounded; not for secrets).
306    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
307    pub details: BTreeMap<String, String>,
308}
309
310impl BypassRecord {
311    /// Begin a bypass: a worker just hit `failure_class` at
312    /// `first_failure_unix_ms`. The first recovery probe is scheduled one
313    /// initial-backoff window later.
314    #[must_use]
315    pub fn new(
316        worker_id: impl Into<String>,
317        host: impl Into<String>,
318        user: impl Into<String>,
319        failure_class: BypassFailureClass,
320        first_failure_unix_ms: u64,
321    ) -> Self {
322        let backoff = BypassBackoff::initial();
323        Self {
324            schema_version: bypass_record_schema_version().to_string(),
325            worker_id: worker_id.into(),
326            host: host.into(),
327            user: user.into(),
328            failure_class,
329            reason_code: failure_class.incident_reason_code(),
330            state: BypassState::TemporaryBypass,
331            first_failure_unix_ms,
332            last_failure_unix_ms: first_failure_unix_ms,
333            next_probe_unix_ms: first_failure_unix_ms.saturating_add(backoff.current_ms),
334            backoff,
335            consecutive_failures: 1,
336            consecutive_passes: 0,
337            last_diagnostic: String::new(),
338            auto_rejoin: AutoRejoinCriteria::default(),
339            local_fallback_allowed: true,
340            details: BTreeMap::new(),
341        }
342    }
343
344    /// Set the diagnostic summary (truncated to [`MAX_DIAGNOSTIC_CHARS`]).
345    #[must_use]
346    pub fn with_diagnostic(mut self, diagnostic: impl Into<String>) -> Self {
347        self.last_diagnostic = truncate_diagnostic(diagnostic.into());
348        self
349    }
350
351    /// Set whether local fallback was allowed for affected commands.
352    #[must_use]
353    pub fn with_local_fallback_allowed(mut self, allowed: bool) -> Self {
354        self.local_fallback_allowed = allowed;
355        self
356    }
357
358    /// Set the auto-rejoin criteria (builder style).
359    #[must_use]
360    pub fn with_auto_rejoin(mut self, criteria: AutoRejoinCriteria) -> Self {
361        self.auto_rejoin = criteria;
362        self
363    }
364
365    /// Insert a compact detail key/value (builder style).
366    #[must_use]
367    pub fn with_detail(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
368        self.details.insert(key.into(), value.into());
369        self
370    }
371
372    /// Record another failure for an already-bypassed worker: bump the failure
373    /// count, reset the pass count, advance the backoff, reschedule the next
374    /// probe, and return to [`BypassState::TemporaryBypass`].
375    ///
376    /// This is record bookkeeping only; deciding *when* to probe is the
377    /// probe/canary loop's job (bead 1.3).
378    pub fn record_failure(&mut self, now_unix_ms: u64, diagnostic: impl Into<String>) {
379        self.last_failure_unix_ms = now_unix_ms;
380        self.consecutive_failures = self.consecutive_failures.saturating_add(1);
381        self.consecutive_passes = 0;
382        self.backoff = self.backoff.advanced();
383        self.next_probe_unix_ms = now_unix_ms.saturating_add(self.backoff.current_ms);
384        self.state = BypassState::TemporaryBypass;
385        let diagnostic = diagnostic.into();
386        if !diagnostic.is_empty() {
387            self.last_diagnostic = truncate_diagnostic(diagnostic);
388        }
389    }
390
391    /// Record a successful recovery probe: bump the pass count, reset the
392    /// failure count, and reschedule the next probe. Returns `true` once the
393    /// worker has met [`AutoRejoinCriteria::required_consecutive_passes`]. When
394    /// the criteria require a canary, the state advances to
395    /// [`BypassState::RecoveredPendingCanary`]; otherwise the caller may rejoin
396    /// the worker (removing this record).
397    pub fn record_probe_pass(&mut self, now_unix_ms: u64) -> bool {
398        self.consecutive_passes = self.consecutive_passes.saturating_add(1);
399        self.consecutive_failures = 0;
400        self.next_probe_unix_ms = now_unix_ms.saturating_add(self.backoff.current_ms);
401        let met = self.consecutive_passes >= self.auto_rejoin.required_consecutive_passes;
402        if met && self.auto_rejoin.canary_required {
403            self.state = BypassState::RecoveredPendingCanary;
404        }
405        met
406    }
407
408    /// Whether a recovery probe is due at `now_unix_ms`.
409    #[must_use]
410    pub const fn probe_due(&self, now_unix_ms: u64) -> bool {
411        now_unix_ms >= self.next_probe_unix_ms
412    }
413}
414
415/// Truncate a diagnostic string to [`MAX_DIAGNOSTIC_CHARS`] on a char boundary,
416/// appending an ellipsis marker when truncated.
417#[must_use]
418pub fn truncate_diagnostic(mut s: String) -> String {
419    if s.chars().count() <= MAX_DIAGNOSTIC_CHARS {
420        return s;
421    }
422    let cut = s
423        .char_indices()
424        .nth(MAX_DIAGNOSTIC_CHARS)
425        .map_or(s.len(), |(i, _)| i);
426    s.truncate(cut);
427    s.push('…');
428    s
429}
430
431/// The current worker-bypass-record schema version.
432#[must_use]
433pub fn bypass_record_schema_version() -> &'static str {
434    current_version(SchemaComponent::WorkerBypassRecord)
435}
436
437/// Export the JSON Schema for [`BypassRecord`].
438#[must_use]
439pub fn bypass_record_schema() -> Schema {
440    SchemaSettings::draft07()
441        .into_generator()
442        .into_root_schema_for::<BypassRecord>()
443}
444
445// ---------------------------------------------------------------------------
446// Persistent store
447// ---------------------------------------------------------------------------
448
449/// On-disk wrapper document. A top-level object (not a bare array) so the file
450/// can grow new top-level fields without a breaking format change.
451#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
452struct BypassRecordFile {
453    /// Store-format schema version (matches the record schema version).
454    schema_version: String,
455    /// Current records (one per worker; deduplicated by worker id on load).
456    records: Vec<BypassRecord>,
457}
458
459/// Resolve the default store path: `${RCH_STATE_HOME}/bypass_records.json`,
460/// falling through the same XDG hierarchy as [`crate::incident_ledger`].
461#[must_use]
462pub fn default_bypass_record_path() -> PathBuf {
463    let base = std::env::var_os("RCH_STATE_HOME")
464        .map(PathBuf::from)
465        .filter(|p| !p.as_os_str().is_empty())
466        .or_else(|| {
467            std::env::var_os("XDG_STATE_HOME")
468                .map(|d| PathBuf::from(d).join("rch"))
469                .filter(|p| p.as_os_str().len() > "rch".len())
470        })
471        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state/rch")))
472        .unwrap_or_else(|| PathBuf::from("/tmp/rch"));
473    base.join("bypass_records.json")
474}
475
476/// A single-document, atomically-persisted, corruption-tolerant store of
477/// current [`BypassRecord`]s keyed by worker id.
478#[derive(Debug, Clone)]
479pub struct BypassRecordStore {
480    path: PathBuf,
481    records: BTreeMap<String, BypassRecord>,
482}
483
484impl BypassRecordStore {
485    /// An empty store bound to `path` (nothing read from disk).
486    #[must_use]
487    pub fn with_path(path: impl Into<PathBuf>) -> Self {
488        Self {
489            path: path.into(),
490            records: BTreeMap::new(),
491        }
492    }
493
494    /// Load the store from `path`. A missing or unparseable file yields an empty
495    /// store (the bypass state is reconstructable from live worker state and the
496    /// incident ledger), so a corrupt file never blocks daemon startup.
497    #[must_use]
498    pub fn load(path: impl Into<PathBuf>) -> Self {
499        let path = path.into();
500        let mut records = BTreeMap::new();
501        if let Ok(bytes) = fs::read(&path)
502            && let Ok(file) = serde_json::from_slice::<BypassRecordFile>(&bytes)
503        {
504            for record in file.records {
505                // Latest entry wins if a malformed file duplicated a worker id.
506                records.insert(record.worker_id.clone(), record);
507            }
508        }
509        Self { path, records }
510    }
511
512    /// The store file path.
513    #[must_use]
514    pub fn path(&self) -> &Path {
515        &self.path
516    }
517
518    /// The current record for a worker, if bypassed.
519    #[must_use]
520    pub fn get(&self, worker_id: &str) -> Option<&BypassRecord> {
521        self.records.get(worker_id)
522    }
523
524    /// Whether a worker currently has a bypass record.
525    #[must_use]
526    pub fn contains(&self, worker_id: &str) -> bool {
527        self.records.contains_key(worker_id)
528    }
529
530    /// All current records, ordered by worker id.
531    #[must_use]
532    pub fn all(&self) -> Vec<&BypassRecord> {
533        self.records.values().collect()
534    }
535
536    /// Number of bypassed workers.
537    #[must_use]
538    pub fn len(&self) -> usize {
539        self.records.len()
540    }
541
542    /// Whether any worker is currently bypassed.
543    #[must_use]
544    pub fn is_empty(&self) -> bool {
545        self.records.is_empty()
546    }
547
548    /// Insert or replace a worker's record and persist the store.
549    pub fn upsert(&mut self, record: BypassRecord) -> std::io::Result<()> {
550        self.records.insert(record.worker_id.clone(), record);
551        self.persist()
552    }
553
554    /// Remove a worker's record (e.g. after full rejoin) and persist. Returns
555    /// the removed record, if any.
556    pub fn remove(&mut self, worker_id: &str) -> std::io::Result<Option<BypassRecord>> {
557        let removed = self.records.remove(worker_id);
558        if removed.is_some() {
559            self.persist()?;
560        }
561        Ok(removed)
562    }
563
564    /// Atomically write the current records to disk (temp-file + rename), so a
565    /// concurrent reader never observes a partial file.
566    fn persist(&self) -> std::io::Result<()> {
567        if let Some(parent) = self.path.parent()
568            && !parent.as_os_str().is_empty()
569        {
570            fs::create_dir_all(parent)?;
571        }
572        let doc = BypassRecordFile {
573            schema_version: bypass_record_schema_version().to_string(),
574            records: self.records.values().cloned().collect(),
575        };
576        let body = serde_json::to_vec_pretty(&doc)
577            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
578
579        let parent = self.path.parent().unwrap_or_else(|| Path::new("."));
580        let tmp = parent.join(format!(".bypass_records.{}.tmp", std::process::id()));
581        {
582            let mut tmp_file = OpenOptions::new()
583                .create(true)
584                .write(true)
585                .truncate(true)
586                .open(&tmp)?;
587            tmp_file.write_all(&body)?;
588            tmp_file.flush()?;
589        }
590        match fs::rename(&tmp, &self.path) {
591            Ok(()) => Ok(()),
592            Err(e) => {
593                let _ = fs::remove_file(&tmp);
594                Err(e)
595            }
596        }
597    }
598}
599
600// ---------------------------------------------------------------------------
601// Durable admin disables (bd-8zxz7)
602// ---------------------------------------------------------------------------
603
604/// A durable record of an admin/system worker disable.
605///
606/// `handle_worker_disable` only flips in-memory lifecycle state, so every
607/// disable — an operator's `rch workers disable`, or the automatic
608/// cpu-capability-fault quarantine — silently evaporated on daemon restart
609/// (observed live 2026-08-09: ovh-b, quarantined for SIGILL-ing AVX2 build
610/// scripts, rejoined the pool after a `launchctl kickstart` and would have
611/// served one false build failure per restart). Unlike a [`BypassRecord`],
612/// an admin disable has NO recovery probing or auto-rejoin: hardware does not
613/// heal on restart, and an operator's explicit disable stands until an
614/// explicit enable removes the record.
615#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
616pub struct AdminDisableRecord {
617    /// Worker id (the store's key).
618    pub worker_id: String,
619    /// Disable reason as given by the operator or fault path, if any.
620    #[serde(default, skip_serializing_if = "Option::is_none")]
621    pub reason: Option<String>,
622    /// Unix epoch milliseconds when the disable was recorded.
623    pub disabled_unix_ms: u64,
624}
625
626/// On-disk document for [`AdminDisableStore`].
627#[derive(Debug, Serialize, Deserialize)]
628struct AdminDisableFile {
629    schema_version: u32,
630    records: Vec<AdminDisableRecord>,
631}
632
633const ADMIN_DISABLE_SCHEMA_VERSION: u32 = 1;
634
635/// Resolve the default store path: `${RCH_STATE_HOME}/admin_disables.json`,
636/// beside [`default_bypass_record_path`]'s `bypass_records.json`.
637#[must_use]
638pub fn default_admin_disable_path() -> PathBuf {
639    default_bypass_record_path().with_file_name("admin_disables.json")
640}
641
642/// A single-document, atomically-persisted store of current
643/// [`AdminDisableRecord`]s keyed by worker id. Same corruption tolerance as
644/// [`BypassRecordStore`]: a missing or unreadable file yields an empty store
645/// and never blocks daemon startup (the failure mode is "worker rejoins until
646/// re-disabled", the pre-existing behavior).
647#[derive(Debug)]
648pub struct AdminDisableStore {
649    path: PathBuf,
650    records: BTreeMap<String, AdminDisableRecord>,
651}
652
653impl AdminDisableStore {
654    /// An empty store bound to `path` (nothing read from disk).
655    #[must_use]
656    pub fn with_path(path: impl Into<PathBuf>) -> Self {
657        Self {
658            path: path.into(),
659            records: BTreeMap::new(),
660        }
661    }
662
663    /// Load the store from `path`; missing/corrupt files yield an empty store.
664    #[must_use]
665    pub fn load(path: impl Into<PathBuf>) -> Self {
666        let path = path.into();
667        let mut records = BTreeMap::new();
668        if let Ok(bytes) = fs::read(&path)
669            && let Ok(file) = serde_json::from_slice::<AdminDisableFile>(&bytes)
670        {
671            for record in file.records {
672                records.insert(record.worker_id.clone(), record);
673            }
674        }
675        Self { path, records }
676    }
677
678    /// The store file path.
679    #[must_use]
680    pub fn path(&self) -> &Path {
681        &self.path
682    }
683
684    /// The current record for a worker, if disabled.
685    #[must_use]
686    pub fn get(&self, worker_id: &str) -> Option<&AdminDisableRecord> {
687        self.records.get(worker_id)
688    }
689
690    /// All current records, ordered by worker id.
691    #[must_use]
692    pub fn all(&self) -> Vec<&AdminDisableRecord> {
693        self.records.values().collect()
694    }
695
696    /// Record a worker disable and persist the store.
697    pub fn upsert(&mut self, record: AdminDisableRecord) -> std::io::Result<()> {
698        self.records.insert(record.worker_id.clone(), record);
699        self.persist()
700    }
701
702    /// Remove a worker's record (operator enable) and persist. Returns the
703    /// removed record, if any.
704    pub fn remove(&mut self, worker_id: &str) -> std::io::Result<Option<AdminDisableRecord>> {
705        let removed = self.records.remove(worker_id);
706        if removed.is_some() {
707            self.persist()?;
708        }
709        Ok(removed)
710    }
711
712    /// Atomically write the current records to disk (temp-file + rename).
713    fn persist(&self) -> std::io::Result<()> {
714        if let Some(parent) = self.path.parent()
715            && !parent.as_os_str().is_empty()
716        {
717            fs::create_dir_all(parent)?;
718        }
719        let doc = AdminDisableFile {
720            schema_version: ADMIN_DISABLE_SCHEMA_VERSION,
721            records: self.records.values().cloned().collect(),
722        };
723        let body = serde_json::to_vec_pretty(&doc)
724            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
725
726        let parent = self.path.parent().unwrap_or_else(|| Path::new("."));
727        let tmp = parent.join(format!(".admin_disables.{}.tmp", std::process::id()));
728        {
729            let mut tmp_file = OpenOptions::new()
730                .create(true)
731                .write(true)
732                .truncate(true)
733                .open(&tmp)?;
734            tmp_file.write_all(&body)?;
735            tmp_file.flush()?;
736        }
737        match fs::rename(&tmp, &self.path) {
738            Ok(()) => Ok(()),
739            Err(e) => {
740                let _ = fs::remove_file(&tmp);
741                Err(e)
742            }
743        }
744    }
745}
746
747// ---------------------------------------------------------------------------
748// Migration: admin-disabled (for transient illness) -> temporary bypass
749// ---------------------------------------------------------------------------
750
751/// A snapshot of an admin-disabled worker, used to decide whether its disable
752/// was a reaction to transient illness (and thus migrate-able to a temporary
753/// bypass) or a deliberate operator action (and thus left disabled).
754#[derive(Debug, Clone, PartialEq, Eq)]
755pub struct DisabledWorkerSnapshot {
756    /// Worker id.
757    pub worker_id: String,
758    /// Worker host.
759    pub host: String,
760    /// Worker SSH user.
761    pub user: String,
762    /// The recorded disable reason, if any.
763    pub disabled_reason: Option<String>,
764    /// When the worker was disabled (Unix epoch milliseconds), if known.
765    pub disabled_at_unix_ms: Option<u64>,
766}
767
768/// The outcome of evaluating a disabled worker for migration.
769#[derive(Debug, Clone, PartialEq, Eq)]
770pub enum DisabledMigration {
771    /// The disable looked transient: migrate to this temporary-bypass record so
772    /// the worker can auto-rejoin once healthy again.
773    Migrate(Box<BypassRecord>),
774    /// The disable is a deliberate or unknown operator action: keep it disabled.
775    KeepDisabled {
776        /// Why migration was declined.
777        reason: String,
778    },
779}
780
781/// Heuristically classify a disable-reason string into a transient
782/// [`BypassFailureClass`]. Returns `None` when the reason does not look
783/// transient (or is empty), in which case the worker should stay disabled.
784///
785/// Matching is case-insensitive and substring-based; order is most-specific
786/// first so e.g. "no space left on device" is classified as disk pressure
787/// rather than something more generic.
788#[must_use]
789pub fn classify_disable_reason(reason: &str) -> Option<BypassFailureClass> {
790    let r = reason.to_ascii_lowercase();
791    if r.trim().is_empty() {
792        return None;
793    }
794    // Most-specific signals first.
795    const TABLE: &[(&[&str], BypassFailureClass)] = &[
796        (
797            &["no space", "enospc", "disk full", "inode", "disk pressure"],
798            BypassFailureClass::DiskInodePressure,
799        ),
800        (
801            &["toolchain", "rustup", "nightly", "missing target", "rustc"],
802            BypassFailureClass::RuntimeToolchain,
803        ),
804        (
805            &[
806                "rch-wkr",
807                "worker binary",
808                "wrong user",
809                "wrong path",
810                "no such file",
811            ],
812            BypassFailureClass::WorkerBinary,
813        ),
814        (&["circuit"], BypassFailureClass::CircuitBreaker),
815        (
816            &["telemetry", "stale", "heartbeat"],
817            BypassFailureClass::StaleTelemetry,
818        ),
819        (
820            &["rsync", "vanished", "path sync", "source sync"],
821            BypassFailureClass::PathSync,
822        ),
823        (&["artifact"], BypassFailureClass::ArtifactRetrieval),
824        (
825            &["os/arch", "architecture", "arch mismatch", "triple"],
826            BypassFailureClass::OsArchMismatch,
827        ),
828        (
829            &[
830                "ssh",
831                "connection refused",
832                "connection reset",
833                "connection failed",
834                "connection timed out",
835                "unreachable",
836                "timed out",
837                "auth",
838                "permission denied",
839            ],
840            BypassFailureClass::Ssh,
841        ),
842    ];
843    for (needles, class) in TABLE {
844        if needles.iter().any(|n| r.contains(n)) {
845            return Some(*class);
846        }
847    }
848    None
849}
850
851/// Decide whether an admin-disabled worker should be migrated to a temporary
852/// bypass. Workers disabled for a recognizably transient reason are migrated so
853/// they can auto-rejoin; deliberate or unrecognized disables are kept disabled.
854///
855/// `now_unix_ms` stamps the migrated record's probe schedule (probe promptly).
856#[must_use]
857pub fn migrate_disabled_worker(
858    snapshot: &DisabledWorkerSnapshot,
859    now_unix_ms: u64,
860) -> DisabledMigration {
861    let Some(reason) = snapshot.disabled_reason.as_deref() else {
862        return DisabledMigration::KeepDisabled {
863            reason: "no disable reason recorded — treat as deliberate operator disable".to_string(),
864        };
865    };
866    let Some(class) = classify_disable_reason(reason) else {
867        return DisabledMigration::KeepDisabled {
868            reason: format!("disable reason does not look transient: {reason:?}"),
869        };
870    };
871
872    let first_failure = snapshot.disabled_at_unix_ms.unwrap_or(now_unix_ms);
873    let mut record = BypassRecord::new(
874        &snapshot.worker_id,
875        &snapshot.host,
876        &snapshot.user,
877        class,
878        first_failure,
879    )
880    .with_diagnostic(format!("migrated from admin-disabled: {reason}"))
881    .with_detail("migrated_from", "admin_disabled");
882    // Probe promptly after migration rather than waiting a full initial backoff
883    // window from the original (possibly old) disable time.
884    record.next_probe_unix_ms = now_unix_ms;
885    DisabledMigration::Migrate(Box::new(record))
886}
887
888#[cfg(test)]
889mod tests {
890    use super::*;
891
892    const TS: u64 = 1_700_000_000_000;
893
894    fn record(class: BypassFailureClass) -> BypassRecord {
895        BypassRecord::new("css", "203.0.113.20", "ubuntu", class, TS)
896    }
897
898    #[test]
899    fn failure_class_wire_form_is_stable_snake_case() {
900        assert_eq!(BypassFailureClass::Ssh.as_str(), "ssh");
901        assert_eq!(
902            BypassFailureClass::DiskInodePressure.as_str(),
903            "disk_inode_pressure"
904        );
905        // serde form matches as_str().
906        for c in BypassFailureClass::ALL {
907            let json = serde_json::to_string(c).unwrap();
908            assert_eq!(json, format!("\"{}\"", c.as_str()));
909            assert_eq!(BypassFailureClass::from_str_opt(c.as_str()), Some(*c));
910        }
911        assert_eq!(BypassFailureClass::from_str_opt("nonsense"), None);
912    }
913
914    #[test]
915    fn failure_class_maps_to_an_incident_reason_for_every_variant() {
916        // Total mapping — every class has a stable reason code for the ledger.
917        for c in BypassFailureClass::ALL {
918            let code = c.incident_reason_code();
919            // Round-trips through the incident registry.
920            assert_eq!(
921                IncidentReasonCode::from_code_str(code.code()),
922                Some(code),
923                "reason code for {c:?} not in registry"
924            );
925        }
926        assert_eq!(
927            BypassFailureClass::Ssh.incident_reason_code(),
928            IncidentReasonCode::HardPreflight
929        );
930        assert_eq!(
931            BypassFailureClass::CircuitBreaker.incident_reason_code(),
932            IncidentReasonCode::CircuitOpen
933        );
934        assert_eq!(
935            BypassFailureClass::DiskInodePressure.incident_reason_code(),
936            IncidentReasonCode::DiskFull
937        );
938    }
939
940    #[test]
941    fn new_record_is_consistent_and_schema_stamped() {
942        let r = record(BypassFailureClass::Ssh);
943        assert_eq!(r.schema_version, bypass_record_schema_version());
944        assert_eq!(r.state, BypassState::TemporaryBypass);
945        assert_eq!(r.reason_code, IncidentReasonCode::HardPreflight);
946        assert_eq!(r.consecutive_failures, 1);
947        assert_eq!(r.consecutive_passes, 0);
948        assert_eq!(r.first_failure_unix_ms, TS);
949        assert_eq!(r.last_failure_unix_ms, TS);
950        assert_eq!(r.next_probe_unix_ms, TS + BypassBackoff::DEFAULT_INITIAL_MS);
951        assert!(r.local_fallback_allowed);
952    }
953
954    #[test]
955    fn record_serializes_required_fields_and_roundtrips() {
956        let r = record(BypassFailureClass::CircuitBreaker)
957            .with_diagnostic("circuit open after 5 failures")
958            .with_local_fallback_allowed(false)
959            .with_detail("attempts", "5");
960        let v = serde_json::to_value(&r).unwrap();
961        // Every field the bead requires must be present and machine-readable.
962        for key in [
963            "worker_id",
964            "host",
965            "user",
966            "failure_class",
967            "reason_code",
968            "state",
969            "first_failure_unix_ms",
970            "last_failure_unix_ms",
971            "next_probe_unix_ms",
972            "backoff",
973            "consecutive_failures",
974            "consecutive_passes",
975            "last_diagnostic",
976            "auto_rejoin",
977            "local_fallback_allowed",
978        ] {
979            assert!(v.get(key).is_some(), "missing record field {key}");
980        }
981        assert_eq!(v["failure_class"], "circuit_breaker");
982        assert_eq!(v["reason_code"], "RCH-I009");
983        assert_eq!(v["local_fallback_allowed"], false);
984        let back: BypassRecord = serde_json::from_value(v).unwrap();
985        assert_eq!(back, r);
986    }
987
988    #[test]
989    fn record_never_carries_raw_command_or_secret_fields() {
990        let v = serde_json::to_value(record(BypassFailureClass::Ssh)).unwrap();
991        let obj = v.as_object().unwrap();
992        for forbidden in ["command", "raw_command", "secret", "password", "token"] {
993            assert!(
994                !obj.contains_key(forbidden),
995                "must not expose `{forbidden}`"
996            );
997        }
998    }
999
1000    #[test]
1001    fn diagnostic_is_truncated() {
1002        let long = "x".repeat(MAX_DIAGNOSTIC_CHARS + 50);
1003        let r = record(BypassFailureClass::Ssh).with_diagnostic(long);
1004        assert!(r.last_diagnostic.chars().count() <= MAX_DIAGNOSTIC_CHARS + 1);
1005        assert!(r.last_diagnostic.ends_with('…'));
1006        // Short diagnostics are untouched.
1007        let short = record(BypassFailureClass::Ssh).with_diagnostic("brief");
1008        assert_eq!(short.last_diagnostic, "brief");
1009    }
1010
1011    #[test]
1012    fn backoff_doubles_up_to_ceiling() {
1013        let mut b = BypassBackoff::initial();
1014        assert_eq!(b.current_ms, BypassBackoff::DEFAULT_INITIAL_MS);
1015        let mut last = b.current_ms;
1016        for _ in 0..20 {
1017            b = b.advanced();
1018            assert!(b.current_ms >= last);
1019            assert!(b.current_ms <= BypassBackoff::DEFAULT_MAX_MS);
1020            last = b.current_ms;
1021        }
1022        // Eventually pinned at the ceiling.
1023        assert_eq!(b.current_ms, BypassBackoff::DEFAULT_MAX_MS);
1024        assert_eq!(b.attempts, 20);
1025    }
1026
1027    #[test]
1028    fn record_failure_advances_backoff_and_resets_passes() {
1029        let mut r = record(BypassFailureClass::Ssh);
1030        r.consecutive_passes = 1;
1031        let before = r.backoff.current_ms;
1032        r.record_failure(TS + 1000, "ssh timed out");
1033        assert_eq!(r.consecutive_failures, 2);
1034        assert_eq!(r.consecutive_passes, 0);
1035        assert!(r.backoff.current_ms > before);
1036        assert_eq!(r.next_probe_unix_ms, TS + 1000 + r.backoff.current_ms);
1037        assert_eq!(r.last_diagnostic, "ssh timed out");
1038        assert_eq!(r.state, BypassState::TemporaryBypass);
1039    }
1040
1041    #[test]
1042    fn probe_pass_advances_to_canary_when_criteria_met() {
1043        let mut r = record(BypassFailureClass::Ssh);
1044        // Default criteria: 2 passes, canary required.
1045        assert!(!r.record_probe_pass(TS + 1));
1046        assert_eq!(r.state, BypassState::TemporaryBypass);
1047        assert!(r.record_probe_pass(TS + 2));
1048        assert_eq!(r.state, BypassState::RecoveredPendingCanary);
1049        assert_eq!(r.consecutive_passes, 2);
1050        assert_eq!(r.consecutive_failures, 0);
1051    }
1052
1053    #[test]
1054    fn probe_due_respects_schedule() {
1055        let r = record(BypassFailureClass::Ssh);
1056        assert!(!r.probe_due(r.next_probe_unix_ms - 1));
1057        assert!(r.probe_due(r.next_probe_unix_ms));
1058        assert!(r.probe_due(r.next_probe_unix_ms + 1));
1059    }
1060
1061    #[test]
1062    fn schema_export_names_the_record_and_fields() {
1063        let text = serde_json::to_string(&bypass_record_schema()).unwrap();
1064        assert!(text.contains("BypassRecord"));
1065        for f in [
1066            "failure_class",
1067            "reason_code",
1068            "next_probe_unix_ms",
1069            "auto_rejoin",
1070            "local_fallback_allowed",
1071        ] {
1072            assert!(text.contains(f), "schema omits {f}");
1073        }
1074    }
1075
1076    // ---- store ----
1077
1078    #[test]
1079    fn store_upsert_get_remove_and_persist() {
1080        let dir = tempfile::tempdir().unwrap();
1081        let path = dir.path().join("bypass_records.json");
1082        let mut store = BypassRecordStore::load(&path);
1083        assert!(store.is_empty());
1084
1085        store.upsert(record(BypassFailureClass::Ssh)).unwrap();
1086        store
1087            .upsert(BypassRecord::new(
1088                "bil",
1089                "203.0.113.21",
1090                "ubuntu",
1091                BypassFailureClass::DiskInodePressure,
1092                TS,
1093            ))
1094            .unwrap();
1095        assert_eq!(store.len(), 2);
1096        assert!(store.contains("css"));
1097        assert_eq!(
1098            store.get("css").unwrap().failure_class,
1099            BypassFailureClass::Ssh
1100        );
1101        // all() is ordered by worker id.
1102        let ids: Vec<&str> = store.all().iter().map(|r| r.worker_id.as_str()).collect();
1103        assert_eq!(ids, vec!["bil", "css"]);
1104
1105        let removed = store.remove("css").unwrap();
1106        assert_eq!(removed.unwrap().worker_id, "css");
1107        assert!(!store.contains("css"));
1108        assert!(store.remove("nope").unwrap().is_none());
1109    }
1110
1111    #[test]
1112    fn store_survives_reload_across_instances() {
1113        let dir = tempfile::tempdir().unwrap();
1114        let path = dir.path().join("bypass_records.json");
1115        {
1116            let mut store = BypassRecordStore::load(&path);
1117            store
1118                .upsert(record(BypassFailureClass::CircuitBreaker).with_diagnostic("circuit churn"))
1119                .unwrap();
1120        }
1121        let reopened = BypassRecordStore::load(&path);
1122        assert_eq!(reopened.len(), 1);
1123        let r = reopened.get("css").unwrap();
1124        assert_eq!(r.failure_class, BypassFailureClass::CircuitBreaker);
1125        assert_eq!(r.last_diagnostic, "circuit churn");
1126    }
1127
1128    #[test]
1129    fn store_tolerates_corrupt_or_missing_file() {
1130        // Missing file -> empty.
1131        let dir = tempfile::tempdir().unwrap();
1132        let missing = dir.path().join("nope.json");
1133        assert!(BypassRecordStore::load(&missing).is_empty());
1134
1135        // Corrupt file -> empty (does not panic / block startup).
1136        let corrupt = dir.path().join("corrupt.json");
1137        fs::write(&corrupt, b"{ not valid json").unwrap();
1138        assert!(BypassRecordStore::load(&corrupt).is_empty());
1139    }
1140
1141    #[test]
1142    fn default_path_ends_with_bypass_records_json() {
1143        assert!(default_bypass_record_path().ends_with("bypass_records.json"));
1144    }
1145
1146    // ---- migration ----
1147
1148    #[test]
1149    fn classify_disable_reason_recognizes_transient_classes() {
1150        assert_eq!(
1151            classify_disable_reason("ssh: connection refused"),
1152            Some(BypassFailureClass::Ssh)
1153        );
1154        assert_eq!(
1155            classify_disable_reason("No space left on device"),
1156            Some(BypassFailureClass::DiskInodePressure)
1157        );
1158        assert_eq!(
1159            classify_disable_reason("circuit breaker tripped"),
1160            Some(BypassFailureClass::CircuitBreaker)
1161        );
1162        assert_eq!(
1163            classify_disable_reason("missing rustup toolchain nightly"),
1164            Some(BypassFailureClass::RuntimeToolchain)
1165        );
1166        // Non-transient / deliberate / empty -> None.
1167        assert_eq!(classify_disable_reason("decommissioned by ops"), None);
1168        assert_eq!(classify_disable_reason(""), None);
1169        assert_eq!(classify_disable_reason("   "), None);
1170    }
1171
1172    #[test]
1173    fn migrate_transient_disable_produces_bypass_record() {
1174        let snap = DisabledWorkerSnapshot {
1175            worker_id: "css".to_string(),
1176            host: "203.0.113.20".to_string(),
1177            user: "ubuntu".to_string(),
1178            disabled_reason: Some("ssh connection timed out during probe".to_string()),
1179            disabled_at_unix_ms: Some(TS),
1180        };
1181        match migrate_disabled_worker(&snap, TS + 100_000) {
1182            DisabledMigration::Migrate(record) => {
1183                assert_eq!(record.worker_id, "css");
1184                assert_eq!(record.failure_class, BypassFailureClass::Ssh);
1185                assert_eq!(record.first_failure_unix_ms, TS);
1186                // Probe promptly at migration time, not a full backoff later.
1187                assert_eq!(record.next_probe_unix_ms, TS + 100_000);
1188                assert_eq!(
1189                    record.details.get("migrated_from").map(String::as_str),
1190                    Some("admin_disabled")
1191                );
1192                assert!(
1193                    record
1194                        .last_diagnostic
1195                        .contains("migrated from admin-disabled")
1196                );
1197            }
1198            other => panic!("expected migration, got {other:?}"),
1199        }
1200    }
1201
1202    #[test]
1203    fn migrate_deliberate_or_unknown_disable_keeps_disabled() {
1204        // No reason recorded -> keep disabled.
1205        let no_reason = DisabledWorkerSnapshot {
1206            worker_id: "css".to_string(),
1207            host: "h".to_string(),
1208            user: "u".to_string(),
1209            disabled_reason: None,
1210            disabled_at_unix_ms: Some(TS),
1211        };
1212        assert!(matches!(
1213            migrate_disabled_worker(&no_reason, TS),
1214            DisabledMigration::KeepDisabled { .. }
1215        ));
1216
1217        // Deliberate reason -> keep disabled.
1218        let deliberate = DisabledWorkerSnapshot {
1219            disabled_reason: Some("retired hardware".to_string()),
1220            ..no_reason
1221        };
1222        assert!(matches!(
1223            migrate_disabled_worker(&deliberate, TS),
1224            DisabledMigration::KeepDisabled { .. }
1225        ));
1226    }
1227
1228    #[test]
1229    fn admin_disable_store_roundtrips_through_disk() {
1230        let dir = tempfile::tempdir().unwrap();
1231        let path = dir.path().join("admin_disables.json");
1232        let mut store = AdminDisableStore::with_path(&path);
1233        store
1234            .upsert(AdminDisableRecord {
1235                worker_id: "ovh-b".to_string(),
1236                reason: Some("cpu-capability-fault (SIGILL)".to_string()),
1237                disabled_unix_ms: 1_700_000_000_000,
1238            })
1239            .unwrap();
1240
1241        let reloaded = AdminDisableStore::load(&path);
1242        let record = reloaded.get("ovh-b").expect("record survives reload");
1243        assert_eq!(
1244            record.reason.as_deref(),
1245            Some("cpu-capability-fault (SIGILL)")
1246        );
1247        assert_eq!(record.disabled_unix_ms, 1_700_000_000_000);
1248        assert_eq!(reloaded.all().len(), 1);
1249    }
1250
1251    #[test]
1252    fn admin_disable_store_remove_persists_and_reports() {
1253        let dir = tempfile::tempdir().unwrap();
1254        let path = dir.path().join("admin_disables.json");
1255        let mut store = AdminDisableStore::with_path(&path);
1256        store
1257            .upsert(AdminDisableRecord {
1258                worker_id: "w1".to_string(),
1259                reason: None,
1260                disabled_unix_ms: 1,
1261            })
1262            .unwrap();
1263        assert!(store.remove("w1").unwrap().is_some());
1264        assert!(store.remove("w1").unwrap().is_none());
1265        assert!(AdminDisableStore::load(&path).get("w1").is_none());
1266    }
1267
1268    #[test]
1269    fn admin_disable_store_tolerates_missing_and_corrupt_files() {
1270        let dir = tempfile::tempdir().unwrap();
1271        let missing = AdminDisableStore::load(dir.path().join("nope.json"));
1272        assert!(missing.all().is_empty());
1273
1274        let corrupt_path = dir.path().join("corrupt.json");
1275        std::fs::write(&corrupt_path, b"{ not json").unwrap();
1276        let corrupt = AdminDisableStore::load(&corrupt_path);
1277        assert!(corrupt.all().is_empty());
1278    }
1279
1280    #[test]
1281    fn admin_disable_default_path_is_beside_bypass_records() {
1282        let path = default_admin_disable_path();
1283        assert!(path.ends_with("admin_disables.json"));
1284        assert_eq!(path.parent(), default_bypass_record_path().parent());
1285    }
1286}