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