Skip to main content

rch_common/
remediation_config.rs

1//! Central config schema and default policy for the session-history remediation
2//! features (bd-session-history-remediation-ocv9i.17.1).
3//!
4//! This module is the **design center** for what every remediation feature means
5//! when the user has not customized anything. It declares one serde- and
6//! `schemars`-friendly [`RemediationConfig`] that aggregates every remediation
7//! knob with a documented default, the fail-open / fail-closed policy, value
8//! validation, path safety, and redaction of sensitive (path-like) fields.
9//!
10//! ## Why a separate config layer
11//!
12//! The runtime subsystems keep their own runtime structs, which use types that
13//! are awkward to serialize (`std::time::Duration`, `PathBuf`) or that live in
14//! the `rchd` binary crate and so cannot be referenced from shared code. This
15//! module holds the **serializable** declaration of the same knobs (seconds,
16//! bytes, plain strings) plus `From` conversions into the rch-common runtime
17//! structs. Drift-guard tests assert that the central defaults reproduce each
18//! runtime struct's own `::default()`, so the documented schema can never
19//! silently diverge from the code that runs.
20//!
21//! ## Default policy
22//!
23//! - Ordinary hook / `rch exec` paths stay **fail-open**: any error falls back
24//!   to local execution rather than blocking the agent.
25//! - Explicit **proof mode fails closed** — it refuses local fallback before it
26//!   would run locally, so a requested proof is never silently downgraded.
27//! - Path-like defaults resolve under RCH-managed roots (`/tmp/rch`, the XDG
28//!   state/cache dirs, `/data/projects`). Operator-supplied paths must be
29//!   absolute; [`RemediationConfig::validate`] flags any path outside the
30//!   managed roots as an operator override rather than silently trusting it.
31
32use serde::{Deserialize, Serialize};
33
34use schemars::JsonSchema;
35use schemars::generate::SchemaSettings;
36
37use crate::incident_ledger::{IncidentLedgerConfig, default_ledger_path};
38use crate::proof_intent::{ReplayConstraints, StaleSourcePolicy};
39// Single source of truth for path masking lives in the shared redaction policy.
40use crate::redaction::redact_path;
41
42// ── Canonical default constants ──────────────────────────────────────────────
43//
44// Each constant is the single documented default for one knob. The values are
45// copied from (and drift-guarded against, where the runtime struct lives in
46// rch-common) the subsystems that consume them:
47//   - temporary bypass backoff  → `bypass_record::BypassBackoff`
48//   - auto-rejoin criteria       → `bypass_record::AutoRejoinCriteria`
49//   - auto-rejoin probe thresholds → `rchd::bypass_recovery_service::BypassRecoveryConfig`
50//   - reconciliation windows     → `rchd::repo_convergence` module constants
51//   - incident ledger retention  → `incident_ledger::IncidentLedgerConfig`
52//   - build-root safety          → `rchd::build_root_policy` module constants
53//   - pooled target / reaper     → `rchd::config::StaleTargetReapConfig`
54//   - log retention              → `log_retention::LogRetentionPolicy`
55//   - disk pressure              → `disk_pressure_report::PressureThresholds`
56
57/// Default temporary-bypass backoff window (30s; mirrors `BypassBackoff`).
58pub const DEFAULT_BACKOFF_INITIAL_SECS: u64 = 30;
59/// Default temporary-bypass backoff ceiling (15min; mirrors `BypassBackoff`).
60pub const DEFAULT_BACKOFF_MAX_SECS: u64 = 900;
61
62/// Default consecutive passes required before a worker may auto-rejoin.
63pub const DEFAULT_REQUIRED_CONSECUTIVE_PASSES: u32 = 2;
64/// Default cadence at which the recovery service scans for due probes (30s).
65pub const DEFAULT_CHECK_INTERVAL_SECS: u64 = 30;
66/// Default SSH/probe timeout for a single capability probe or canary (10s).
67pub const DEFAULT_PROBE_TIMEOUT_SECS: u64 = 10;
68/// Default minimum free disk (GB) a probed root must report.
69pub const DEFAULT_MIN_DISK_FREE_GB: f64 = 5.0;
70/// Default minimum free inodes a probed root must report.
71pub const DEFAULT_MIN_DISK_INODES: u64 = 10_000;
72/// Default maximum load-per-core a recovered worker may report.
73pub const DEFAULT_MAX_LOAD_PER_CORE: f64 = 4.0;
74/// Default minimum worker wire protocol (held at 0 until rch-wkr exposes one).
75pub const DEFAULT_MIN_PROTOCOL: u32 = 0;
76/// Default canary command run over the SSH path before a full rejoin.
77pub const DEFAULT_CANARY_COMMAND: &str = "rustc --version";
78
79/// Default maximum convergence attempts before a worker enters `Failed`.
80pub const DEFAULT_RECONCILE_MAX_ATTEMPTS: u32 = 3;
81/// Default wall-clock budget per convergence cycle (seconds).
82pub const DEFAULT_RECONCILE_TIME_BUDGET_SECS: u64 = 120;
83/// Default minimum dwell time before a state transition (hysteresis, ms).
84pub const DEFAULT_RECONCILE_HYSTERESIS_MS: u64 = 5_000;
85/// Default retained per-worker transition-history entries.
86pub const DEFAULT_RECONCILE_MAX_TRANSITION_HISTORY: usize = 64;
87/// Default retained global convergence-outcome entries.
88pub const DEFAULT_RECONCILE_MAX_OUTCOME_HISTORY: usize = 256;
89/// Default staleness threshold for a worker's last status check (seconds).
90pub const DEFAULT_RECONCILE_STALENESS_SECS: u64 = 300;
91
92/// Default retained-event count for the incident ledger after compaction.
93pub const DEFAULT_INCIDENT_MAX_ENTRIES: usize = 5_000;
94/// Default incident-ledger size that triggers compaction (4 MiB).
95pub const DEFAULT_INCIDENT_MAX_BYTES: u64 = 4 * 1024 * 1024;
96
97/// Default minimum free bytes a build root must have (2 GiB).
98pub const DEFAULT_BUILD_ROOT_MIN_FREE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
99/// Default minimum free inodes a build root must have.
100pub const DEFAULT_BUILD_ROOT_MIN_FREE_INODES: u64 = 50_000;
101
102/// Default idle window before a pooled target dir is reaped (hours).
103pub const DEFAULT_POOLED_REAPER_IDLE_HOURS: u32 = 12;
104/// Default sweep cadence for the pooled-target stale reaper (minutes).
105pub const DEFAULT_POOLED_REAPER_INTERVAL_MINS: u64 = 120;
106/// Default LONG idle window before a POOLED (`-pool-`) target dir is reaped
107/// (hours). Pooled dirs are reused across jobs — the warm cache is the point —
108/// so they get a much longer window than per-job dirs: seven days of zero file
109/// activity means the pool key's build dimensions are gone (toolchain bumped,
110/// project retired) and the dir is a corpse, not a cache (observed live
111/// 2026-08-09: ~670h-idle pooled dirs accumulating forever, bead 6dj11).
112/// `0` disables pooled reaping entirely.
113pub const DEFAULT_POOLED_REAPER_POOLED_IDLE_HOURS: u32 = 168;
114/// Default per-worker byte budget (GiB) across ALL reap-class target dirs
115/// (per-job + pooled + legacy). `0` disables the cap. When the total exceeds
116/// the budget after the TTL passes, the sweep evicts oldest-idle-first among
117/// dirs past the SHORT idle window — never an actively-written dir — until
118/// back under budget (bead 6dj11: the css incident was ~300 GB of target
119/// trees; a TTL alone cannot bound a burst of still-fresh dirs). Ships 0
120/// (off): the cap pass re-stats every candidate tree, and an autonomous
121/// eviction budget is an operator decision.
122pub const DEFAULT_POOLED_REAPER_MAX_CACHE_GB: u32 = 0;
123/// Default remote base under which pooled target dirs live.
124pub const DEFAULT_POOLED_REMOTE_BASE: &str = "/data/projects";
125
126/// Default idle window, in DAYS, before `rch gc` will collect a durable
127/// per-worker Cargo cache dir (`rch-cargo-cache-*`, issue #42).
128///
129/// These caches hold the registry and git-dependency state every offloaded
130/// build reuses, and each job refreshes the dir's mtime, so an idle age this
131/// long means no job has touched the worker's cache for two weeks — a cold
132/// corpse, not a warm cache. Collection additionally requires the open-fd and
133/// live-process gates, so this window is a floor, never the sole authority.
134/// `0` disables cache collection entirely.
135pub const DEFAULT_GC_CARGO_CACHE_IDLE_DAYS: u32 = 14;
136
137/// Default maximum telemetry age that still counts as "fresh" (seconds).
138pub const DEFAULT_TELEMETRY_MAX_AGE_SECS: u64 = 120;
139
140/// Default rotation trigger for an active managed log (16 MiB).
141pub const DEFAULT_LOG_MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
142/// Default rotated generations retained per managed log.
143pub const DEFAULT_LOG_KEEP_ROTATED: usize = 3;
144/// Default managed-log total at/above which pressure is `Warning` (64 MiB).
145pub const DEFAULT_LOG_WARN_TOTAL_BYTES: u64 = 64 * 1024 * 1024;
146/// Default managed-log total at/above which pressure is `Critical` (256 MiB).
147pub const DEFAULT_LOG_CRITICAL_TOTAL_BYTES: u64 = 256 * 1024 * 1024;
148
149/// Default available-percent below which disk pressure is `Warning`.
150pub const DEFAULT_PRESSURE_WARNING_AVAIL_PCT: f64 = 15.0;
151/// Default available-percent below which disk pressure is `Critical`.
152pub const DEFAULT_PRESSURE_CRITICAL_AVAIL_PCT: f64 = 5.0;
153/// Default available-inodes below which disk pressure is `Warning`.
154pub const DEFAULT_PRESSURE_WARNING_AVAIL_INODES: u64 = 100_000;
155/// Default available-inodes below which disk pressure is `Critical`.
156pub const DEFAULT_PRESSURE_CRITICAL_AVAIL_INODES: u64 = 10_000;
157
158/// Default real-fleet smoke/soak iteration count.
159pub const DEFAULT_SMOKE_ITERATIONS: usize = 20;
160/// Default deterministic seed for the soak workload.
161pub const DEFAULT_SMOKE_SEED: u64 = 42;
162
163/// Absolute path prefixes RCH treats as managed/standard (no operator warning).
164const RCH_MANAGED_ROOT_PREFIXES: &[&str] =
165    &["/tmp/rch", "/tmp", "/data/tmp/rch", "/data/projects", "/dp"];
166
167/// Default disk roots whose capacity/inodes the recovery probe reports.
168#[must_use]
169fn default_disk_roots() -> Vec<String> {
170    vec![
171        "/tmp".to_string(),
172        "/tmp/rch".to_string(),
173        crate::types::default_remote_base(),
174    ]
175}
176
177// ── Top-level policy ─────────────────────────────────────────────────────────
178
179/// The failure-mode policy: what RCH does when a remediation path errors.
180///
181/// The defaults encode the project's core invariant — ordinary command paths
182/// fail open (run locally) while explicit proof mode fails closed.
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
184#[serde(default)]
185pub struct RemediationPolicy {
186    /// Ordinary hook / `rch exec` paths fall back to local execution on error.
187    pub hook_exec_fail_open: bool,
188    /// Explicit proof mode refuses local fallback before running locally.
189    pub proof_mode_fail_closed: bool,
190}
191
192impl Default for RemediationPolicy {
193    fn default() -> Self {
194        Self {
195            hook_exec_fail_open: true,
196            proof_mode_fail_closed: true,
197        }
198    }
199}
200
201// ── Temporary bypass ─────────────────────────────────────────────────────────
202
203/// Temporary-bypass retention and probe backoff knobs.
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
205#[serde(default)]
206pub struct TemporaryBypassConfig {
207    /// Initial backoff window before the first recovery probe (seconds).
208    pub backoff_initial_secs: u64,
209    /// Ceiling for the exponential backoff window (seconds).
210    pub backoff_max_secs: u64,
211}
212
213impl Default for TemporaryBypassConfig {
214    fn default() -> Self {
215        Self {
216            backoff_initial_secs: DEFAULT_BACKOFF_INITIAL_SECS,
217            backoff_max_secs: DEFAULT_BACKOFF_MAX_SECS,
218        }
219    }
220}
221
222// ── Auto-rejoin ──────────────────────────────────────────────────────────────
223
224/// Auto-rejoin canary thresholds and recovery-probe dimensions.
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
226#[serde(default)]
227pub struct AutoRejoinConfig {
228    /// Consecutive successful recovery probes required before a canary build.
229    pub required_consecutive_passes: u32,
230    /// Whether one canary build is required before full rejoin.
231    pub canary_required: bool,
232    /// How often the recovery service scans for due probes (seconds).
233    pub check_interval_secs: u64,
234    /// SSH/probe timeout for a single capability probe or canary (seconds).
235    pub probe_timeout_secs: u64,
236    /// Minimum free disk (GB) a worker must report on every probed root.
237    pub min_disk_free_gb: f64,
238    /// Minimum free inodes a worker must report on every probed root.
239    pub min_disk_inodes: u64,
240    /// Disk roots whose capacity/inodes the probe reports.
241    pub disk_roots: Vec<String>,
242    /// Maximum load-per-core a worker may report to pass the load dimension.
243    pub max_load_per_core: f64,
244    /// Minimum worker wire protocol required to pass the protocol dimension.
245    pub min_protocol: u32,
246    /// rustup targets a recovered worker must have (empty = cargo/rustc only).
247    pub required_targets: Vec<String>,
248    /// rustup toolchains a recovered worker must have (prefix-matched).
249    pub required_toolchains: Vec<String>,
250    /// The canary command run over the SSH path before full rejoin.
251    pub canary_command: String,
252}
253
254impl Default for AutoRejoinConfig {
255    fn default() -> Self {
256        Self {
257            required_consecutive_passes: DEFAULT_REQUIRED_CONSECUTIVE_PASSES,
258            canary_required: true,
259            check_interval_secs: DEFAULT_CHECK_INTERVAL_SECS,
260            probe_timeout_secs: DEFAULT_PROBE_TIMEOUT_SECS,
261            min_disk_free_gb: DEFAULT_MIN_DISK_FREE_GB,
262            min_disk_inodes: DEFAULT_MIN_DISK_INODES,
263            disk_roots: default_disk_roots(),
264            max_load_per_core: DEFAULT_MAX_LOAD_PER_CORE,
265            min_protocol: DEFAULT_MIN_PROTOCOL,
266            required_targets: Vec::new(),
267            required_toolchains: Vec::new(),
268            canary_command: DEFAULT_CANARY_COMMAND.to_string(),
269        }
270    }
271}
272
273// ── Desired-state reconciliation ─────────────────────────────────────────────
274
275/// Desired-state fleet reconciliation windows and history bounds.
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
277#[serde(default)]
278pub struct ReconciliationConfig {
279    /// Maximum convergence attempts before a worker enters `Failed`.
280    pub max_attempts: u32,
281    /// Wall-clock budget per convergence cycle (seconds).
282    pub time_budget_secs: u64,
283    /// Minimum dwell time before a state transition (hysteresis, ms).
284    pub state_hysteresis_ms: u64,
285    /// Retained per-worker transition-history entries.
286    pub max_transition_history: usize,
287    /// Retained global convergence-outcome entries.
288    pub max_outcome_history: usize,
289    /// Staleness threshold for a worker's last status check (seconds).
290    pub staleness_threshold_secs: u64,
291}
292
293impl Default for ReconciliationConfig {
294    fn default() -> Self {
295        Self {
296            max_attempts: DEFAULT_RECONCILE_MAX_ATTEMPTS,
297            time_budget_secs: DEFAULT_RECONCILE_TIME_BUDGET_SECS,
298            state_hysteresis_ms: DEFAULT_RECONCILE_HYSTERESIS_MS,
299            max_transition_history: DEFAULT_RECONCILE_MAX_TRANSITION_HISTORY,
300            max_outcome_history: DEFAULT_RECONCILE_MAX_OUTCOME_HISTORY,
301            staleness_threshold_secs: DEFAULT_RECONCILE_STALENESS_SECS,
302        }
303    }
304}
305
306// ── Proof mode / deferred proof queue ────────────────────────────────────────
307
308/// Proof intent store location and default replay policy.
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
310#[serde(default)]
311pub struct ProofConfig {
312    /// Override path for the proof-intent JSONL store. `None` resolves under the
313    /// RCH-managed state dir (alongside the incident ledger).
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub store_path: Option<String>,
316    /// Default stale-source policy when an intent does not specify one.
317    pub default_stale_source_policy: StaleSourcePolicy,
318    // Note: this nested table must be serialized last so the scalar keys above
319    // precede it (TOML requires scalar keys before sub-tables).
320    /// Default constraints applied to a stored intent at replay time.
321    pub default_replay_constraints: ReplayConstraints,
322}
323
324impl Default for ProofConfig {
325    fn default() -> Self {
326        Self {
327            store_path: None,
328            default_replay_constraints: ReplayConstraints::default(),
329            // Conservative default: only replay byte-identical sources.
330            default_stale_source_policy: StaleSourcePolicy::RejectIfChanged,
331        }
332    }
333}
334
335// ── Incident ledger ──────────────────────────────────────────────────────────
336
337/// Incident-ledger retention bounds and optional path override.
338#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
339#[serde(default)]
340pub struct IncidentLedgerPolicy {
341    /// Override path for the ledger JSONL file. `None` uses the RCH state dir.
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub path: Option<String>,
344    /// Events retained after a compaction (most-recent wins).
345    pub max_entries: usize,
346    /// File size that triggers a compaction, in bytes.
347    pub max_bytes: u64,
348}
349
350impl Default for IncidentLedgerPolicy {
351    fn default() -> Self {
352        Self {
353            path: None,
354            max_entries: DEFAULT_INCIDENT_MAX_ENTRIES,
355            max_bytes: DEFAULT_INCIDENT_MAX_BYTES,
356        }
357    }
358}
359
360// ── Mount-aware build root ───────────────────────────────────────────────────
361
362/// Mount-aware build-root / cargo-home safety thresholds.
363#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
364#[serde(default)]
365pub struct BuildRootConfig {
366    /// Minimum free bytes a build root must have to be considered safe.
367    pub min_free_bytes: u64,
368    /// Minimum free inodes a build root must have to be considered safe.
369    pub min_free_inodes: u64,
370}
371
372impl Default for BuildRootConfig {
373    fn default() -> Self {
374        Self {
375            min_free_bytes: DEFAULT_BUILD_ROOT_MIN_FREE_BYTES,
376            min_free_inodes: DEFAULT_BUILD_ROOT_MIN_FREE_INODES,
377        }
378    }
379}
380
381// ── Pooled target dirs ───────────────────────────────────────────────────────
382
383/// Pooled target-dir policy and conservative stale reaper.
384#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
385#[serde(default)]
386pub struct PooledTargetConfig {
387    /// Whether per-key pooled target dirs are used for remote builds.
388    pub pooling_enabled: bool,
389    /// Whether the stale-target reaper runs (ships off pending canary soak).
390    pub reaper_enabled: bool,
391    /// Idle window before a pooled target dir is reaped (hours).
392    pub reaper_idle_hours: u32,
393    /// Sweep cadence for the stale reaper (minutes).
394    pub reaper_interval_mins: u64,
395    /// Remote base under which pooled target dirs live.
396    pub remote_base: String,
397    /// LONG idle window before a POOLED (`-pool-`) dir is reaped (hours);
398    /// `0` disables pooled reaping. Floored at 24h when non-zero — pooled
399    /// dirs are warm caches, never short-TTL targets (bead 6dj11).
400    pub reaper_pooled_idle_hours: u32,
401    /// Per-worker byte budget (GiB) across all reap-class target dirs; `0`
402    /// disables. Over budget, the sweep evicts oldest-idle-first among dirs
403    /// past the short idle window until back under (bead 6dj11).
404    pub reaper_max_cache_gb: u32,
405    /// Where pooled Cargo target stores are PLACED on the worker (issue #64).
406    ///
407    /// Unset (the default) keeps today's behavior: the pooled store lives
408    /// inside the project mirror, i.e. under the worker's canonical project
409    /// root, on whatever filesystem holds it. Set to an absolute path to move
410    /// every pooled store to `<store_base>/<project_id>/.rch-target-…-pool-…`,
411    /// which is how a worker with a small root disk keeps multi-GB warm pools
412    /// on a larger volume.
413    ///
414    /// Distinct from [`Self::remote_base`], which is where the reaper *scans*;
415    /// when this is set the reaper scans both, and
416    /// [`Self::reaper_max_cache_gb`] then applies per scan root rather than
417    /// per worker. Ignored for Windows workers, which keep their drive-letter
418    /// build base.
419    #[serde(default, skip_serializing_if = "Option::is_none")]
420    pub store_base: Option<String>,
421
422    /// EXTRA roots `rch gc` scans, beyond the ones rch derives itself.
423    ///
424    /// The derived set ([`crate::gc_roots::derive_gc_roots`]) already covers
425    /// [`Self::remote_base`], [`Self::store_base`] and the worker temp base
426    /// resolved by
427    /// [`crate::gc_roots::remote_cargo_home_base_prelude`]. This
428    /// list exists for runtime roots rch cannot know about — an operator build
429    /// root on a mounted volume, a per-host scratch tree — which is precisely
430    /// how ~700 GB of `.rch-target-*-pool-*` and `rch-cargo-cache-*` went
431    /// uncollected fleet-wide while `rch gc` reported "0 dirs, 0 MB".
432    ///
433    /// Each entry must pass [`crate::stale_target_reap::is_safe_reap_base`]:
434    /// absolute, no `..`, at least one segment, built only from
435    /// `[A-Za-z0-9/._-]` (the value is embedded in a remote shell command).
436    #[serde(default, skip_serializing_if = "Vec::is_empty")]
437    pub gc_extra_roots: Vec<String>,
438
439    /// Idle window in DAYS before `rch gc` may collect a durable per-worker
440    /// Cargo cache dir (`rch-cargo-cache-*`); `0` disables cache collection.
441    /// See [`DEFAULT_GC_CARGO_CACHE_IDLE_DAYS`].
442    pub gc_cargo_cache_idle_days: u32,
443}
444
445impl Default for PooledTargetConfig {
446    fn default() -> Self {
447        Self {
448            pooling_enabled: true,
449            reaper_enabled: false,
450            reaper_idle_hours: DEFAULT_POOLED_REAPER_IDLE_HOURS,
451            reaper_interval_mins: DEFAULT_POOLED_REAPER_INTERVAL_MINS,
452            remote_base: DEFAULT_POOLED_REMOTE_BASE.to_string(),
453            reaper_pooled_idle_hours: DEFAULT_POOLED_REAPER_POOLED_IDLE_HOURS,
454            reaper_max_cache_gb: DEFAULT_POOLED_REAPER_MAX_CACHE_GB,
455            store_base: None,
456            gc_extra_roots: Vec::new(),
457            gc_cargo_cache_idle_days: DEFAULT_GC_CARGO_CACHE_IDLE_DAYS,
458        }
459    }
460}
461
462// ── Telemetry freshness ──────────────────────────────────────────────────────
463
464/// Telemetry freshness tolerance.
465///
466/// The freshness model itself ([`crate::telemetry_freshness`]) is adaptive and
467/// pure; this knob is the base maximum age the recovery path treats as "fresh"
468/// when it cannot derive a tighter, observer-aware tolerance.
469#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
470#[serde(default)]
471pub struct TelemetryFreshnessConfig {
472    /// Maximum telemetry age that still counts as fresh (seconds).
473    pub max_age_secs: u64,
474}
475
476impl Default for TelemetryFreshnessConfig {
477    fn default() -> Self {
478        Self {
479            max_age_secs: DEFAULT_TELEMETRY_MAX_AGE_SECS,
480        }
481    }
482}
483
484// ── Log retention ────────────────────────────────────────────────────────────
485
486/// Daemon log rotation/retention policy.
487#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
488#[serde(default)]
489pub struct LogRetentionConfig {
490    /// Rotate an active log once it exceeds this many bytes.
491    pub max_file_bytes: u64,
492    /// Rotated generations to retain per log.
493    pub keep_rotated: usize,
494    /// Managed total at/above which pressure is `Warning`.
495    pub warn_total_bytes: u64,
496    /// Managed total at/above which pressure is `Critical`.
497    pub critical_total_bytes: u64,
498}
499
500impl Default for LogRetentionConfig {
501    fn default() -> Self {
502        Self {
503            max_file_bytes: DEFAULT_LOG_MAX_FILE_BYTES,
504            keep_rotated: DEFAULT_LOG_KEEP_ROTATED,
505            warn_total_bytes: DEFAULT_LOG_WARN_TOTAL_BYTES,
506            critical_total_bytes: DEFAULT_LOG_CRITICAL_TOTAL_BYTES,
507        }
508    }
509}
510
511// ── Disk pressure ────────────────────────────────────────────────────────────
512
513/// Disk-pressure classification thresholds.
514#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
515#[serde(default)]
516pub struct DiskPressureConfig {
517    /// Available-percent at/below which pressure is `Warning`.
518    pub warning_avail_pct: f64,
519    /// Available-percent at/below which pressure is `Critical`.
520    pub critical_avail_pct: f64,
521    /// Available-inodes at/below which pressure is `Warning`.
522    pub warning_avail_inodes: u64,
523    /// Available-inodes at/below which pressure is `Critical`.
524    pub critical_avail_inodes: u64,
525}
526
527impl Default for DiskPressureConfig {
528    fn default() -> Self {
529        Self {
530            warning_avail_pct: DEFAULT_PRESSURE_WARNING_AVAIL_PCT,
531            critical_avail_pct: DEFAULT_PRESSURE_CRITICAL_AVAIL_PCT,
532            warning_avail_inodes: DEFAULT_PRESSURE_WARNING_AVAIL_INODES,
533            critical_avail_inodes: DEFAULT_PRESSURE_CRITICAL_AVAIL_INODES,
534        }
535    }
536}
537
538// ── Real-fleet smoke/soak ────────────────────────────────────────────────────
539
540/// Real-fleet smoke and soak validation defaults.
541#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
542#[serde(default)]
543pub struct SmokeConfig {
544    /// Iteration count for the soak workload.
545    pub iterations: usize,
546    /// Deterministic seed for the soak workload.
547    pub seed: u64,
548}
549
550impl Default for SmokeConfig {
551    fn default() -> Self {
552        Self {
553            iterations: DEFAULT_SMOKE_ITERATIONS,
554            seed: DEFAULT_SMOKE_SEED,
555        }
556    }
557}
558
559// ── Aggregate ────────────────────────────────────────────────────────────────
560
561/// The complete remediation config schema: one section per subsystem plus the
562/// top-level failure-mode policy.
563#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
564#[serde(default)]
565pub struct RemediationConfig {
566    /// Failure-mode policy (fail-open hook/exec, fail-closed proof mode).
567    pub policy: RemediationPolicy,
568    /// Temporary-bypass retention and backoff.
569    pub temporary_bypass: TemporaryBypassConfig,
570    /// Auto-rejoin canary thresholds and recovery-probe dimensions.
571    pub auto_rejoin: AutoRejoinConfig,
572    /// Desired-state reconciliation windows.
573    pub reconciliation: ReconciliationConfig,
574    /// Proof intent store and default replay policy.
575    pub proof: ProofConfig,
576    /// Incident ledger retention.
577    pub incident_ledger: IncidentLedgerPolicy,
578    /// Mount-aware build-root safety thresholds.
579    pub build_root: BuildRootConfig,
580    /// Pooled target-dir policy and stale reaper.
581    pub pooled_target: PooledTargetConfig,
582    /// Telemetry freshness tolerance.
583    pub telemetry_freshness: TelemetryFreshnessConfig,
584    /// Daemon log rotation/retention.
585    pub log_retention: LogRetentionConfig,
586    /// Disk-pressure classification thresholds.
587    pub disk_pressure: DiskPressureConfig,
588    /// Real-fleet smoke/soak defaults.
589    pub smoke: SmokeConfig,
590}
591
592/// Severity of a [`RemediationIssue`].
593#[derive(Debug, Clone, Copy, PartialEq, Eq)]
594pub enum IssueSeverity {
595    /// A value that makes the config invalid and must be corrected.
596    Error,
597    /// A value that is accepted but worth flagging (e.g. an operator path
598    /// outside the RCH-managed roots).
599    Warning,
600}
601
602/// A single validation finding for a remediation knob.
603#[derive(Debug, Clone, PartialEq, Eq)]
604pub struct RemediationIssue {
605    /// Dotted field path, e.g. `remediation.auto_rejoin.check_interval_secs`.
606    pub field: String,
607    /// Whether the finding is an error or an advisory warning.
608    pub severity: IssueSeverity,
609    /// Human-readable explanation.
610    pub message: String,
611}
612
613impl RemediationIssue {
614    fn error(field: impl Into<String>, message: impl Into<String>) -> Self {
615        Self {
616            field: field.into(),
617            severity: IssueSeverity::Error,
618            message: message.into(),
619        }
620    }
621
622    fn warning(field: impl Into<String>, message: impl Into<String>) -> Self {
623        Self {
624            field: field.into(),
625            severity: IssueSeverity::Warning,
626            message: message.into(),
627        }
628    }
629}
630
631impl RemediationConfig {
632    /// Whether ordinary hook / `rch exec` paths fall back to local on error.
633    #[must_use]
634    pub fn hook_exec_is_fail_open(&self) -> bool {
635        self.policy.hook_exec_fail_open
636    }
637
638    /// Whether explicit proof mode refuses local fallback before running.
639    #[must_use]
640    pub fn proof_mode_is_fail_closed(&self) -> bool {
641        self.policy.proof_mode_fail_closed
642    }
643
644    /// Validate every knob, returning any errors and advisory warnings.
645    ///
646    /// Range/ordering checks reject impossible values; path checks require
647    /// operator paths to be absolute and flag any path outside the RCH-managed
648    /// roots as an operator override (warning, not error).
649    #[must_use]
650    pub fn validate(&self) -> Vec<RemediationIssue> {
651        let mut issues = Vec::new();
652
653        // Temporary bypass backoff.
654        if self.temporary_bypass.backoff_initial_secs == 0 {
655            issues.push(RemediationIssue::error(
656                "remediation.temporary_bypass.backoff_initial_secs",
657                "backoff_initial_secs must be greater than 0",
658            ));
659        }
660        if self.temporary_bypass.backoff_max_secs < self.temporary_bypass.backoff_initial_secs {
661            issues.push(RemediationIssue::error(
662                "remediation.temporary_bypass.backoff_max_secs",
663                "backoff_max_secs must be >= backoff_initial_secs",
664            ));
665        }
666
667        // Auto-rejoin.
668        if self.auto_rejoin.required_consecutive_passes == 0 {
669            issues.push(RemediationIssue::error(
670                "remediation.auto_rejoin.required_consecutive_passes",
671                "required_consecutive_passes must be at least 1",
672            ));
673        }
674        if self.auto_rejoin.check_interval_secs == 0 {
675            issues.push(RemediationIssue::error(
676                "remediation.auto_rejoin.check_interval_secs",
677                "check_interval_secs must be greater than 0",
678            ));
679        }
680        if self.auto_rejoin.probe_timeout_secs == 0 {
681            issues.push(RemediationIssue::error(
682                "remediation.auto_rejoin.probe_timeout_secs",
683                "probe_timeout_secs must be greater than 0",
684            ));
685        }
686        if !self.auto_rejoin.min_disk_free_gb.is_finite() || self.auto_rejoin.min_disk_free_gb < 0.0
687        {
688            issues.push(RemediationIssue::error(
689                "remediation.auto_rejoin.min_disk_free_gb",
690                "min_disk_free_gb must be finite and >= 0",
691            ));
692        }
693        if !self.auto_rejoin.max_load_per_core.is_finite()
694            || self.auto_rejoin.max_load_per_core <= 0.0
695        {
696            issues.push(RemediationIssue::error(
697                "remediation.auto_rejoin.max_load_per_core",
698                "max_load_per_core must be finite and > 0",
699            ));
700        }
701        if self.auto_rejoin.canary_command.trim().is_empty() {
702            issues.push(RemediationIssue::error(
703                "remediation.auto_rejoin.canary_command",
704                "canary_command must not be empty",
705            ));
706        }
707        for root in &self.auto_rejoin.disk_roots {
708            check_managed_path("remediation.auto_rejoin.disk_roots", root, &mut issues);
709        }
710
711        // Reconciliation.
712        if self.reconciliation.max_attempts == 0 {
713            issues.push(RemediationIssue::error(
714                "remediation.reconciliation.max_attempts",
715                "max_attempts must be at least 1",
716            ));
717        }
718        if self.reconciliation.time_budget_secs == 0 {
719            issues.push(RemediationIssue::error(
720                "remediation.reconciliation.time_budget_secs",
721                "time_budget_secs must be greater than 0",
722            ));
723        }
724
725        // Proof store path.
726        if let Some(path) = &self.proof.store_path {
727            check_managed_path("remediation.proof.store_path", path, &mut issues);
728        }
729
730        // Incident ledger.
731        if self.incident_ledger.max_entries == 0 {
732            issues.push(RemediationIssue::error(
733                "remediation.incident_ledger.max_entries",
734                "max_entries must be at least 1",
735            ));
736        }
737        if self.incident_ledger.max_bytes == 0 {
738            issues.push(RemediationIssue::error(
739                "remediation.incident_ledger.max_bytes",
740                "max_bytes must be greater than 0",
741            ));
742        }
743        if let Some(path) = &self.incident_ledger.path {
744            check_managed_path("remediation.incident_ledger.path", path, &mut issues);
745        }
746
747        // Build root.
748        if self.build_root.min_free_bytes == 0 {
749            issues.push(RemediationIssue::error(
750                "remediation.build_root.min_free_bytes",
751                "min_free_bytes must be greater than 0",
752            ));
753        }
754
755        // Pooled target.
756        if self.pooled_target.reaper_enabled && self.pooled_target.reaper_interval_mins == 0 {
757            issues.push(RemediationIssue::error(
758                "remediation.pooled_target.reaper_interval_mins",
759                "reaper_interval_mins must be > 0 when the reaper is enabled",
760            ));
761        }
762        check_managed_path(
763            "remediation.pooled_target.remote_base",
764            &self.pooled_target.remote_base,
765            &mut issues,
766        );
767        if let Some(store_base) = self.pooled_target.store_base.as_deref() {
768            check_managed_path(
769                "remediation.pooled_target.store_base",
770                store_base,
771                &mut issues,
772            );
773            // The value is embedded in remote shell commands (sweep/enumerate)
774            // and in CARGO_TARGET_DIR, so hold it to the same shape as every
775            // other reap base: absolute, no traversal, at least two levels.
776            if !crate::stale_target_reap::is_safe_reap_base(store_base) {
777                issues.push(RemediationIssue::error(
778                    "remediation.pooled_target.store_base",
779                    format!(
780                        "{store_base:?} is not a usable pooled-store base (absolute, no `..`, at least two levels deep)"
781                    ),
782                ));
783            }
784        }
785        if self.pooled_target.reaper_pooled_idle_hours != 0
786            && self.pooled_target.reaper_pooled_idle_hours < 24
787        {
788            issues.push(RemediationIssue::error(
789                "remediation.pooled_target.reaper_pooled_idle_hours",
790                "reaper_pooled_idle_hours must be 0 (disabled) or >= 24 — pooled dirs are warm caches, not short-TTL targets",
791            ));
792        }
793        for (idx, root) in self.pooled_target.gc_extra_roots.iter().enumerate() {
794            check_managed_path(
795                "remediation.pooled_target.gc_extra_roots",
796                root,
797                &mut issues,
798            );
799            if !crate::stale_target_reap::is_safe_reap_base(root) {
800                issues.push(RemediationIssue::error(
801                    "remediation.pooled_target.gc_extra_roots",
802                    format!(
803                        "gc_extra_roots[{idx}] {root:?} is not a usable gc scan root (absolute, no `..`, no shell metacharacters)"
804                    ),
805                ));
806            }
807        }
808
809        // Telemetry freshness.
810        if self.telemetry_freshness.max_age_secs == 0 {
811            issues.push(RemediationIssue::error(
812                "remediation.telemetry_freshness.max_age_secs",
813                "max_age_secs must be greater than 0",
814            ));
815        }
816
817        // Log retention.
818        if self.log_retention.max_file_bytes == 0 {
819            issues.push(RemediationIssue::error(
820                "remediation.log_retention.max_file_bytes",
821                "max_file_bytes must be greater than 0",
822            ));
823        }
824        if self.log_retention.critical_total_bytes < self.log_retention.warn_total_bytes {
825            issues.push(RemediationIssue::error(
826                "remediation.log_retention.critical_total_bytes",
827                "critical_total_bytes must be >= warn_total_bytes",
828            ));
829        }
830
831        // Disk pressure ordering and range.
832        let dp = &self.disk_pressure;
833        for (field, pct) in [
834            (
835                "remediation.disk_pressure.warning_avail_pct",
836                dp.warning_avail_pct,
837            ),
838            (
839                "remediation.disk_pressure.critical_avail_pct",
840                dp.critical_avail_pct,
841            ),
842        ] {
843            if !pct.is_finite() || !(0.0..=100.0).contains(&pct) {
844                issues.push(RemediationIssue::error(
845                    field,
846                    "available-percent thresholds must be within [0.0, 100.0]",
847                ));
848            }
849        }
850        if dp.critical_avail_pct > dp.warning_avail_pct {
851            issues.push(RemediationIssue::error(
852                "remediation.disk_pressure.critical_avail_pct",
853                "critical_avail_pct must be <= warning_avail_pct",
854            ));
855        }
856        if dp.critical_avail_inodes > dp.warning_avail_inodes {
857            issues.push(RemediationIssue::error(
858                "remediation.disk_pressure.critical_avail_inodes",
859                "critical_avail_inodes must be <= warning_avail_inodes",
860            ));
861        }
862
863        // Smoke.
864        if self.smoke.iterations == 0 {
865            issues.push(RemediationIssue::error(
866                "remediation.smoke.iterations",
867                "iterations must be at least 1",
868            ));
869        }
870
871        issues
872    }
873
874    /// A redacted clone: every path-like field has its home/user segment masked
875    /// so the config is safe to print in status output, logs, and proof JSON.
876    #[must_use]
877    pub fn redacted(&self) -> Self {
878        let mut out = self.clone();
879        out.auto_rejoin.disk_roots = out
880            .auto_rejoin
881            .disk_roots
882            .iter()
883            .map(|p| redact_path(p))
884            .collect();
885        out.proof.store_path = out.proof.store_path.as_deref().map(redact_path);
886        out.incident_ledger.path = out.incident_ledger.path.as_deref().map(redact_path);
887        out.pooled_target.remote_base = redact_path(&out.pooled_target.remote_base);
888        out.pooled_target.store_base = out.pooled_target.store_base.as_deref().map(redact_path);
889        out.pooled_target.gc_extra_roots = out
890            .pooled_target
891            .gc_extra_roots
892            .iter()
893            .map(|root| redact_path(root.as_str()))
894            .collect();
895        out
896    }
897
898    /// The machine-readable JSON Schema for the remediation config.
899    ///
900    /// This is the "machine-readable schema" half of the documented-defaults
901    /// acceptance criterion; [`RemediationConfig::human_help`] is the human half.
902    #[must_use]
903    pub fn schema_json() -> serde_json::Value {
904        serde_json::to_value(
905            SchemaSettings::draft07()
906                .into_generator()
907                .into_root_schema_for::<RemediationConfig>(),
908        )
909        .expect("RemediationConfig schema serializes")
910    }
911
912    /// A human-readable summary of every knob and its default value.
913    #[must_use]
914    pub fn human_help() -> String {
915        let d = RemediationConfig::default();
916        let mut s = String::new();
917        s.push_str("[remediation] — session-history remediation defaults\n");
918        s.push_str("  policy.hook_exec_fail_open      = ");
919        s.push_str(&d.policy.hook_exec_fail_open.to_string());
920        s.push_str("  (ordinary hook/exec falls back to local on error)\n");
921        s.push_str("  policy.proof_mode_fail_closed   = ");
922        s.push_str(&d.policy.proof_mode_fail_closed.to_string());
923        s.push_str("  (proof mode refuses local fallback)\n");
924        s.push_str(&format!(
925            "  temporary_bypass.backoff_initial_secs = {}\n",
926            d.temporary_bypass.backoff_initial_secs
927        ));
928        s.push_str(&format!(
929            "  temporary_bypass.backoff_max_secs     = {}\n",
930            d.temporary_bypass.backoff_max_secs
931        ));
932        s.push_str(&format!(
933            "  auto_rejoin.required_consecutive_passes = {}\n",
934            d.auto_rejoin.required_consecutive_passes
935        ));
936        s.push_str(&format!(
937            "  auto_rejoin.canary_required           = {}\n",
938            d.auto_rejoin.canary_required
939        ));
940        s.push_str(&format!(
941            "  auto_rejoin.check_interval_secs       = {}\n",
942            d.auto_rejoin.check_interval_secs
943        ));
944        s.push_str(&format!(
945            "  auto_rejoin.probe_timeout_secs        = {}\n",
946            d.auto_rejoin.probe_timeout_secs
947        ));
948        s.push_str(&format!(
949            "  reconciliation.max_attempts           = {}\n",
950            d.reconciliation.max_attempts
951        ));
952        s.push_str(&format!(
953            "  reconciliation.time_budget_secs       = {}\n",
954            d.reconciliation.time_budget_secs
955        ));
956        s.push_str(&format!(
957            "  proof.default_stale_source_policy     = {:?}\n",
958            d.proof.default_stale_source_policy
959        ));
960        s.push_str(&format!(
961            "  incident_ledger.max_entries           = {}\n",
962            d.incident_ledger.max_entries
963        ));
964        s.push_str(&format!(
965            "  incident_ledger.max_bytes             = {}\n",
966            d.incident_ledger.max_bytes
967        ));
968        s.push_str(&format!(
969            "  build_root.min_free_bytes             = {}\n",
970            d.build_root.min_free_bytes
971        ));
972        s.push_str(&format!(
973            "  build_root.min_free_inodes            = {}\n",
974            d.build_root.min_free_inodes
975        ));
976        s.push_str(&format!(
977            "  pooled_target.pooling_enabled         = {}\n",
978            d.pooled_target.pooling_enabled
979        ));
980        s.push_str(&format!(
981            "  pooled_target.reaper_enabled          = {}\n",
982            d.pooled_target.reaper_enabled
983        ));
984        s.push_str(&format!(
985            "  telemetry_freshness.max_age_secs      = {}\n",
986            d.telemetry_freshness.max_age_secs
987        ));
988        s.push_str(&format!(
989            "  log_retention.max_file_bytes          = {}\n",
990            d.log_retention.max_file_bytes
991        ));
992        s.push_str(&format!(
993            "  disk_pressure.warning_avail_pct       = {}\n",
994            d.disk_pressure.warning_avail_pct
995        ));
996        s.push_str(&format!(
997            "  disk_pressure.critical_avail_pct      = {}\n",
998            d.disk_pressure.critical_avail_pct
999        ));
1000        s.push_str(&format!(
1001            "  smoke.iterations                      = {}\n",
1002            d.smoke.iterations
1003        ));
1004        s.push_str(&format!(
1005            "  smoke.seed                            = {}\n",
1006            d.smoke.seed
1007        ));
1008        s
1009    }
1010}
1011
1012/// Whether `path` resolves under a known RCH-managed/standard root.
1013#[must_use]
1014fn is_managed_path(path: &str) -> bool {
1015    if path.contains("/rch") {
1016        return true;
1017    }
1018    RCH_MANAGED_ROOT_PREFIXES
1019        .iter()
1020        .any(|prefix| path == *prefix || path.starts_with(&format!("{prefix}/")))
1021}
1022
1023/// Push an error for a relative path or a warning for an operator path outside
1024/// the RCH-managed roots. Empty strings are ignored (treated as "unset").
1025fn check_managed_path(field: &str, path: &str, issues: &mut Vec<RemediationIssue>) {
1026    if path.trim().is_empty() {
1027        return;
1028    }
1029    // A leading `~` expands to an absolute home path at use time.
1030    let absolute = path.starts_with('/') || path.starts_with('~');
1031    if !absolute {
1032        issues.push(RemediationIssue::error(
1033            field,
1034            format!("path must be absolute, got {path:?}"),
1035        ));
1036        return;
1037    }
1038    if !is_managed_path(path) {
1039        issues.push(RemediationIssue::warning(
1040            field,
1041            format!(
1042                "{path:?} is outside the RCH-managed roots; ensure it is an intended operator path"
1043            ),
1044        ));
1045    }
1046}
1047
1048// ── Conversions into rch-common runtime structs ──────────────────────────────
1049//
1050// These let runtime consumers build their existing structs from the central
1051// config; drift-guard tests assert each conversion of the *default* config
1052// reproduces the runtime struct's own `::default()`.
1053
1054impl From<&TemporaryBypassConfig> for crate::bypass_record::BypassBackoff {
1055    fn from(cfg: &TemporaryBypassConfig) -> Self {
1056        Self {
1057            current_ms: cfg.backoff_initial_secs.saturating_mul(1000),
1058            attempts: 0,
1059            max_ms: cfg.backoff_max_secs.saturating_mul(1000),
1060        }
1061    }
1062}
1063
1064impl From<&AutoRejoinConfig> for crate::bypass_record::AutoRejoinCriteria {
1065    fn from(cfg: &AutoRejoinConfig) -> Self {
1066        Self {
1067            required_consecutive_passes: cfg.required_consecutive_passes,
1068            canary_required: cfg.canary_required,
1069        }
1070    }
1071}
1072
1073impl From<&IncidentLedgerPolicy> for IncidentLedgerConfig {
1074    fn from(cfg: &IncidentLedgerPolicy) -> Self {
1075        Self {
1076            // Validation treats a blank path as unset and a leading `~` as
1077            // absolute; honour both here, where the path is actually used.
1078            path: cfg
1079                .path
1080                .as_deref()
1081                .map(str::trim)
1082                .filter(|path| !path.is_empty())
1083                .map_or_else(default_ledger_path, |path| {
1084                    std::path::PathBuf::from(shellexpand::tilde(path).into_owned())
1085                }),
1086            max_entries: cfg.max_entries,
1087            max_bytes: cfg.max_bytes,
1088        }
1089    }
1090}
1091
1092impl From<&LogRetentionConfig> for crate::log_retention::LogRetentionPolicy {
1093    fn from(cfg: &LogRetentionConfig) -> Self {
1094        Self {
1095            max_file_bytes: cfg.max_file_bytes,
1096            keep_rotated: cfg.keep_rotated,
1097            warn_total_bytes: cfg.warn_total_bytes,
1098            critical_total_bytes: cfg.critical_total_bytes,
1099        }
1100    }
1101}
1102
1103impl From<&DiskPressureConfig> for crate::disk_pressure_report::PressureThresholds {
1104    fn from(cfg: &DiskPressureConfig) -> Self {
1105        Self {
1106            warning_avail_pct: cfg.warning_avail_pct,
1107            critical_avail_pct: cfg.critical_avail_pct,
1108            warning_avail_inodes: cfg.warning_avail_inodes,
1109            critical_avail_inodes: cfg.critical_avail_inodes,
1110        }
1111    }
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116    use super::*;
1117    use crate::bypass_record::{AutoRejoinCriteria, BypassBackoff};
1118    use crate::disk_pressure_report::PressureThresholds;
1119    use crate::log_retention::LogRetentionPolicy;
1120
1121    /// Issue #64: the pooled store base is opt-in — an unset value keeps
1122    /// today's in-mirror placement, and a set value must survive a TOML
1123    /// round-trip under its documented key.
1124    #[test]
1125    fn pooled_store_base_is_opt_in_and_round_trips() {
1126        assert_eq!(RemediationConfig::default().pooled_target.store_base, None);
1127
1128        let parsed: RemediationConfig = toml::from_str(
1129            r#"
1130            [pooled_target]
1131            store_base = "/bigdisk/rch-pools"
1132            "#,
1133        )
1134        .expect("parse pooled_target.store_base");
1135        assert_eq!(
1136            parsed.pooled_target.store_base.as_deref(),
1137            Some("/bigdisk/rch-pools")
1138        );
1139        assert!(
1140            parsed
1141                .validate()
1142                .iter()
1143                .all(|issue| issue.field != "remediation.pooled_target.store_base"),
1144            "an absolute, managed store_base must validate cleanly"
1145        );
1146
1147        let round_trip: RemediationConfig =
1148            toml::from_str(&toml::to_string(&parsed).expect("serialize")).expect("reparse");
1149        assert_eq!(
1150            round_trip.pooled_target.store_base,
1151            parsed.pooled_target.store_base
1152        );
1153
1154        // Unset stays absent from the serialized form, so existing configs are
1155        // not rewritten with a null placement.
1156        let default_toml =
1157            toml::to_string(&RemediationConfig::default()).expect("serialize default");
1158        assert!(
1159            !default_toml.contains("store_base"),
1160            "an unset store_base must not be emitted: {default_toml}"
1161        );
1162    }
1163
1164    /// The value is embedded in remote shell commands and in
1165    /// `CARGO_TARGET_DIR`, so an unsafe base is a hard config error.
1166    /// The gc scan roots must be reachable from configuration alone: the whole
1167    /// bug was that a runtime root rch writes to could not be added without a
1168    /// code change.
1169    #[test]
1170    fn gc_extra_roots_are_opt_in_and_round_trip() {
1171        assert!(
1172            RemediationConfig::default()
1173                .pooled_target
1174                .gc_extra_roots
1175                .is_empty()
1176        );
1177        let parsed: RemediationConfig = toml::from_str(
1178            r#"
1179            [pooled_target]
1180            gc_extra_roots = ["/mnt/big/rch", "/srv/rch"]
1181            gc_cargo_cache_idle_days = 21
1182            "#,
1183        )
1184        .expect("parse pooled_target.gc_extra_roots");
1185        assert_eq!(
1186            parsed.pooled_target.gc_extra_roots,
1187            vec!["/mnt/big/rch".to_string(), "/srv/rch".to_string()]
1188        );
1189        assert_eq!(parsed.pooled_target.gc_cargo_cache_idle_days, 21);
1190        let issues = parsed.validate();
1191        assert!(
1192            issues
1193                .iter()
1194                .all(|i| i.field != "remediation.pooled_target.gc_extra_roots"
1195                    || i.severity != IssueSeverity::Error),
1196            "absolute, managed roots must validate cleanly: {issues:?}"
1197        );
1198
1199        let round_trip: RemediationConfig =
1200            toml::from_str(&toml::to_string(&parsed).expect("serialize")).expect("round trip");
1201        assert_eq!(
1202            round_trip.pooled_target.gc_extra_roots,
1203            parsed.pooled_target.gc_extra_roots
1204        );
1205
1206        // An unset list is not emitted, so a default config stays tidy.
1207        let default_toml =
1208            toml::to_string(&RemediationConfig::default()).expect("serialize default");
1209        assert!(
1210            !default_toml.contains("gc_extra_roots"),
1211            "an empty gc_extra_roots must not be emitted: {default_toml}"
1212        );
1213    }
1214
1215    /// A gc root is embedded in a remote shell command, so anything that could
1216    /// break out of that context is an ERROR, not a sanitized value.
1217    #[test]
1218    fn unsafe_gc_extra_roots_are_rejected() {
1219        for bad in ["/", "relative/x", "/tmp/../etc", "/tmp/$(whoami)", "/a b/c"] {
1220            let mut config = RemediationConfig::default();
1221            config.pooled_target.gc_extra_roots = vec![bad.to_string()];
1222            let issues = config.validate();
1223            assert!(
1224                issues
1225                    .iter()
1226                    .any(|i| i.field == "remediation.pooled_target.gc_extra_roots"
1227                        && i.severity == IssueSeverity::Error),
1228                "{bad:?} must be rejected: {issues:?}"
1229            );
1230        }
1231    }
1232
1233    #[test]
1234    fn gc_extra_roots_are_redacted_like_every_other_operator_path() {
1235        let mut config = RemediationConfig::default();
1236        config.pooled_target.gc_extra_roots = vec!["/home/carol/builds".to_string()];
1237        assert_eq!(
1238            config.redacted().pooled_target.gc_extra_roots,
1239            vec!["/home/<redacted>/builds".to_string()]
1240        );
1241    }
1242
1243    #[test]
1244    fn unsafe_pooled_store_base_is_rejected() {
1245        for bad in ["relative/path", "/", "/tmp/../etc", "/toplevel"] {
1246            let mut config = RemediationConfig::default();
1247            config.pooled_target.store_base = Some(bad.to_string());
1248            assert!(
1249                config
1250                    .validate()
1251                    .iter()
1252                    .any(|issue| issue.field == "remediation.pooled_target.store_base"),
1253                "expected {bad:?} to be rejected"
1254            );
1255        }
1256    }
1257
1258    #[test]
1259    fn default_construction_matches_documented_values() {
1260        let c = RemediationConfig::default();
1261        assert!(c.policy.hook_exec_fail_open);
1262        assert!(c.policy.proof_mode_fail_closed);
1263        assert_eq!(c.temporary_bypass.backoff_initial_secs, 30);
1264        assert_eq!(c.temporary_bypass.backoff_max_secs, 900);
1265        assert_eq!(c.auto_rejoin.required_consecutive_passes, 2);
1266        assert!(c.auto_rejoin.canary_required);
1267        assert_eq!(c.auto_rejoin.canary_command, "rustc --version");
1268        assert_eq!(c.reconciliation.max_attempts, 3);
1269        assert_eq!(c.incident_ledger.max_entries, 5_000);
1270        assert_eq!(c.incident_ledger.max_bytes, 4 * 1024 * 1024);
1271        assert_eq!(c.build_root.min_free_bytes, 2 * 1024 * 1024 * 1024);
1272        assert_eq!(c.telemetry_freshness.max_age_secs, 120);
1273        assert_eq!(c.smoke.iterations, 20);
1274    }
1275
1276    #[test]
1277    fn default_is_valid() {
1278        let issues = RemediationConfig::default().validate();
1279        assert!(issues.is_empty(), "default must be valid, got {issues:?}");
1280    }
1281
1282    #[test]
1283    fn serde_round_trip_via_toml() {
1284        let c = RemediationConfig::default();
1285        let toml_str = toml::to_string(&c).expect("serialize toml");
1286        let back: RemediationConfig = toml::from_str(&toml_str).expect("deserialize toml");
1287        assert_eq!(c, back);
1288    }
1289
1290    #[test]
1291    fn serde_round_trip_via_json() {
1292        let c = RemediationConfig::default();
1293        let json = serde_json::to_string(&c).expect("serialize json");
1294        let back: RemediationConfig = serde_json::from_str(&json).expect("deserialize json");
1295        assert_eq!(c, back);
1296    }
1297
1298    #[test]
1299    fn partial_toml_fills_missing_fields_from_default() {
1300        // Container-level #[serde(default)] must fill every omitted field.
1301        let partial = r#"
1302            [policy]
1303            hook_exec_fail_open = false
1304
1305            [auto_rejoin]
1306            check_interval_secs = 99
1307        "#;
1308        let c: RemediationConfig = toml::from_str(partial).expect("parse partial");
1309        // Overridden:
1310        assert!(!c.policy.hook_exec_fail_open);
1311        assert_eq!(c.auto_rejoin.check_interval_secs, 99);
1312        // Untouched → defaults:
1313        assert!(c.policy.proof_mode_fail_closed);
1314        assert_eq!(c.auto_rejoin.required_consecutive_passes, 2);
1315        assert_eq!(c.incident_ledger.max_entries, 5_000);
1316    }
1317
1318    #[test]
1319    fn malformed_values_are_rejected_by_validate() {
1320        let mut c = RemediationConfig::default();
1321        c.temporary_bypass.backoff_initial_secs = 0;
1322        c.auto_rejoin.check_interval_secs = 0;
1323        c.auto_rejoin.canary_command = "  ".to_string();
1324        c.disk_pressure_force_invalid();
1325        c.incident_ledger.max_entries = 0;
1326        let issues = c.validate();
1327        let fields: Vec<&str> = issues.iter().map(|i| i.field.as_str()).collect();
1328        assert!(fields.contains(&"remediation.temporary_bypass.backoff_initial_secs"));
1329        assert!(fields.contains(&"remediation.auto_rejoin.check_interval_secs"));
1330        assert!(fields.contains(&"remediation.auto_rejoin.canary_command"));
1331        assert!(fields.contains(&"remediation.incident_ledger.max_entries"));
1332        assert!(issues.iter().any(|i| i.severity == IssueSeverity::Error
1333            && i.field.starts_with("remediation.disk_pressure")));
1334    }
1335
1336    #[test]
1337    fn malformed_toml_value_type_fails_to_parse() {
1338        // backoff_initial_secs must be an integer, not a string.
1339        let bad = r#"
1340            [temporary_bypass]
1341            backoff_initial_secs = "thirty"
1342        "#;
1343        assert!(toml::from_str::<RemediationConfig>(bad).is_err());
1344    }
1345
1346    #[test]
1347    fn relative_path_is_an_error_managed_path_is_clean() {
1348        let mut c = RemediationConfig::default();
1349        c.incident_ledger.path = Some("relative/incidents.jsonl".to_string());
1350        let issues = c.validate();
1351        assert!(issues.iter().any(|i| {
1352            i.field == "remediation.incident_ledger.path" && i.severity == IssueSeverity::Error
1353        }));
1354
1355        // A managed path under /tmp/rch is clean (no issue for that field).
1356        c.incident_ledger.path = Some("/tmp/rch/incidents.jsonl".to_string());
1357        let issues = c.validate();
1358        assert!(
1359            !issues
1360                .iter()
1361                .any(|i| i.field == "remediation.incident_ledger.path")
1362        );
1363    }
1364
1365    #[test]
1366    fn operator_path_outside_managed_roots_warns() {
1367        let mut c = RemediationConfig::default();
1368        c.incident_ledger.path = Some("/var/lib/custom/incidents.jsonl".to_string());
1369        let issues = c.validate();
1370        let issue = issues
1371            .iter()
1372            .find(|i| i.field == "remediation.incident_ledger.path")
1373            .expect("operator path produces an issue");
1374        assert_eq!(issue.severity, IssueSeverity::Warning);
1375    }
1376
1377    #[test]
1378    fn redaction_masks_home_and_user_segments() {
1379        let mut c = RemediationConfig::default();
1380        c.incident_ledger.path = Some("/home/alice/.local/state/rch/incidents.jsonl".to_string());
1381        c.proof.store_path = Some("/Users/bob/proofs.jsonl".to_string());
1382        c.auto_rejoin.disk_roots = vec!["/home/carol/builds".to_string(), "/tmp/rch".to_string()];
1383        let r = c.redacted();
1384        assert_eq!(
1385            r.incident_ledger.path.as_deref(),
1386            Some("/home/<redacted>/.local/state/rch/incidents.jsonl")
1387        );
1388        assert_eq!(
1389            r.proof.store_path.as_deref(),
1390            Some("/Users/<redacted>/proofs.jsonl")
1391        );
1392        assert_eq!(
1393            r.auto_rejoin.disk_roots,
1394            vec![
1395                "/home/<redacted>/builds".to_string(),
1396                "/tmp/rch".to_string()
1397            ]
1398        );
1399        // Non-sensitive paths are untouched.
1400        assert_eq!(redact_path("/tmp/rch"), "/tmp/rch");
1401        assert_eq!(redact_path("/data/projects"), "/data/projects");
1402    }
1403
1404    #[test]
1405    fn schema_json_describes_the_struct() {
1406        let schema = RemediationConfig::schema_json();
1407        // Top-level object with our sections present.
1408        let props = schema
1409            .get("properties")
1410            .and_then(serde_json::Value::as_object)
1411            .expect("schema has properties");
1412        for section in [
1413            "policy",
1414            "temporary_bypass",
1415            "auto_rejoin",
1416            "reconciliation",
1417            "proof",
1418            "incident_ledger",
1419            "build_root",
1420            "pooled_target",
1421            "telemetry_freshness",
1422            "log_retention",
1423            "disk_pressure",
1424            "smoke",
1425        ] {
1426            assert!(
1427                props.contains_key(section),
1428                "schema missing section {section}"
1429            );
1430        }
1431    }
1432
1433    #[test]
1434    fn human_help_lists_key_defaults() {
1435        let help = RemediationConfig::human_help();
1436        assert!(help.contains("hook_exec_fail_open"));
1437        assert!(help.contains("proof_mode_fail_closed"));
1438        assert!(help.contains("backoff_initial_secs"));
1439        assert!(help.contains("incident_ledger.max_entries"));
1440    }
1441
1442    // ── Drift guards: central defaults reproduce runtime struct defaults ──
1443
1444    #[test]
1445    fn drift_guard_bypass_backoff() {
1446        let from_cfg = BypassBackoff::from(&TemporaryBypassConfig::default());
1447        assert_eq!(from_cfg, BypassBackoff::default());
1448    }
1449
1450    #[test]
1451    fn drift_guard_auto_rejoin_criteria() {
1452        let from_cfg = AutoRejoinCriteria::from(&AutoRejoinConfig::default());
1453        assert_eq!(from_cfg, AutoRejoinCriteria::default());
1454    }
1455
1456    #[test]
1457    fn drift_guard_log_retention() {
1458        let from_cfg = LogRetentionPolicy::from(&LogRetentionConfig::default());
1459        assert_eq!(from_cfg, LogRetentionPolicy::default());
1460    }
1461
1462    #[test]
1463    fn drift_guard_disk_pressure() {
1464        let from_cfg = PressureThresholds::from(&DiskPressureConfig::default());
1465        assert_eq!(from_cfg, PressureThresholds::default());
1466    }
1467
1468    #[test]
1469    fn drift_guard_incident_ledger() {
1470        let from_cfg = IncidentLedgerConfig::from(&IncidentLedgerPolicy::default());
1471        let runtime = IncidentLedgerConfig::default();
1472        // IncidentLedgerConfig has no PartialEq; compare field-by-field.
1473        assert_eq!(from_cfg.max_entries, runtime.max_entries);
1474        assert_eq!(from_cfg.max_bytes, runtime.max_bytes);
1475        assert_eq!(from_cfg.path, runtime.path);
1476    }
1477
1478    #[test]
1479    fn incident_ledger_path_expands_tilde_and_treats_blank_as_unset() {
1480        let with = |path: &str| {
1481            IncidentLedgerConfig::from(&IncidentLedgerPolicy {
1482                path: Some(path.to_owned()),
1483                ..IncidentLedgerPolicy::default()
1484            })
1485            .path
1486        };
1487        let expanded = with("~/state/incidents.jsonl");
1488        assert!(expanded.is_absolute(), "{expanded:?}");
1489        assert!(expanded.ends_with("state/incidents.jsonl"));
1490        assert_eq!(with("  "), IncidentLedgerConfig::default().path);
1491        assert_eq!(with("/x/i.jsonl"), std::path::PathBuf::from("/x/i.jsonl"));
1492    }
1493
1494    // Test-only helper to force an invalid disk-pressure ordering.
1495    impl RemediationConfig {
1496        fn disk_pressure_force_invalid(&mut self) {
1497            self.disk_pressure.critical_avail_pct = 90.0;
1498            self.disk_pressure.warning_avail_pct = 10.0;
1499        }
1500    }
1501}