Skip to main content

subc_daemon/
daemon_config.rs

1use std::{
2    collections::BTreeMap,
3    env,
4    error::Error,
5    ffi::OsString,
6    fmt, fs, io,
7    path::{Path, PathBuf},
8    time::Duration,
9};
10
11use cortexkit_log::Retention;
12use serde::Deserialize;
13use subc_control::ModuleProtocol;
14use subc_jsonc::jsonc_to_json;
15use subc_protocol::manifest::is_valid_capability_identifier;
16
17use crate::{
18    supervise::{ModuleOverlap, SUBC_SPAWN_ROLE_ENV},
19    HealthAction, HealthConfig, ModuleSpec, RestartPolicy,
20};
21
22const DAEMON_CONFIG_RELATIVE_PATH: &str = "cortexkit/subc.jsonc";
23const SUPPORTED_CONFIG_VERSION: u32 = 1;
24pub(crate) const CK_LOG_ENV: &str = "CK_LOG";
25pub(crate) const CAPTURE_MAX_FILE_MB_ENV: &str = "__SUBC_CAPTURE_LOG_MAX_FILE_MB";
26pub(crate) const CAPTURE_KEEP_ENV: &str = "__SUBC_CAPTURE_LOG_KEEP";
27pub(crate) const CAPTURE_MAX_AGE_DAYS_ENV: &str = "__SUBC_CAPTURE_LOG_MAX_AGE_DAYS";
28/// The child's own segment retention, read by `cortexkit_log::Config::from_env`.
29/// Unlike the `__SUBC_CAPTURE_*` names above these are a real child-process
30/// contract and are spawned into the environment.
31pub(crate) const CHILD_LOG_MAX_AGE_DAYS_ENV: &str = "CK_LOG_MAX_AGE_DAYS";
32pub(crate) const CHILD_LOG_ALARM_SEGMENT_MB_ENV: &str = "CK_LOG_ALARM_SEGMENT_MB";
33
34/// Top-level daemon config sections that rescan cannot apply. The daemon
35/// snapshots these sections at start and reports later rescan changes as
36/// `restart_required`. Setup intersects this set with sections core
37/// configuration would write so a dry-run can flag a restart before the
38/// config file exists on disk. Match this enum exhaustively so a new section
39/// cannot be added without a comparison.
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub enum RestartRequiredSection {
42    Port,
43    Storage,
44    AdmissionFactsCarrierModuleId,
45    AdmissionFactsTargets,
46    ScopeAuthorityOwners,
47}
48
49impl RestartRequiredSection {
50    pub const ALL: [Self; 5] = [
51        Self::Port,
52        Self::Storage,
53        Self::AdmissionFactsCarrierModuleId,
54        Self::AdmissionFactsTargets,
55        Self::ScopeAuthorityOwners,
56    ];
57
58    pub const fn label(self) -> &'static str {
59        match self {
60            Self::Port => "port",
61            Self::Storage => "storage",
62            Self::AdmissionFactsCarrierModuleId => "admission_facts_carrier_module_id",
63            Self::AdmissionFactsTargets => "admission_facts_targets",
64            Self::ScopeAuthorityOwners => "scope_authority_owners",
65        }
66    }
67}
68
69/// `scope_authority_owners` when the config does not set it: the session
70/// runtime, the same module that carries admission facts today.
71pub fn default_scope_authority_owners() -> Vec<String> {
72    vec!["prefrontal-core".to_string()]
73}
74
75/// Refused at parse time by both layers (daemon-wide and per-module) — `0`
76/// would turn every affected bind into an instant failure, which is not a
77/// posture anyone deliberately configures. The asymmetry with
78/// `drain_timeout_ms` (which accepts `0` as a legitimate "tear down now")
79/// is intentional: drain `0` is an *action* an operator takes during a
80/// wedge bounce; bind `0` is a typo wearing a config key. Operators who
81/// want a module unreachable should use `enabled: false` instead.
82///
83/// The per-module variant prefixes the offending module id before this
84/// message — see `parse_doc`.
85const ROUTE_BIND_RELAY_ZERO_MESSAGE: &str = "route_bind_relay_timeout_ms must be greater than 0 (a zero budget fails every bind to the module; to make a module unreachable use enabled: false)";
86
87/// Refused at parse time because a zero window and a large one are different
88/// settings that look alike in a diff. The crash budget counts restarts inside
89/// `window_secs`; with `0`, no restart is ever inside it, so the cap can never
90/// be reached and the module restarts forever. That is a real posture, but it
91/// is "unlimited restarts", and anyone choosing it must say so by name rather
92/// than by writing a zero that reads like "no delay".
93const RESTART_WINDOW_ZERO_MESSAGE: &str = "restart.window_secs must be greater than 0 (a zero window holds no crash, so the budget can never be spent; for effectively unlimited restarts set a deliberately large window_secs, and to stop restarting entirely set restart.max_restarts: 0)";
94
95/// Logging policy parsed from `subc.jsonc`.
96///
97/// `retention` is the rename-rotating policy for the daemon's per-child
98/// stderr CAPTURE file (single writer). The daemon's own log and every module's
99/// log are date segments under fleet-logging r2, which never rotate; for those
100/// only `retention.max_age_days` applies, plus `alarm_segment_mb`.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct LoggingConfig {
103    pub level: String,
104    /// Per-logger levels. Keys are logger names; a key with no dot is taken
105    /// as a COMPONENT of the module it is configured on (`perf` on synapse is
106    /// `synapse.perf`), so an operator's `subc.jsonc` reads naturally. See
107    /// [`LoggingConfig::filter_spec`].
108    pub tags: BTreeMap<String, String>,
109    pub retention: Retention,
110    /// Segment size at which the writer alarms (never truncates).
111    pub alarm_segment_mb: u32,
112}
113
114impl LoggingConfig {
115    /// The `CK_LOG` value for `module_id`. Logger names in `CK_LOG` are
116    /// absolute (`synapse.perf=info`), while the config block is written per
117    /// module, so a dotless key is prefixed with the module id here. A key
118    /// that already starts with `<module_id>.` or contains a dot is passed
119    /// verbatim; a key equal to the module id is the root and is also
120    /// verbatim. Without this a config `tags: { perf: debug }` would emit
121    /// `perf=debug`, which matches no logger on the r2 hierarchy and silently
122    /// does nothing.
123    pub fn filter_spec(&self, module_id: &str) -> String {
124        let mut directives = vec![self.level.clone()];
125        directives.extend(self.tags.iter().map(|(logger, level)| {
126            if logger == module_id || logger.contains('.') {
127                format!("{logger}={level}")
128            } else {
129                format!("{module_id}.{logger}={level}")
130            }
131        }));
132        directives.join(",")
133    }
134
135    pub fn segment_retention(&self) -> cortexkit_log::SegmentRetention {
136        cortexkit_log::SegmentRetention {
137            max_age_days: self.retention.max_age_days,
138            alarm_segment_mb: self.alarm_segment_mb,
139        }
140    }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct DaemonConfig {
145    pub path: PathBuf,
146    pub port: Option<u16>,
147    /// Daemon-wide default drain budget (ms) for module teardown: how long a
148    /// drain waits for already-dispatched requests to finalize. `None` uses
149    /// the built-in default (30s). Per-module `drain_timeout_ms` overrides.
150    pub drain_timeout_ms: Option<u64>,
151    /// Daemon-wide default route.bind relay budget (ms): how long the daemon
152    /// waits for the target module to acknowledge a relayed `route.bind` before
153    /// reporting `module_timeout`. `None` uses the built-in default (12s, set
154    /// in `control::DEFAULT_ROUTE_BIND_RELAY_TIMEOUT`). Per-module
155    /// `route_bind_relay_timeout_ms` overrides. `0` is refused at parse time
156    /// (a zero budget fails every bind; use `enabled: false` to make a
157    /// module unreachable) — this is deliberately asymmetric with
158    /// `drain_timeout_ms`, where `0` is the sanctioned "tear down now".
159    pub route_bind_relay_timeout_ms: Option<u64>,
160    pub modules: Vec<ConfiguredModule>,
161    /// Central storage policy: the single backend choice all managed modules use.
162    /// `None` when the config has no `storage` section (no managed storage).
163    pub storage: Option<StorageConfig>,
164    /// Exact module id whose reserved process may carry admission facts.
165    pub admission_facts_carrier_module_id: Option<String>,
166    /// Exact target module ids that may receive facts from the configured carrier.
167    pub admission_facts_targets: Option<Vec<String>>,
168    /// Module ids whose scopes may set the attributes that grant authority
169    /// (`agent_id`, `delegates`), and whose scopes are stamped
170    /// `owner_authorized`. Restart-required: a change reaching a running daemon
171    /// would leave live routes holding an `owner_authorized` stamp the owner no
172    /// longer has, and a restart closes every route.
173    pub scope_authority_owners: Vec<String>,
174    /// Capability names reserved to one module id. The binding may name a module
175    /// that is not configured yet so an operator can reserve an interface before
176    /// installing its provider.
177    pub reserved_capabilities: BTreeMap<String, String>,
178}
179
180/// Central storage configuration: one backend for every managed module. subc
181/// resolves this into a per-module storage descriptor and delivers it in the
182/// module's HELLO_ACK; the module opens it via the shared store library.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub enum StorageConfig {
185    /// Each module gets its own sqlite file under `data_home`.
186    Sqlite { data_home: PathBuf },
187}
188
189impl StorageConfig {
190    /// Resolve this central policy into a module's storage descriptor: the opaque
191    /// JSON delivered in `HELLO_ACK.storage`. The shape matches
192    /// `cortexkit_store_types::StorageDescriptor` (subc constructs it by hand to
193    /// avoid a database-library dependency in the thin daemon). The module
194    /// deserializes it into that type and hands it to `cortexkit-store`.
195    ///
196    /// THE DESCRIPTOR IS ADVISORY, NOT BINDING, and the daemon has no way to
197    /// tell whether a module consumed it. A module that opens its store BEFORE
198    /// connecting -- building its own descriptor from an environment variable --
199    /// never reads this at all, and nothing on the wire reports that.
200    ///
201    /// Two consequences worth knowing before reasoning from a store path:
202    ///
203    /// * A store at the path below does NOT prove the descriptor arrived or was
204    ///   keyed correctly; a self-keying module can land on the same path by
205    ///   agreeing with the convention rather than by consuming the descriptor.
206    ///   Any test asserting "the store landed under MODULE_ID" proves the
207    ///   daemon's half only for modules that derive the path from the id they
208    ///   claimed.
209    /// * Where a self-keying module disagrees, BOTH paths can exist. Observed on
210    ///   the live box: astrocyte is handed a data dir already ending in
211    ///   `cortexkit/astrocyte` and appends the same suffix again, so its real
212    ///   store sits nested while an empty file remains at the path this function
213    ///   names -- and a reader inspecting that directory would reasonably
214    ///   conclude the module has an empty store.
215    pub fn descriptor_for(&self, module_id: &str) -> serde_json::Value {
216        match self {
217            // Path convention mirrors cortexkit_store_types::sqlite_store_path:
218            // <data_home>/cortexkit/<module_id>/store.db. One database per module;
219            // a project-scoped module partitions its own rows internally.
220            //
221            // Build the path with forward slashes (NOT PathBuf::join, which inserts
222            // backslashes on Windows) so the delivered wire descriptor is identical
223            // cross-platform and byte-matches the store-types helper. Forward-slash
224            // paths are accepted by sqlite on every platform.
225            StorageConfig::Sqlite { data_home } => {
226                let data_home = data_home.to_string_lossy();
227                let path = format!(
228                    "{}/cortexkit/{module_id}/store.db",
229                    data_home.trim_end_matches('/')
230                );
231                serde_json::json!({
232                    "module_id": module_id,
233                    "storage_namespace": "default",
234                    "isolation": { "kind": "module" },
235                    "backend": { "backend": "sqlite", "path": path },
236                })
237            }
238        }
239    }
240}
241
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct ConfiguredModule {
244    pub module_id: String,
245    pub program: PathBuf,
246    pub args: Vec<String>,
247    pub env: Vec<(String, String)>,
248    /// Effective module logging policy. An absent module block inherits the
249    /// daemon-wide logging block; when neither exists this stays absent so
250    /// `CK_LOG` is genuinely absent from the service-manager-minimal child env.
251    pub log: Option<LoggingConfig>,
252    pub enabled: bool,
253    /// When true, only the daemon-spawned process for this `module_id` may register
254    /// it: subc injects a one-time launch nonce on spawn and rejects any HELLO for
255    /// this id whose nonce does not match. Protects security-boundary modules (e.g.
256    /// the credential vault) from being impersonated by another key-holder while the
257    /// real process is down or restarting. Defaults to false.
258    pub reserved: bool,
259    /// Namespace prefixes owned by this reserved, supervised module. A HELLO for a
260    /// module id under one of these prefixes must echo this owner module's current
261    /// spawn nonce.
262    pub reserved_prefixes: Vec<String>,
263    /// Which wire protocol this module speaks, as declared. Absent in config
264    /// means `Subc`, which is what every module written before this key meant.
265    pub protocol: ModuleProtocol,
266    /// Whether a second process of this module may run beside the first, which
267    /// a blue/green swap does. Absent in config means exclusive.
268    pub overlap: ModuleOverlap,
269    pub health: HealthConfig,
270    /// Effective drain budget (ms) for this module's teardown, already resolved
271    /// against the daemon-wide default at parse time. `None` = built-in default.
272    pub drain_timeout_ms: Option<u64>,
273    /// Effective route.bind relay budget (ms) for this module, already resolved
274    /// against the daemon-wide default at parse time. `None` = built-in default
275    /// (12s). A `0` is refused at parse time at both layers — see
276    /// `DaemonConfig::route_bind_relay_timeout_ms` and `ROUTE_BIND_RELAY_ZERO_MESSAGE`.
277    pub route_bind_relay_timeout_ms: Option<u64>,
278    /// This module's crash-restart budget, fully resolved at parse time: every
279    /// absent key of the optional `restart` block falls back to the supervisor
280    /// default (3 restarts per 600s, 100ms base backoff, 30s maximum backoff).
281    /// Stored resolved rather than as an `Option` so no later layer has to
282    /// re-derive the defaults and get them subtly different.
283    ///
284    /// Read when a module STARTS being supervised (daemon start, or a rescan
285    /// that adds the module). Like `drain_timeout_ms`, an edit to this block for
286    /// an already-running module is not part of the rescan diff, so it takes
287    /// effect on the next daemon start rather than immediately.
288    pub restart: RestartPolicy,
289}
290
291impl ConfiguredModule {
292    pub fn module_spec(&self) -> ModuleSpec {
293        let mut env = self.env.clone();
294        if let Some(log) = &self.log {
295            env.retain(|(key, _)| {
296                key != CK_LOG_ENV
297                    && key != CAPTURE_MAX_FILE_MB_ENV
298                    && key != CAPTURE_KEEP_ENV
299                    && key != CAPTURE_MAX_AGE_DAYS_ENV
300            });
301            env.retain(|(key, _)| {
302                key != CHILD_LOG_MAX_AGE_DAYS_ENV && key != CHILD_LOG_ALARM_SEGMENT_MB_ENV
303            });
304            env.push((CK_LOG_ENV.to_string(), log.filter_spec(&self.module_id)));
305            env.push((
306                CHILD_LOG_MAX_AGE_DAYS_ENV.to_string(),
307                log.retention.max_age_days.to_string(),
308            ));
309            env.push((
310                CHILD_LOG_ALARM_SEGMENT_MB_ENV.to_string(),
311                log.alarm_segment_mb.to_string(),
312            ));
313            // The capture file's own rotation policy. These private entries are
314            // supervisor metadata and are removed before spawn: the child never
315            // sees them, and the capture sink reads them back at spawn time.
316            env.push((
317                CAPTURE_MAX_FILE_MB_ENV.to_string(),
318                log.retention.max_file_mb.to_string(),
319            ));
320            env.push((CAPTURE_KEEP_ENV.to_string(), log.retention.keep.to_string()));
321            env.push((
322                CAPTURE_MAX_AGE_DAYS_ENV.to_string(),
323                log.retention.max_age_days.to_string(),
324            ));
325        }
326        ModuleSpec {
327            module_id: self.module_id.clone(),
328            program: self.program.clone(),
329            args: self.args.clone(),
330            env,
331            reserved: self.reserved,
332            reserved_prefixes: self.reserved_prefixes.clone(),
333            protocol: self.protocol,
334            overlap: self.overlap,
335        }
336    }
337}
338
339#[derive(Debug)]
340pub enum DaemonConfigError {
341    Read {
342        path: PathBuf,
343        source: io::Error,
344    },
345    InvalidJsonc {
346        path: PathBuf,
347        message: String,
348    },
349    InvalidJson {
350        path: PathBuf,
351        source: serde_json::Error,
352    },
353    UnsupportedVersion {
354        path: PathBuf,
355        version: u32,
356    },
357    InvalidValue {
358        path: PathBuf,
359        message: String,
360    },
361}
362
363#[derive(Debug, Deserialize)]
364struct RawDaemonConfig {
365    version: u32,
366    #[serde(default)]
367    port: Option<u16>,
368    #[serde(default)]
369    drain_timeout_ms: Option<u64>,
370    #[serde(default)]
371    route_bind_relay_timeout_ms: Option<u64>,
372    #[serde(default)]
373    log: Option<RawLoggingConfig>,
374    #[serde(default)]
375    modules: BTreeMap<String, RawModuleConfig>,
376    #[serde(default)]
377    storage: Option<RawStorageConfig>,
378    #[serde(default)]
379    admission_facts_carrier_module_id: Option<String>,
380    #[serde(default)]
381    admission_facts_targets: Option<Vec<String>>,
382    #[serde(default)]
383    scope_authority_owners: Option<Vec<String>>,
384    #[serde(default)]
385    reserved_capabilities: BTreeMap<String, String>,
386}
387
388#[derive(Debug, Deserialize)]
389#[serde(tag = "backend", rename_all = "snake_case")]
390enum RawStorageConfig {
391    Sqlite {
392        /// Where per-module sqlite files live. Defaults to the platform data home
393        /// (`$XDG_DATA_HOME`, else `~/.local/share`) when omitted.
394        #[serde(default)]
395        data_home: Option<PathBuf>,
396    },
397}
398
399#[derive(Debug, Deserialize)]
400struct RawModuleConfig {
401    program: PathBuf,
402    #[serde(default)]
403    args: Vec<String>,
404    #[serde(default)]
405    env: BTreeMap<String, String>,
406    #[serde(default)]
407    log: Option<RawLoggingConfig>,
408    #[serde(default = "default_enabled")]
409    enabled: bool,
410    #[serde(default)]
411    reserved: bool,
412    #[serde(default)]
413    reserved_prefixes: Vec<String>,
414    /// Read as a raw string rather than a serde enum so an unusable value is
415    /// refused as an `InvalidValue` naming the module and the value the operator
416    /// typed, instead of a serde variant error that names neither.
417    #[serde(default)]
418    protocol: Option<String>,
419    /// Read as a raw string for the same reason as `protocol`.
420    #[serde(default)]
421    overlap: Option<String>,
422    #[serde(default)]
423    health: Option<RawHealthConfig>,
424    #[serde(default)]
425    drain_timeout_ms: Option<u64>,
426    #[serde(default)]
427    route_bind_relay_timeout_ms: Option<u64>,
428    #[serde(default)]
429    restart: Option<RawRestartConfig>,
430}
431
432#[derive(Debug, Clone, Deserialize)]
433struct RawLoggingConfig {
434    #[serde(default)]
435    level: Option<String>,
436    #[serde(default)]
437    tags: BTreeMap<String, String>,
438    #[serde(default)]
439    alarm_segment_mb: Option<u32>,
440    #[serde(default)]
441    max_file_mb: Option<u32>,
442    #[serde(default)]
443    keep: Option<u8>,
444    #[serde(default)]
445    max_age_days: Option<u32>,
446}
447
448#[derive(Debug, Deserialize)]
449struct RawRestartConfig {
450    #[serde(default)]
451    max_restarts: Option<u32>,
452    #[serde(default)]
453    window_secs: Option<u64>,
454    #[serde(default)]
455    backoff_ms: Option<u64>,
456    #[serde(default)]
457    max_backoff_ms: Option<u64>,
458}
459
460#[derive(Debug, Deserialize)]
461struct RawHealthConfig {
462    #[serde(default)]
463    cadence_ms: Option<u64>,
464    #[serde(default)]
465    deadline_ms: Option<u64>,
466    #[serde(default)]
467    failure_threshold: Option<u32>,
468    #[serde(default)]
469    on_degraded: Option<RawHealthAction>,
470    #[serde(default)]
471    on_failing: Option<RawHealthAction>,
472    #[serde(default)]
473    critical: bool,
474}
475
476#[derive(Debug, Deserialize)]
477#[serde(rename_all = "snake_case")]
478enum RawHealthAction {
479    Report,
480    Restart,
481    Alert,
482}
483
484pub fn default_config_path() -> PathBuf {
485    default_config_home().join(DAEMON_CONFIG_RELATIVE_PATH)
486}
487
488/// The XDG-style CONFIG HOME (`~/.config`, `%APPDATA%`), with no `cortexkit/`
489/// tail. This is the AUTHORITY for every module that resolves its own config
490/// file: mirrors (`cortexkit-store-types::resolve_config_home`, and any module
491/// still carrying a hand copy of this ladder) assert against
492/// `tests/golden/config_home_resolution.json` and may not diverge. It is split
493/// from `default_config_path` so the mirror and the daemon share one ladder
494/// rather than one ladder plus a tail that each copy re-appends differently --
495/// the daemon appends `cortexkit/subc.jsonc`, a module appends
496/// `cortexkit/<its file>`, and a copy that bakes the tail in cannot be reused.
497///
498/// Resolution: `XDG_CONFIG_HOME` → `APPDATA` (Windows) → `USERPROFILE` +
499/// `AppData\Roaming` (Windows) → `HOME/.config` → `.config` relative.
500/// Empty values count as unset. Mirrors the data-home ladder exactly except for
501/// the per-platform tails (`.local/share` there, `.config` here).
502///
503/// A RELATIVE result means one of two things and the resolver does not say
504/// which: no home variable was set (the final rung), or `XDG_CONFIG_HOME` was
505/// itself relative (honoured as-is, golden-pinned). Either way the path resolves
506/// against the caller's cwd, which is a true answer about a directory nobody
507/// chose. Callers that must be fail-closed check `is_absolute()` and refuse;
508/// the daemon does so for the storage descriptor it serves (`parse_doc`).
509pub fn default_config_home() -> PathBuf {
510    if let Some(config_home) = non_empty_os_var("XDG_CONFIG_HOME") {
511        return PathBuf::from(config_home);
512    }
513
514    #[cfg(windows)]
515    {
516        if let Some(app_data) = non_empty_os_var("APPDATA") {
517            return PathBuf::from(app_data);
518        }
519        if let Some(user_profile) = non_empty_os_var("USERPROFILE") {
520            return PathBuf::from(user_profile).join("AppData").join("Roaming");
521        }
522    }
523
524    if let Some(home) = non_empty_os_var("HOME") {
525        return PathBuf::from(home).join(".config");
526    }
527
528    PathBuf::from(".config")
529}
530
531pub fn load(path: impl AsRef<Path>) -> Result<Option<DaemonConfig>, DaemonConfigError> {
532    let path = path.as_ref();
533    let Some(doc) = read_config_doc(path)? else {
534        return Ok(None);
535    };
536    parse_doc(&doc, path).map(Some)
537}
538
539/// Loads only the daemon-wide logging block for tracing initialization.
540///
541/// The daemon installs its global subscriber before bootstrap parses the full
542/// configuration. A malformed full config is still reported by bootstrap after
543/// the file sink is live; this early read only chooses its filter and retention.
544pub fn load_logging(path: impl AsRef<Path>) -> Result<Option<LoggingConfig>, DaemonConfigError> {
545    let path = path.as_ref();
546    let Some(doc) = read_config_doc(path)? else {
547        return Ok(None);
548    };
549    let json = jsonc_to_json(&doc).map_err(|message| DaemonConfigError::InvalidJsonc {
550        path: path.to_path_buf(),
551        message,
552    })?;
553    let raw: RawDaemonConfig =
554        serde_json::from_str(&json).map_err(|source| DaemonConfigError::InvalidJson {
555            path: path.to_path_buf(),
556            source,
557        })?;
558    if raw.version != SUPPORTED_CONFIG_VERSION {
559        return Err(DaemonConfigError::UnsupportedVersion {
560            path: path.to_path_buf(),
561            version: raw.version,
562        });
563    }
564    raw.log
565        .map(|log| parse_logging_config(log, path, "daemon log"))
566        .transpose()
567}
568
569/// Create the daemon run directory at 0700 if absent, and tighten it if wider.
570///
571/// WHY A SEPARATE STEP RATHER THAN A MODE ON THE CREATOR. Several things create
572/// this directory and none of them owns it: the log sink's `create_dir_all`
573/// (0777 & ~umask, so 0755 on a default desk), the terminal journal, and the
574/// connection-file writer -- which DOES build its parents at 0700, but returns
575/// early when the directory already exists, because an existing directory keeps
576/// its mode. So the first creator to run decides the mode for every later one,
577/// and on this fleet that was the log sink.
578///
579/// WHAT THE BIT COSTS, stated so nobody over- or under-reads it: the connection
580/// secret inside is written 0600 and was never readable by another account. A
581/// world-listable run directory leaks the MAP -- which modules are live and what
582/// their connection files are named -- not the key. It is worth closing anyway
583/// because the map is reconnaissance and costs nothing to withhold. (Found by
584/// prefrontal's campaign-rig isolation probe, 2026-09-20, on a real desk.)
585///
586/// TIGHTENING IS BEST-EFFORT AND NEVER FATAL. The daemon does not own every
587/// deployment: a directory it cannot chmod belongs to someone else, and refusing
588/// to boot over a permission bit would trade a reconnaissance leak for an
589/// outage. The caller logs what it could not do.
590///
591/// A run directory that cannot be resolved (see [`daemon_run_dir`]) is reported
592/// as an `InvalidInput` I/O error rather than created under the working
593/// directory.
594pub fn ensure_daemon_run_dir_private() -> Result<PathBuf, io::Error> {
595    let path = daemon_run_dir()
596        .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
597    ensure_directory_private(&path)?;
598    Ok(path)
599}
600
601/// The policy half, taking the directory so a test drives a real one without
602/// touching the process environment (this crate forbids unsafe, and `set_var` is
603/// unsafe in this edition -- which is the better outcome: the seam is a parameter
604/// rather than a global the test has to fight).
605#[cfg(unix)]
606fn ensure_directory_private(path: &Path) -> Result<(), io::Error> {
607    use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
608
609    if !path.exists() {
610        fs::DirBuilder::new()
611            .recursive(true)
612            .mode(0o700)
613            .create(path)?;
614        return Ok(());
615    }
616    let mode = fs::metadata(path)?.permissions().mode() & 0o777;
617    if mode & 0o077 != 0 {
618        fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
619    }
620    Ok(())
621}
622
623/// Windows has no mode bits to tighten; the directory is created on first use.
624#[cfg(not(unix))]
625fn ensure_directory_private(path: &Path) -> Result<(), io::Error> {
626    if !path.exists() {
627        fs::create_dir_all(path)?;
628    }
629    Ok(())
630}
631
632/// Existing per-user daemon run directory (`<data-home>/cortexkit/run`).
633///
634/// Refuses a relative data home (HOME and XDG_DATA_HOME both unset, or a
635/// relative XDG_DATA_HOME) instead of resolving it against the working
636/// directory. Resolving it there once wrote a stray `.local/` tree into a crate
637/// directory and dirtied a release build: the run directory holds the
638/// connection file, the terminal journal and the daemon's logs, so it must not
639/// depend on where a process happened to be started. The storage data home is
640/// refused at config parse for the same reason.
641pub fn daemon_run_dir() -> Result<PathBuf, DaemonRunDirError> {
642    daemon_run_dir_from(default_data_home())
643}
644
645/// The policy half of [`daemon_run_dir`], taking the data home as a parameter so
646/// a test can drive both outcomes without touching the process environment.
647fn daemon_run_dir_from(data_home: PathBuf) -> Result<PathBuf, DaemonRunDirError> {
648    if !data_home.is_absolute() {
649        return Err(DaemonRunDirError::RelativeDataHome { data_home });
650    }
651    Ok(data_home.join("cortexkit").join("run"))
652}
653
654/// Why the daemon run directory could not be resolved.
655#[derive(Debug, Clone, PartialEq, Eq)]
656pub enum DaemonRunDirError {
657    /// The data home resolved to a relative path, which would place the run
658    /// directory under whatever directory the process was started from.
659    RelativeDataHome { data_home: PathBuf },
660}
661
662impl fmt::Display for DaemonRunDirError {
663    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
664        match self {
665            Self::RelativeDataHome { data_home } => write!(
666                f,
667                "cannot resolve the daemon run directory: the data home `{}` is relative, \
668                 so it would land under the current working directory; {}",
669                data_home.display(),
670                DATA_HOME_REMEDY
671            ),
672        }
673    }
674}
675
676impl std::error::Error for DaemonRunDirError {}
677
678/// Which environment variables make the data home absolute on this platform.
679#[cfg(windows)]
680const DATA_HOME_REMEDY: &str =
681    "set XDG_DATA_HOME to an absolute path, or set APPDATA, USERPROFILE or HOME";
682#[cfg(not(windows))]
683const DATA_HOME_REMEDY: &str = "set XDG_DATA_HOME to an absolute path, or set HOME";
684
685fn read_config_doc(path: &Path) -> Result<Option<String>, DaemonConfigError> {
686    match fs::read_to_string(path) {
687        Ok(doc) => Ok(Some(doc)),
688        Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(None),
689        Err(source) => Err(DaemonConfigError::Read {
690            path: path.to_path_buf(),
691            source,
692        }),
693    }
694}
695
696fn parse_doc(doc: &str, path: &Path) -> Result<DaemonConfig, DaemonConfigError> {
697    let json = jsonc_to_json(doc).map_err(|message| DaemonConfigError::InvalidJsonc {
698        path: path.to_path_buf(),
699        message,
700    })?;
701    let raw: RawDaemonConfig =
702        serde_json::from_str(&json).map_err(|source| DaemonConfigError::InvalidJson {
703            path: path.to_path_buf(),
704            source,
705        })?;
706
707    if raw.version != SUPPORTED_CONFIG_VERSION {
708        return Err(DaemonConfigError::UnsupportedVersion {
709            path: path.to_path_buf(),
710            version: raw.version,
711        });
712    }
713
714    let daemon_logging = raw
715        .log
716        .map(|log| parse_logging_config(log, path, "daemon log"))
717        .transpose()?;
718    let default_drain_timeout_ms = raw.drain_timeout_ms;
719    // `0` here would turn every bind to a slow module into an instant failure;
720    // "off is not a budget" so refuse the key at parse time. Operators who
721    // want a module unreachable should use `enabled: false` instead. The
722    // check is per-layer (daemon-wide + per-module) because either alone
723    // poisons every affected bind.
724    let default_route_bind_relay_timeout_ms = match raw.route_bind_relay_timeout_ms {
725        Some(0) => {
726            return Err(DaemonConfigError::InvalidValue {
727                path: path.to_path_buf(),
728                message: ROUTE_BIND_RELAY_ZERO_MESSAGE.to_string(),
729            });
730        }
731        Some(value) => Some(value),
732        None => None,
733    };
734    let modules = raw
735        .modules
736        .into_iter()
737        .map(|(module_id, module)| {
738            let health = module
739                .health
740                .map(|health| parse_health_config(health, path, &module_id))
741                .transpose()?
742                .unwrap_or_default();
743            if let Err(reason) = crate::registry::module_id_path_hazard(&module_id) {
744                return Err(DaemonConfigError::InvalidValue {
745                    path: path.to_path_buf(),
746                    message: format!(
747                        "module id '{}' is not usable as a path component ({reason}): \
748                         the daemon derives each module's store path from its id",
749                        module_id.escape_debug()
750                    ),
751                });
752            }
753            // Same rejection at the per-module layer. `Some(0)` from a module
754            // is refused even when the daemon-wide value is also Some(0): the
755            // failure must name the offending module id so the operator can
756            // locate it in the file.
757            let per_module_route_bind_relay_timeout_ms = match module.route_bind_relay_timeout_ms {
758                Some(0) => {
759                    return Err(DaemonConfigError::InvalidValue {
760                        path: path.to_path_buf(),
761                        message: format!(
762                            "module '{module_id}' {ROUTE_BIND_RELAY_ZERO_MESSAGE}",
763                            module_id = module_id.escape_debug()
764                        ),
765                    });
766                }
767                Some(value) => Some(value),
768                None => default_route_bind_relay_timeout_ms,
769            };
770            let protocol = parse_module_protocol(module.protocol.as_deref(), path, &module_id)?;
771            let overlap = parse_module_overlap(module.overlap.as_deref(), path, &module_id)?;
772            // The spawn role is set by the supervisor on a swap candidate and
773            // nowhere else; a configured value would put the long swap warm-up
774            // budget on every plain restart, where callers wait on it.
775            if module.env.contains_key(SUBC_SPAWN_ROLE_ENV) {
776                return Err(DaemonConfigError::InvalidValue {
777                    path: path.to_path_buf(),
778                    message: format!(
779                        "module '{module_id}' sets {SUBC_SPAWN_ROLE_ENV} in env; that variable is set by the supervisor on a swap candidate only and cannot be configured",
780                        module_id = module_id.escape_debug()
781                    ),
782                });
783            }
784            // A reserved module is one only the daemon-spawned process may
785            // REGISTER as, enforced by matching a launch nonce in its HELLO. A
786            // module that speaks no subc wire sends no HELLO, so the gate has
787            // nothing to check and the pairing states an intent the daemon
788            // cannot carry out. Refusing at parse is better than accepting a
789            // security-looking declaration that protects nothing.
790            if protocol == ModuleProtocol::None && module.reserved {
791                return Err(DaemonConfigError::InvalidValue {
792                    path: path.to_path_buf(),
793                    message: format!(
794                        "module '{module_id}' sets reserved: true with protocol: \"none\"; \
795                         reserved is enforced on the module's HELLO and a protocol: \"none\" \
796                         module never registers, so the reservation could never be checked",
797                        module_id = module_id.escape_debug()
798                    ),
799                });
800            }
801            let restart = parse_restart_config(module.restart, path, &module_id)?;
802            let log = module
803                .log
804                .map(|log| parse_logging_config(log, path, &format!("module '{module_id}' log")))
805                .transpose()?
806                .or_else(|| daemon_logging.clone());
807            Ok(ConfiguredModule {
808                module_id,
809                program: module.program,
810                args: module.args,
811                env: module.env.into_iter().collect(),
812                log,
813                enabled: module.enabled,
814                reserved: module.reserved,
815                reserved_prefixes: module.reserved_prefixes,
816                protocol,
817                overlap,
818                health,
819                // Per-module wins; the daemon-wide value is the fallback. `0` is
820                // legitimate ("never wait"), so this is `.or`, not `filter+or`.
821                drain_timeout_ms: module.drain_timeout_ms.or(default_drain_timeout_ms),
822                // Same shape as drain: an explicit per-module value wins over
823                // the daemon-wide default. A `0` here is rejected above
824                // (see "off is not a budget"), so `None` means "use the
825                // daemon-wide value" and `Some(value > 0)` means "use this".
826                route_bind_relay_timeout_ms: per_module_route_bind_relay_timeout_ms,
827                restart,
828            })
829        })
830        .collect::<Result<Vec<_>, DaemonConfigError>>()?;
831
832    validate_reserved_prefixes(&modules, path)?;
833    validate_reserved_capabilities(&raw.reserved_capabilities, path)?;
834    validate_admission_facts_config(
835        &modules,
836        raw.admission_facts_carrier_module_id.as_deref(),
837        raw.admission_facts_targets.as_deref(),
838        path,
839    )?;
840    let scope_authority_owners = raw
841        .scope_authority_owners
842        .unwrap_or_else(default_scope_authority_owners);
843    if scope_authority_owners.iter().any(|owner| owner.is_empty()) {
844        return Err(DaemonConfigError::InvalidValue {
845            path: path.to_path_buf(),
846            message: "scope_authority_owners must not contain empty module ids".to_string(),
847        });
848    }
849
850    let storage = raw
851        .storage
852        .map(|s| match s {
853            RawStorageConfig::Sqlite { data_home } => {
854                let data_home = data_home.unwrap_or_else(default_data_home);
855                // A relative data home is served to every module in its storage
856                // descriptor and resolves against each module's own cwd, so one
857                // daemon would hand out N different directories while every
858                // module's gate stays green. The resolver returns a relative
859                // path when no home variable is set (golden-pinned) or when an
860                // operator set XDG_DATA_HOME to one; both are refused here rather
861                // than in the resolver, because the resolver's contract is shared
862                // with modules that may legitimately tolerate it.
863                if !data_home.is_absolute() {
864                    return Err(DaemonConfigError::InvalidValue {
865                        path: path.to_path_buf(),
866                        message: format!(
867                            "storage data home resolved to the relative path {} \
868                             (no absolute XDG_DATA_HOME, APPDATA, USERPROFILE, or HOME \
869                             in the daemon's environment); refusing to serve a \
870                             cwd-relative storage descriptor to modules. Set \
871                             XDG_DATA_HOME or HOME to an absolute path, or set \
872                             storage.data_home in this file.",
873                            data_home.display()
874                        ),
875                    });
876                }
877                Ok(StorageConfig::Sqlite { data_home })
878            }
879        })
880        .transpose()?;
881
882    Ok(DaemonConfig {
883        path: path.to_path_buf(),
884        port: raw.port,
885        drain_timeout_ms: default_drain_timeout_ms,
886        route_bind_relay_timeout_ms: default_route_bind_relay_timeout_ms,
887        modules,
888        storage,
889        admission_facts_carrier_module_id: raw.admission_facts_carrier_module_id,
890        admission_facts_targets: raw.admission_facts_targets,
891        scope_authority_owners,
892        reserved_capabilities: raw.reserved_capabilities,
893    })
894}
895
896fn parse_logging_config(
897    raw: RawLoggingConfig,
898    path: &Path,
899    owner: &str,
900) -> Result<LoggingConfig, DaemonConfigError> {
901    fn valid_level(level: &str) -> bool {
902        matches!(level, "off" | "error" | "warn" | "info" | "debug" | "trace")
903    }
904
905    let level = raw.level.unwrap_or_else(|| "info".to_string());
906    if !valid_level(&level) {
907        return Err(DaemonConfigError::InvalidValue {
908            path: path.to_path_buf(),
909            message: format!(
910                "{owner}.level must be one of off, error, warn, info, debug, trace; got {level:?}"
911            ),
912        });
913    }
914    for (tag, tag_level) in &raw.tags {
915        // A logger name is dotted segments of [a-z][a-z0-9-]*: the same
916        // grammar cortexkit-log renders and filters on. Anything else would
917        // pass through CK_LOG and be refused there, one process away from the
918        // config that caused it.
919        let well_formed = !tag.is_empty()
920            && tag.split('.').all(|segment| {
921                let mut chars = segment.chars();
922                matches!(chars.next(), Some('a'..='z'))
923                    && chars.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '-'))
924            });
925        if !well_formed {
926            return Err(DaemonConfigError::InvalidValue {
927                path: path.to_path_buf(),
928                message: format!(
929                    "{owner}.tags key {tag:?} is not a logger name (dotted segments of [a-z][a-z0-9-]*)"
930                ),
931            });
932        }
933        if !valid_level(tag_level) {
934            return Err(DaemonConfigError::InvalidValue {
935                path: path.to_path_buf(),
936                message: format!(
937                    "{owner}.tags.{tag} must be one of off, error, warn, info, debug, trace; got {tag_level:?}"
938                ),
939            });
940        }
941    }
942
943    let defaults = Retention::default();
944    let retention = Retention {
945        max_file_mb: raw.max_file_mb.unwrap_or(defaults.max_file_mb),
946        keep: raw.keep.unwrap_or(defaults.keep),
947        max_age_days: raw.max_age_days.unwrap_or(defaults.max_age_days),
948    };
949    if retention.max_file_mb == 0 {
950        return Err(DaemonConfigError::InvalidValue {
951            path: path.to_path_buf(),
952            message: format!("{owner}.max_file_mb must be greater than 0"),
953        });
954    }
955
956    let alarm_segment_mb = raw
957        .alarm_segment_mb
958        .unwrap_or(cortexkit_log::SegmentRetention::default().alarm_segment_mb);
959    if alarm_segment_mb == 0 {
960        return Err(DaemonConfigError::InvalidValue {
961            path: path.to_path_buf(),
962            message: format!("{owner}.alarm_segment_mb must be greater than 0"),
963        });
964    }
965
966    Ok(LoggingConfig {
967        level,
968        tags: raw.tags,
969        retention,
970        alarm_segment_mb,
971    })
972}
973
974/// Resolve a module's declared `protocol` key.
975///
976/// Absent and `"subc"` are the SAME answer on purpose: a config written before
977/// this key existed meant "a subc module", so there is no third state for
978/// "unspecified" to drift into. Anything else is refused with the value quoted,
979/// because the alternative -- falling back to `subc` for a typo like `"non"` --
980/// silently restores the exact supervision behaviour the operator was trying to
981/// turn off.
982fn parse_module_protocol(
983    raw: Option<&str>,
984    path: &Path,
985    module_id: &str,
986) -> Result<ModuleProtocol, DaemonConfigError> {
987    match raw {
988        None | Some("subc") => Ok(ModuleProtocol::Subc),
989        Some("none") => Ok(ModuleProtocol::None),
990        // `{other:?}` quotes and escapes the operator's own bytes, so a value
991        // carrying control characters cannot rewrite the terminal of whoever
992        // reads the refusal.
993        Some(other) => Err(DaemonConfigError::InvalidValue {
994            path: path.to_path_buf(),
995            message: format!(
996                "module '{module_id}' declares protocol {other:?}; supported values are \
997                 \"subc\" (the default when the key is absent) and \"none\"",
998                module_id = module_id.escape_debug(),
999            ),
1000        }),
1001    }
1002}
1003
1004/// Resolve a module's declared `overlap` key. Absent means `"exclusive"`,
1005/// and an unknown value is refused rather than read as either: a typo that
1006/// became `"safe"` would let a swap run two processes on a single-writer store.
1007fn parse_module_overlap(
1008    raw: Option<&str>,
1009    path: &Path,
1010    module_id: &str,
1011) -> Result<ModuleOverlap, DaemonConfigError> {
1012    match raw {
1013        None | Some("exclusive") => Ok(ModuleOverlap::Exclusive),
1014        Some("safe") => Ok(ModuleOverlap::Safe),
1015        Some(other) => Err(DaemonConfigError::InvalidValue {
1016            path: path.to_path_buf(),
1017            message: format!(
1018                "module '{module_id}' declares overlap {other:?}; supported values are \
1019                 \"exclusive\" (the default when the key is absent) and \"safe\"",
1020                module_id = module_id.escape_debug(),
1021            ),
1022        }),
1023    }
1024}
1025
1026fn validate_reserved_capabilities(
1027    bindings: &BTreeMap<String, String>,
1028    path: &Path,
1029) -> Result<(), DaemonConfigError> {
1030    for (capability, module_id) in bindings {
1031        if !is_valid_capability_identifier(capability) {
1032            return Err(DaemonConfigError::InvalidValue {
1033                path: path.to_path_buf(),
1034                message: format!(
1035                    "reserved_capabilities key {:?} is not a valid capability identifier",
1036                    capability
1037                ),
1038            });
1039        }
1040        if module_id.trim().is_empty() {
1041            return Err(DaemonConfigError::InvalidValue {
1042                path: path.to_path_buf(),
1043                message: format!(
1044                    "reserved_capabilities binding for {:?} has an empty module id",
1045                    capability
1046                ),
1047            });
1048        }
1049        if let Err(reason) = crate::registry::module_id_path_hazard(module_id) {
1050            return Err(DaemonConfigError::InvalidValue {
1051                path: path.to_path_buf(),
1052                message: format!(
1053                    "reserved_capabilities binding for {:?} has an unusable module id {:?}: {reason}",
1054                    capability, module_id
1055                ),
1056            });
1057        }
1058    }
1059    Ok(())
1060}
1061
1062fn validate_admission_facts_config(
1063    modules: &[ConfiguredModule],
1064    carrier_module_id: Option<&str>,
1065    targets: Option<&[String]>,
1066    path: &Path,
1067) -> Result<(), DaemonConfigError> {
1068    let Some(carrier_module_id) = carrier_module_id else {
1069        return Ok(());
1070    };
1071
1072    let Some(carrier) = modules
1073        .iter()
1074        .find(|module| module.module_id == carrier_module_id)
1075    else {
1076        return Err(DaemonConfigError::InvalidValue {
1077            path: path.to_path_buf(),
1078            message: format!(
1079                "admission_facts_carrier_module_id '{carrier_module_id}' must name a configured module"
1080            ),
1081        });
1082    };
1083    if !carrier.enabled || !carrier.reserved {
1084        return Err(DaemonConfigError::InvalidValue {
1085            path: path.to_path_buf(),
1086            message: format!(
1087                "admission_facts_carrier_module_id '{carrier_module_id}' must name an enabled reserved module"
1088            ),
1089        });
1090    }
1091
1092    let Some(targets) = targets else {
1093        return Err(DaemonConfigError::InvalidValue {
1094            path: path.to_path_buf(),
1095            message: "admission_facts_targets must be present when an admission facts carrier is configured".to_string(),
1096        });
1097    };
1098    if targets.is_empty() || targets.iter().any(String::is_empty) {
1099        return Err(DaemonConfigError::InvalidValue {
1100            path: path.to_path_buf(),
1101            message:
1102                "admission_facts_targets must be non-empty and must not contain empty module ids"
1103                    .to_string(),
1104        });
1105    }
1106
1107    Ok(())
1108}
1109
1110fn default_enabled() -> bool {
1111    true
1112}
1113
1114fn validate_reserved_prefixes(
1115    modules: &[ConfiguredModule],
1116    path: &Path,
1117) -> Result<(), DaemonConfigError> {
1118    for module in modules {
1119        if module.reserved_prefixes.is_empty() {
1120            continue;
1121        }
1122        if !module.reserved {
1123            return Err(DaemonConfigError::InvalidValue {
1124                path: path.to_path_buf(),
1125                message: format!(
1126                    "module '{}' reserved_prefixes require reserved=true so the owner is spawn-nonce protected",
1127                    module.module_id
1128                ),
1129            });
1130        }
1131        for prefix in &module.reserved_prefixes {
1132            if !prefix.ends_with(':') {
1133                return Err(DaemonConfigError::InvalidValue {
1134                    path: path.to_path_buf(),
1135                    message: format!(
1136                        "module '{}' reserved prefix '{}' must end with ':'",
1137                        module.module_id, prefix
1138                    ),
1139                });
1140            }
1141        }
1142    }
1143
1144    for module in modules {
1145        for prefix in &module.reserved_prefixes {
1146            if let Some(colliding) = modules
1147                .iter()
1148                .find(|candidate| candidate.module_id.starts_with(prefix))
1149            {
1150                return Err(DaemonConfigError::InvalidValue {
1151                    path: path.to_path_buf(),
1152                    message: format!(
1153                        "reserved prefix '{}' owned by '{}' collides with configured module id '{}'",
1154                        prefix, module.module_id, colliding.module_id
1155                    ),
1156                });
1157            }
1158        }
1159    }
1160
1161    for (left_index, left) in modules.iter().enumerate() {
1162        for right in modules.iter().skip(left_index + 1) {
1163            if left.module_id == right.module_id {
1164                continue;
1165            }
1166            for left_prefix in &left.reserved_prefixes {
1167                for right_prefix in &right.reserved_prefixes {
1168                    if left_prefix.starts_with(right_prefix)
1169                        || right_prefix.starts_with(left_prefix)
1170                    {
1171                        return Err(DaemonConfigError::InvalidValue {
1172                            path: path.to_path_buf(),
1173                            message: format!(
1174                                "reserved prefixes '{}' owned by '{}' and '{}' owned by '{}' overlap",
1175                                left_prefix, left.module_id, right_prefix, right.module_id
1176                            ),
1177                        });
1178                    }
1179                }
1180            }
1181        }
1182    }
1183
1184    Ok(())
1185}
1186
1187fn parse_health_config(
1188    raw: RawHealthConfig,
1189    path: &Path,
1190    module_id: &str,
1191) -> Result<HealthConfig, DaemonConfigError> {
1192    let defaults = HealthConfig::default();
1193    let cadence = positive_millis(
1194        raw.cadence_ms,
1195        defaults.cadence,
1196        path,
1197        module_id,
1198        "cadence_ms",
1199    )?;
1200    let deadline = positive_millis(
1201        raw.deadline_ms,
1202        defaults.deadline,
1203        path,
1204        module_id,
1205        "deadline_ms",
1206    )?;
1207    let failure_threshold = match raw.failure_threshold {
1208        Some(0) => {
1209            return Err(DaemonConfigError::InvalidValue {
1210                path: path.to_path_buf(),
1211                message: format!("module '{module_id}' health.failure_threshold must be positive"),
1212            })
1213        }
1214        Some(value) => value,
1215        None => defaults.failure_threshold,
1216    };
1217
1218    Ok(HealthConfig {
1219        cadence,
1220        deadline,
1221        failure_threshold,
1222        on_degraded: match raw.on_degraded {
1223            Some(RawHealthAction::Restart) => {
1224                return Err(DaemonConfigError::InvalidValue {
1225                    path: path.to_path_buf(),
1226                    message: format!(
1227                        "module '{module_id}' health.on_degraded may not be 'restart': a degraded module is slow-but-moving, so restarting it converts transient load into an outage. Use 'report' or 'alert' (Health-Path v2: only total wreckage or reported-unresponsiveness restarts)."
1228                    ),
1229                });
1230            }
1231            Some(action) => health_action(action),
1232            None => defaults.on_degraded,
1233        },
1234        on_failing: raw
1235            .on_failing
1236            .map(health_action)
1237            .unwrap_or(defaults.on_failing),
1238        critical: raw.critical,
1239    })
1240}
1241
1242/// Resolve one module's `restart` block against the supervisor defaults.
1243///
1244/// Every key is optional and independent: a config that sets only
1245/// `window_secs` keeps the default cap and backoff, and a config with no
1246/// `restart` block at all gets exactly the policy the daemon used before the
1247/// block existed.
1248fn parse_restart_config(
1249    raw: Option<RawRestartConfig>,
1250    path: &Path,
1251    module_id: &str,
1252) -> Result<RestartPolicy, DaemonConfigError> {
1253    let defaults = RestartPolicy::default();
1254    let Some(raw) = raw else {
1255        return Ok(defaults);
1256    };
1257
1258    let window = match raw.window_secs {
1259        Some(0) => {
1260            return Err(DaemonConfigError::InvalidValue {
1261                path: path.to_path_buf(),
1262                message: format!(
1263                    "module '{module_id}' {RESTART_WINDOW_ZERO_MESSAGE}",
1264                    module_id = module_id.escape_debug()
1265                ),
1266            });
1267        }
1268        Some(secs) => Duration::from_secs(secs),
1269        None => defaults.window,
1270    };
1271    let backoff = raw
1272        .backoff_ms
1273        .map(Duration::from_millis)
1274        .unwrap_or(defaults.backoff);
1275    let max_backoff = raw
1276        .max_backoff_ms
1277        .map(Duration::from_millis)
1278        .unwrap_or(defaults.max_backoff);
1279    if max_backoff < backoff {
1280        return Err(DaemonConfigError::InvalidValue {
1281            path: path.to_path_buf(),
1282            message: format!(
1283                "module '{}' restart.max_backoff_ms must be greater than or equal to restart.backoff_ms (max_backoff_ms={max_backoff:?}, backoff_ms={backoff:?})",
1284                module_id.escape_debug()
1285            ),
1286        });
1287    }
1288
1289    Ok(RestartPolicy {
1290        // `0` is a deliberate posture here ("never replace this module"), unlike
1291        // the window, so it is accepted as written.
1292        max_restarts: raw.max_restarts.unwrap_or(defaults.max_restarts),
1293        backoff,
1294        max_backoff,
1295        window,
1296    })
1297}
1298
1299fn positive_millis(
1300    value: Option<u64>,
1301    default: std::time::Duration,
1302    path: &Path,
1303    module_id: &str,
1304    field: &str,
1305) -> Result<std::time::Duration, DaemonConfigError> {
1306    match value {
1307        Some(0) => Err(DaemonConfigError::InvalidValue {
1308            path: path.to_path_buf(),
1309            message: format!("module '{module_id}' health.{field} must be positive"),
1310        }),
1311        Some(value) => Ok(std::time::Duration::from_millis(value)),
1312        None => Ok(default),
1313    }
1314}
1315
1316fn health_action(action: RawHealthAction) -> HealthAction {
1317    match action {
1318        RawHealthAction::Report => HealthAction::Report,
1319        RawHealthAction::Restart => HealthAction::Restart,
1320        RawHealthAction::Alert => HealthAction::Alert,
1321    }
1322}
1323
1324/// Platform data home for per-module storage: `$XDG_DATA_HOME`, else
1325/// `~/.local/share` (or the Windows roaming app data), else a relative fallback.
1326pub(crate) fn default_data_home() -> PathBuf {
1327    if let Some(data_home) = non_empty_os_var("XDG_DATA_HOME") {
1328        return PathBuf::from(data_home);
1329    }
1330
1331    #[cfg(windows)]
1332    {
1333        if let Some(app_data) = non_empty_os_var("APPDATA") {
1334            return PathBuf::from(app_data);
1335        }
1336        if let Some(user_profile) = non_empty_os_var("USERPROFILE") {
1337            return PathBuf::from(user_profile).join("AppData").join("Roaming");
1338        }
1339    }
1340
1341    if let Some(home) = non_empty_os_var("HOME") {
1342        return PathBuf::from(home).join(".local").join("share");
1343    }
1344
1345    PathBuf::from(".local").join("share")
1346}
1347
1348fn non_empty_os_var(key: &str) -> Option<OsString> {
1349    let value = env::var_os(key)?;
1350    if value.is_empty() {
1351        None
1352    } else {
1353        Some(value)
1354    }
1355}
1356
1357impl fmt::Display for DaemonConfigError {
1358    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1359        match self {
1360            Self::Read { path, source } => {
1361                write!(f, "failed to read daemon config {}: {source}", path.display())
1362            }
1363            Self::InvalidJsonc { path, message } => {
1364                write!(f, "invalid JSONC in daemon config {}: {message}", path.display())
1365            }
1366            Self::InvalidJson { path, source } => {
1367                write!(f, "invalid daemon config {}: {source}", path.display())
1368            }
1369            Self::UnsupportedVersion { path, version } => write!(
1370                f,
1371                "invalid daemon config {}: version {version} is unsupported (expected {SUPPORTED_CONFIG_VERSION})",
1372                path.display()
1373            ),
1374            Self::InvalidValue { path, message } => {
1375                write!(f, "invalid daemon config {}: {message}", path.display())
1376            }
1377        }
1378    }
1379}
1380
1381impl Error for DaemonConfigError {
1382    fn source(&self) -> Option<&(dyn Error + 'static)> {
1383        match self {
1384            Self::Read { source, .. } => Some(source),
1385            Self::InvalidJson { source, .. } => Some(source),
1386            Self::InvalidJsonc { .. }
1387            | Self::UnsupportedVersion { .. }
1388            | Self::InvalidValue { .. } => None,
1389        }
1390    }
1391}
1392
1393#[cfg(all(test, unix))]
1394mod run_dir_privacy_tests {
1395    use std::fs;
1396    use std::os::unix::fs::PermissionsExt;
1397    use subc_test_support::TestTempDir;
1398
1399    /// Both arms of the thing that actually bit: a directory this code CREATES,
1400    /// and one it INHERITS from another creator. The second is the real case --
1401    /// every desk in the fleet already had a 0755 run directory made by the log
1402    /// sink, so a fix that only sets the mode at creation would have changed
1403    /// nothing anywhere it mattered.
1404    #[test]
1405    fn run_dir_is_created_private_and_an_inherited_wide_one_is_tightened() {
1406        let temp = TestTempDir::new("subc-run-dir-privacy");
1407        let created = temp.path().join("cortexkit").join("run");
1408        super::ensure_directory_private(&created).expect("create run dir");
1409        let mode = fs::metadata(&created)
1410            .expect("stat created")
1411            .permissions()
1412            .mode()
1413            & 0o777;
1414        assert_eq!(
1415            mode, 0o700,
1416            "observable a run directory this code creates must be 0700, got {mode:o}"
1417        );
1418
1419        // Now the inherited case: widen it the way create_dir_all would have.
1420        fs::set_permissions(&created, fs::Permissions::from_mode(0o755)).expect("widen");
1421        let widened = fs::metadata(&created)
1422            .expect("stat widened")
1423            .permissions()
1424            .mode()
1425            & 0o777;
1426        assert_eq!(
1427            widened, 0o755,
1428            "observable the fixture must actually be wide before the tighten"
1429        );
1430
1431        super::ensure_directory_private(&created).expect("tighten run dir");
1432        let mode = fs::metadata(&created)
1433            .expect("stat tightened")
1434            .permissions()
1435            .mode()
1436            & 0o777;
1437        assert_eq!(
1438            mode, 0o700,
1439            "observable an inherited group- or world-readable run directory must be tightened to 0700, got {mode:o}"
1440        );
1441    }
1442}
1443
1444#[cfg(test)]
1445mod tests {
1446    use super::*;
1447
1448    /// The golden fixture is the CONTRACT for data-home resolution: mirror
1449    /// implementations (cortexkit-store-types `resolve_data_home`,
1450    /// @cortexkit/store `resolveDataHome`) assert against the same rows, so a
1451    /// rule change here that skips the fixture breaks THIS test rather than
1452    /// silently splitting a module's self-resolved path from the descriptor
1453    /// the daemon serves (the CKCRED Windows divergence, 2026-08).
1454    /// Env-mutating tests share this lock: cargo runs tests on multiple
1455    /// threads and the four data-home variables are process-global.
1456    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1457
1458    /// A path that is absolute on the platform running the test. `/data` is
1459    /// relative on Windows (no drive letter), which is not a bug in the resolver
1460    /// but a bug in a test that assumes POSIX absoluteness -- the relative-home
1461    /// refusal exposed three such tests on the Windows leg.
1462    fn abs(posix: &str) -> PathBuf {
1463        if cfg!(windows) {
1464            PathBuf::from(format!("C:{}", posix.replace('/', "\\")))
1465        } else {
1466            PathBuf::from(posix)
1467        }
1468    }
1469
1470    #[test]
1471    fn default_data_home_matches_golden_fixture() {
1472        let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1473        let doc: serde_json::Value =
1474            serde_json::from_str(include_str!("../tests/golden/data_home_resolution.json"))
1475                .expect("golden parses");
1476        let vars = ["XDG_DATA_HOME", "APPDATA", "USERPROFILE", "HOME"];
1477        let saved: Vec<(&str, Option<std::ffi::OsString>)> =
1478            vars.iter().map(|v| (*v, env::var_os(v))).collect();
1479        let platform_matches =
1480            |p: &str| p == "any" || p == if cfg!(windows) { "windows" } else { "unix" };
1481
1482        let mut ran = 0usize;
1483        for case in doc["cases"].as_array().expect("cases array") {
1484            let name = case["name"].as_str().expect("name");
1485            if !platform_matches(case["platform"].as_str().expect("platform")) {
1486                continue;
1487            }
1488            for v in vars {
1489                env::remove_var(v);
1490            }
1491            for (k, v) in case["env"].as_object().expect("env map") {
1492                env::set_var(k, v.as_str().expect("env value"));
1493            }
1494            let got = default_data_home();
1495            assert_eq!(
1496                got.to_string_lossy(),
1497                case["expect"].as_str().expect("expect"),
1498                "golden case '{name}' diverged"
1499            );
1500            ran += 1;
1501        }
1502        // Vacuity floor: 'any' rows plus this platform's rows must both run.
1503        assert!(
1504            ran >= 6,
1505            "only {ran} golden cases ran; fixture or filter broken"
1506        );
1507
1508        for (k, v) in saved {
1509            match v {
1510                Some(val) => env::set_var(k, val),
1511                None => env::remove_var(k),
1512            }
1513        }
1514    }
1515
1516    /// A relative data home is what `default_data_home` returns when HOME and
1517    /// XDG_DATA_HOME are both unset (the golden fixture pins `.local/share`), or
1518    /// when XDG_DATA_HOME itself is relative. Either must be refused, never
1519    /// joined onto the working directory.
1520    #[test]
1521    fn daemon_run_dir_refuses_a_relative_data_home_and_names_the_variables() {
1522        for data_home in [PathBuf::from(".local/share"), PathBuf::from("relative-xdg")] {
1523            let error = daemon_run_dir_from(data_home.clone())
1524                .expect_err("a relative data home must be refused");
1525            assert_eq!(
1526                error,
1527                DaemonRunDirError::RelativeDataHome {
1528                    data_home: data_home.clone()
1529                }
1530            );
1531            let message = error.to_string();
1532            assert!(
1533                message.contains("XDG_DATA_HOME") && message.contains("HOME"),
1534                "the refusal must name the variables to set: {message}"
1535            );
1536        }
1537    }
1538
1539    #[test]
1540    fn daemon_run_dir_under_an_absolute_data_home_is_cortexkit_run() {
1541        let data_home = env::temp_dir().join("subc-run-dir-probe").join("data");
1542        assert!(data_home.is_absolute());
1543        assert_eq!(
1544            daemon_run_dir_from(data_home.clone()),
1545            Ok(data_home.join("cortexkit").join("run"))
1546        );
1547    }
1548
1549    /// Same harness as the data-home golden, over the config-home ladder. The two
1550    /// fixtures share a row shape on purpose: a divergence between the ladders
1551    /// (one honouring a variable the other does not) is exactly the class that
1552    /// produced the doubled-path store defect, and a shared harness makes it
1553    /// visible as a fixture diff rather than as a runtime surprise.
1554    #[test]
1555    fn default_config_home_matches_golden_fixture() {
1556        let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1557        let doc: serde_json::Value =
1558            serde_json::from_str(include_str!("../tests/golden/config_home_resolution.json"))
1559                .expect("golden parses");
1560        let vars = ["XDG_CONFIG_HOME", "APPDATA", "USERPROFILE", "HOME"];
1561        let saved: Vec<(&str, Option<std::ffi::OsString>)> =
1562            vars.iter().map(|v| (*v, env::var_os(v))).collect();
1563        let platform_matches =
1564            |p: &str| p == "any" || p == if cfg!(windows) { "windows" } else { "unix" };
1565
1566        let mut ran = 0usize;
1567        for case in doc["cases"].as_array().expect("cases array") {
1568            let name = case["name"].as_str().expect("name");
1569            if !platform_matches(case["platform"].as_str().expect("platform")) {
1570                continue;
1571            }
1572            for v in vars {
1573                env::remove_var(v);
1574            }
1575            for (k, v) in case["env"].as_object().expect("env map") {
1576                env::set_var(k, v.as_str().expect("env value"));
1577            }
1578            let got = default_config_home();
1579            assert_eq!(
1580                got.to_string_lossy(),
1581                case["expect"].as_str().expect("expect"),
1582                "golden case '{name}' diverged"
1583            );
1584            ran += 1;
1585        }
1586        assert!(
1587            ran >= 6,
1588            "only {ran} golden cases ran; fixture or filter broken"
1589        );
1590
1591        for (k, v) in saved {
1592            match v {
1593                Some(val) => env::set_var(k, val),
1594                None => env::remove_var(k),
1595            }
1596        }
1597    }
1598
1599    /// A relative storage data home is refused at parse rather than served.
1600    /// Both ways a relative path arises are covered: an explicit relative
1601    /// `storage.data_home` in the file, and the resolver's own fall-through when
1602    /// no home variable is set. The control proves the guard is on the VALUE and
1603    /// not on the presence of the key: the same document with an absolute home
1604    /// parses.
1605    #[test]
1606    fn relative_storage_data_home_is_refused_at_parse() {
1607        let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1608        let path = Path::new("/golden/subc.jsonc");
1609
1610        // Arm 1: explicit relative value in the file.
1611        let doc =
1612            r#"{ "version": 1, "storage": { "backend": "sqlite", "data_home": "relative/home" } }"#;
1613        let err = parse_doc(doc, path).expect_err("relative data_home must refuse");
1614        assert!(
1615            matches!(&err, DaemonConfigError::InvalidValue { message, .. }
1616                if message.contains("relative path relative/home")),
1617            "wrong refusal: {err:?}"
1618        );
1619
1620        // Arm 2: the resolver's fall-through, with every home variable cleared.
1621        let vars = ["XDG_DATA_HOME", "APPDATA", "USERPROFILE", "HOME"];
1622        let saved: Vec<(&str, Option<std::ffi::OsString>)> =
1623            vars.iter().map(|v| (*v, env::var_os(v))).collect();
1624        for v in vars {
1625            env::remove_var(v);
1626        }
1627        let doc = r#"{ "version": 1, "storage": { "backend": "sqlite" } }"#;
1628        let err = parse_doc(doc, path).expect_err("no home in env must refuse");
1629        assert!(
1630            matches!(&err, DaemonConfigError::InvalidValue { message, .. }
1631                if message.contains("no absolute XDG_DATA_HOME")),
1632            "wrong refusal: {err:?}"
1633        );
1634
1635        // Control: an absolute value parses -- the guard is on the value. The
1636        // path must be absolute ON THIS PLATFORM; `/abs/home` is relative on
1637        // Windows and would make the control refuse for the wrong reason.
1638        let want = abs("/abs/home");
1639        let doc = format!(
1640            r#"{{ "version": 1, "storage": {{ "backend": "sqlite", "data_home": {} }} }}"#,
1641            serde_json::to_string(&want).expect("json path")
1642        );
1643        let cfg = parse_doc(&doc, path).expect("absolute data_home parses");
1644        assert!(matches!(
1645            cfg.storage,
1646            Some(StorageConfig::Sqlite { ref data_home }) if *data_home == want
1647        ));
1648
1649        for (k, v) in saved {
1650            match v {
1651                Some(val) => env::set_var(k, val),
1652                None => env::remove_var(k),
1653            }
1654        }
1655    }
1656
1657    #[test]
1658    fn restart_required_sections_are_the_rescan_cannot_apply_set() {
1659        assert_eq!(
1660            RestartRequiredSection::ALL.map(RestartRequiredSection::label),
1661            [
1662                "port",
1663                "storage",
1664                "admission_facts_carrier_module_id",
1665                "admission_facts_targets",
1666                "scope_authority_owners",
1667            ]
1668        );
1669    }
1670
1671    #[test]
1672    fn scope_authority_owners_defaults_to_the_session_runtime() {
1673        let config = parse_doc(r#"{ "version": 1 }"#, Path::new("/tmp/subc.jsonc")).unwrap();
1674        assert_eq!(config.scope_authority_owners, vec!["prefrontal-core"]);
1675    }
1676
1677    #[test]
1678    fn scope_authority_owners_is_read_when_set_and_refuses_an_empty_id() {
1679        let config = parse_doc(
1680            r#"{ "version": 1, "scope_authority_owners": ["a", "b"] }"#,
1681            Path::new("/tmp/subc.jsonc"),
1682        )
1683        .unwrap();
1684        assert_eq!(config.scope_authority_owners, vec!["a", "b"]);
1685        // An explicit empty list is a real posture (no owner may set gated
1686        // attributes), distinct from an absent key.
1687        let config = parse_doc(
1688            r#"{ "version": 1, "scope_authority_owners": [] }"#,
1689            Path::new("/tmp/subc.jsonc"),
1690        )
1691        .unwrap();
1692        assert!(config.scope_authority_owners.is_empty());
1693        let error = parse_doc(
1694            r#"{ "version": 1, "scope_authority_owners": [""] }"#,
1695            Path::new("/tmp/subc.jsonc"),
1696        )
1697        .expect_err("an empty module id is refused");
1698        assert!(
1699            error.to_string().contains("scope_authority_owners"),
1700            "{error}"
1701        );
1702    }
1703
1704    #[test]
1705    fn no_storage_section_yields_none() {
1706        let config = parse_doc(
1707            r#"{ "version": 1, "modules": {} }"#,
1708            Path::new("/tmp/subc.jsonc"),
1709        )
1710        .expect("parse");
1711        assert_eq!(config.storage, None);
1712    }
1713
1714    #[test]
1715    fn sqlite_storage_parses_with_explicit_data_home() {
1716        let config = parse_doc(
1717            &format!(
1718                r#"{{ "version": 1, "storage": {{ "backend": "sqlite", "data_home": {} }} }}"#,
1719                serde_json::to_string(&abs("/data")).expect("json path")
1720            ),
1721            Path::new("/tmp/subc.jsonc"),
1722        )
1723        .expect("parse");
1724        assert_eq!(
1725            config.storage,
1726            Some(StorageConfig::Sqlite {
1727                data_home: abs("/data")
1728            })
1729        );
1730    }
1731
1732    #[test]
1733    fn sqlite_storage_defaults_data_home_when_omitted() {
1734        // With no data_home, it falls back to the platform data home (here forced
1735        // via XDG_DATA_HOME so the test is deterministic).
1736        // Mutating the environment is a process-wide side effect; every test
1737        // reading or writing the data-home variables serializes on ENV_LOCK
1738        // (the golden-fixture test above mutates all four variables).
1739        let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1740        std::env::set_var("XDG_DATA_HOME", abs("/forced/data/home"));
1741        let config = parse_doc(
1742            r#"{ "version": 1, "storage": { "backend": "sqlite" } }"#,
1743            Path::new("/tmp/subc.jsonc"),
1744        )
1745        .expect("parse");
1746        std::env::remove_var("XDG_DATA_HOME");
1747        assert_eq!(
1748            config.storage,
1749            Some(StorageConfig::Sqlite {
1750                data_home: abs("/forced/data/home")
1751            })
1752        );
1753    }
1754
1755    #[test]
1756    fn descriptor_for_matches_store_types_shape() {
1757        // The opaque descriptor subc delivers must match the
1758        // cortexkit_store_types::StorageDescriptor JSON shape exactly (path
1759        // convention <data_home>/cortexkit/<module>/store.db, one db per module).
1760        let cfg = StorageConfig::Sqlite {
1761            data_home: PathBuf::from("/data"),
1762        };
1763        let descriptor = cfg.descriptor_for("alfonso-routing");
1764        assert_eq!(
1765            descriptor,
1766            serde_json::json!({
1767                "module_id": "alfonso-routing",
1768                "storage_namespace": "default",
1769                "isolation": { "kind": "module" },
1770                "backend": {
1771                    "backend": "sqlite",
1772                    "path": "/data/cortexkit/alfonso-routing/store.db"
1773                }
1774            })
1775        );
1776    }
1777
1778    #[test]
1779    fn path_hazard_module_id_refuses_config_parse() {
1780        let path = Path::new("/tmp/subc.jsonc");
1781        let err = parse_doc(
1782            r#"{ "version": 1, "modules": { "../escape": { "program": "x" } } }"#,
1783            path,
1784        )
1785        .expect_err("separator-bearing module id must refuse");
1786        let text = format!("{err}");
1787        assert!(
1788            text.contains("not usable as a path component"),
1789            "refusal must name the hazard: {text}"
1790        );
1791    }
1792
1793    #[test]
1794    fn drain_timeout_resolves_module_over_daemon_over_absent() {
1795        let path = Path::new("/tmp/subc.jsonc");
1796        let config = parse_doc(
1797            r#"
1798            {
1799              "version": 1,
1800              "drain_timeout_ms": 45000,
1801              "modules": {
1802                "fast": { "program": "fast", "drain_timeout_ms": 0 },
1803                "slow": { "program": "slow", "drain_timeout_ms": 120000 },
1804                "inherits": { "program": "inherits" }
1805              }
1806            }
1807            "#,
1808            path,
1809        )
1810        .unwrap();
1811        let by_id = |id: &str| {
1812            config
1813                .modules
1814                .iter()
1815                .find(|m| m.module_id == id)
1816                .unwrap()
1817                .drain_timeout_ms
1818        };
1819        // Per-module wins, INCLUDING an explicit 0 ("never wait") -- the case a
1820        // truthiness-shaped resolution would silently replace with the default.
1821        assert_eq!(by_id("fast"), Some(0));
1822        assert_eq!(by_id("slow"), Some(120_000));
1823        // No per-module value: the daemon-wide default flows in at parse time.
1824        assert_eq!(by_id("inherits"), Some(45_000));
1825        assert_eq!(config.drain_timeout_ms, Some(45_000));
1826    }
1827
1828    #[test]
1829    fn drain_timeout_absent_everywhere_stays_none_for_builtin_default() {
1830        let path = Path::new("/tmp/subc.jsonc");
1831        let config = parse_doc(
1832            r#"{ "version": 1, "modules": { "m": { "program": "m" } } }"#,
1833            path,
1834        )
1835        .unwrap();
1836        // None here is load-bearing: it means "use the compiled default", so a
1837        // future default bump reaches every unconfigured module without a
1838        // config migration.
1839        assert_eq!(config.modules[0].drain_timeout_ms, None);
1840        assert_eq!(config.drain_timeout_ms, None);
1841    }
1842
1843    #[test]
1844    fn route_bind_relay_timeout_resolves_module_over_daemon_over_absent() {
1845        // Precedence still holds for valid non-zero values. `0` at either
1846        // layer is rejected by `route_bind_relay_timeout_zero_at_daemon_layer_is_refused`
1847        // and `route_bind_relay_timeout_zero_at_module_layer_is_refused` below
1848        // — the asymmetry is deliberate (drain `0` is still accepted; see
1849        // `drain_timeout_zero_still_parses_for_wedge_bounces`).
1850        let path = Path::new("/tmp/subc.jsonc");
1851        let config = parse_doc(
1852            r#"
1853            {
1854              "version": 1,
1855              "route_bind_relay_timeout_ms": 30000,
1856              "modules": {
1857                "tight": { "program": "tight", "route_bind_relay_timeout_ms": 5000 },
1858                "loose": { "program": "loose", "route_bind_relay_timeout_ms": 60000 },
1859                "inherits": { "program": "inherits" }
1860              }
1861            }
1862            "#,
1863            path,
1864        )
1865        .unwrap();
1866        let by_id = |id: &str| {
1867            config
1868                .modules
1869                .iter()
1870                .find(|m| m.module_id == id)
1871                .unwrap()
1872                .route_bind_relay_timeout_ms
1873        };
1874        // Per-module wins for every non-zero value.
1875        assert_eq!(by_id("tight"), Some(5_000));
1876        assert_eq!(by_id("loose"), Some(60_000));
1877        // No per-module value: the daemon-wide default flows in at parse time.
1878        assert_eq!(by_id("inherits"), Some(30_000));
1879        assert_eq!(config.route_bind_relay_timeout_ms, Some(30_000));
1880    }
1881
1882    #[test]
1883    fn log_tag_keys_must_be_logger_names_and_the_error_names_the_key() {
1884        let path = Path::new("/tmp/subc.jsonc");
1885        for bad in ["Perf", "a b", "perf.", ".perf", "gc..walk", "a=b"] {
1886            let doc = format!(
1887                r#"{{ "version": 1, "modules": {{ "m": {{ "program": "m", "log": {{ "tags": {{ "{bad}": "debug" }} }} }} }} }}"#
1888            );
1889            let err = parse_doc(&doc, path).expect_err(bad);
1890            let text = format!("{err}");
1891            assert!(
1892                text.contains(&format!("{bad:?}")),
1893                "must name the key: {text}"
1894            );
1895            assert!(
1896                text.contains("logger name"),
1897                "must say what a key is: {text}"
1898            );
1899        }
1900        // Control: dotted, hyphenated, root-equal keys are all fine.
1901        let ok = parse_doc(
1902            r#"{ "version": 1, "modules": { "m": { "program": "m", "log": { "tags": { "perf": "debug", "gc.walk": "trace", "m": "error", "a-b": "info" } } } } }"#,
1903            path,
1904        );
1905        assert!(ok.is_ok(), "{ok:?}");
1906    }
1907
1908    #[test]
1909    fn log_filter_spec_prefixes_bare_keys_with_the_module_and_passes_absolute_ones() {
1910        let path = Path::new("/tmp/subc.jsonc");
1911        let config = parse_doc(
1912            r#"{ "version": 1, "modules": { "synapse": { "program": "s", "log": { "level": "warn", "tags": { "perf": "debug", "gc.walk": "trace", "synapse": "error", "other.x": "info" } } } } }"#,
1913            path,
1914        )
1915        .unwrap();
1916        let log = config.modules[0].log.as_ref().unwrap();
1917        // BTreeMap order: gc.walk, other.x, perf, synapse.
1918        assert_eq!(
1919            log.filter_spec("synapse"),
1920            "warn,gc.walk=trace,other.x=info,synapse.perf=debug,synapse=error"
1921        );
1922    }
1923
1924    #[test]
1925    fn log_alarm_segment_mb_defaults_to_the_crate_default_and_refuses_zero() {
1926        let path = Path::new("/tmp/subc.jsonc");
1927        let config = parse_doc(
1928            r#"{ "version": 1, "modules": { "m": { "program": "m", "log": { "level": "info" } } } }"#,
1929            path,
1930        )
1931        .unwrap();
1932        assert_eq!(
1933            config.modules[0].log.as_ref().unwrap().alarm_segment_mb,
1934            cortexkit_log::SegmentRetention::default().alarm_segment_mb
1935        );
1936        let err = parse_doc(
1937            r#"{ "version": 1, "modules": { "m": { "program": "m", "log": { "alarm_segment_mb": 0 } } } }"#,
1938            path,
1939        )
1940        .expect_err("zero alarm must refuse");
1941        assert!(format!("{err}").contains("alarm_segment_mb"));
1942    }
1943
1944    #[test]
1945    fn route_bind_relay_timeout_zero_at_daemon_layer_is_refused() {
1946        let path = Path::new("/tmp/subc.jsonc");
1947        let err = parse_doc(
1948            r#"
1949            {
1950              "version": 1,
1951              "route_bind_relay_timeout_ms": 0,
1952              "modules": { "m": { "program": "m" } }
1953            }
1954            "#,
1955            path,
1956        )
1957        .expect_err("a daemon-wide zero budget must refuse parse");
1958        let text = format!("{err}");
1959        assert!(
1960            text.contains("route_bind_relay_timeout_ms"),
1961            "error must name the offending key: {text}"
1962        );
1963        assert!(
1964            text.contains("enabled: false"),
1965            "error must name the remedy (enable false): {text}"
1966        );
1967    }
1968
1969    #[test]
1970    fn route_bind_relay_timeout_zero_at_module_layer_is_refused() {
1971        let path = Path::new("/tmp/subc.jsonc");
1972        let err = parse_doc(
1973            r#"
1974            {
1975              "version": 1,
1976              "modules": {
1977                "good": { "program": "good" },
1978                "broken": { "program": "broken", "route_bind_relay_timeout_ms": 0 }
1979              }
1980            }
1981            "#,
1982            path,
1983        )
1984        .expect_err("a per-module zero budget must refuse parse");
1985        let text = format!("{err}");
1986        assert!(
1987            text.contains("route_bind_relay_timeout_ms"),
1988            "error must name the offending key: {text}"
1989        );
1990        assert!(
1991            text.contains("broken"),
1992            "error must name the offending module id: {text}"
1993        );
1994        assert!(
1995            text.contains("enabled: false"),
1996            "error must name the remedy (enable false): {text}"
1997        );
1998    }
1999
2000    #[test]
2001    fn drain_timeout_zero_still_parses_for_wedge_bounces() {
2002        // The asymmetry guard: `drain_timeout_ms: 0` is the sanctioned "tear
2003        // down now" used during a wedge bounce and MUST keep parsing. Anyone
2004        // later tempted to "fix the inconsistency" between drain and bind by
2005        // rejecting drain `0` too will break the wedge-bounce path; this
2006        // test names that contract explicitly.
2007        let path = Path::new("/tmp/subc.jsonc");
2008        let config = parse_doc(
2009            r#"
2010            {
2011              "version": 1,
2012              "drain_timeout_ms": 0,
2013              "modules": {
2014                "wedge": { "program": "wedge", "drain_timeout_ms": 0 }
2015              }
2016            }
2017            "#,
2018            path,
2019        )
2020        .expect("drain_timeout_ms: 0 must still parse; wedge-bounce uses it");
2021        let wedge = config
2022            .modules
2023            .iter()
2024            .find(|m| m.module_id == "wedge")
2025            .unwrap();
2026        assert_eq!(wedge.drain_timeout_ms, Some(0));
2027        assert_eq!(config.drain_timeout_ms, Some(0));
2028    }
2029
2030    #[test]
2031    fn route_bind_relay_timeout_absent_everywhere_stays_none_for_builtin_default() {
2032        // Backward-compatibility guard: a config that does not mention
2033        // `route_bind_relay_timeout_ms` at all (the shape every pre-#38 daemon
2034        // shipped) parses to `None` on both layers, so the bind path keeps
2035        // its compiled 12s default.
2036        let path = Path::new("/tmp/subc.jsonc");
2037        let config = parse_doc(
2038            r#"{ "version": 1, "modules": { "m": { "program": "m" } } }"#,
2039            path,
2040        )
2041        .unwrap();
2042        assert_eq!(config.modules[0].route_bind_relay_timeout_ms, None);
2043        assert_eq!(config.route_bind_relay_timeout_ms, None);
2044    }
2045
2046    /// The shape every config in the field has today: no `restart` block at
2047    /// all. It must keep parsing, and it must land on the exact policy the
2048    /// daemon used before the block existed -- all three numbers asserted, so
2049    /// that quietly changing one is a failing test rather than a fleet-wide
2050    /// behaviour change nobody configured.
2051    #[test]
2052    fn a_config_without_a_restart_block_keeps_the_supervisor_defaults() {
2053        let path = Path::new("/tmp/subc.jsonc");
2054        let config = parse_doc(
2055            r#"{ "version": 1, "modules": { "m": { "program": "m" } } }"#,
2056            path,
2057        )
2058        .unwrap();
2059        assert_eq!(config.modules[0].restart.max_restarts, 3);
2060        assert_eq!(config.modules[0].restart.window, Duration::from_secs(600));
2061        assert_eq!(
2062            config.modules[0].restart.backoff,
2063            Duration::from_millis(100)
2064        );
2065        assert_eq!(
2066            config.modules[0].restart.max_backoff,
2067            Duration::from_secs(30)
2068        );
2069    }
2070
2071    #[test]
2072    fn a_restart_block_resolves_each_key_independently() {
2073        let path = Path::new("/tmp/subc.jsonc");
2074        let config = parse_doc(
2075            r#"
2076            {
2077              "version": 1,
2078              "modules": {
2079                "all": {
2080                  "program": "all",
2081                  "restart": { "max_restarts": 5, "window_secs": 60, "backoff_ms": 250, "max_backoff_ms": 5000 }
2082                },
2083                "window-only": {
2084                  "program": "window-only",
2085                  "restart": { "window_secs": 7200 }
2086                },
2087                "never": {
2088                  "program": "never",
2089                  "restart": { "max_restarts": 0 }
2090                }
2091              }
2092            }
2093            "#,
2094            path,
2095        )
2096        .unwrap();
2097        let by_id = |id: &str| {
2098            config
2099                .modules
2100                .iter()
2101                .find(|m| m.module_id == id)
2102                .unwrap()
2103                .restart
2104        };
2105
2106        let all = by_id("all");
2107        assert_eq!(all.max_restarts, 5);
2108        assert_eq!(all.window, Duration::from_secs(60));
2109        assert_eq!(all.backoff, Duration::from_millis(250));
2110        assert_eq!(all.max_backoff, Duration::from_secs(5));
2111
2112        // A module that only widens its window keeps the default cap and
2113        // backoff: the keys do not travel as a set.
2114        let window_only = by_id("window-only");
2115        assert_eq!(window_only.max_restarts, 3);
2116        assert_eq!(window_only.window, Duration::from_secs(7_200));
2117        assert_eq!(window_only.backoff, Duration::from_millis(100));
2118        assert_eq!(window_only.max_backoff, Duration::from_secs(30));
2119
2120        // `max_restarts: 0` is a posture, not a mistake: never replace this
2121        // module. Unlike a zero window, it is accepted as written.
2122        assert_eq!(by_id("never").max_restarts, 0);
2123    }
2124
2125    /// A zero window makes the budget unspendable, which is the opposite of a
2126    /// tight limit and looks almost identical in a diff. Refuse it by name so
2127    /// the operator writes what they meant.
2128    #[test]
2129    fn restart_window_zero_is_refused_by_name() {
2130        let path = Path::new("/tmp/subc.jsonc");
2131        let err = parse_doc(
2132            r#"
2133            {
2134              "version": 1,
2135              "modules": {
2136                "good": { "program": "good" },
2137                "broken": { "program": "broken", "restart": { "window_secs": 0 } }
2138              }
2139            }
2140            "#,
2141            path,
2142        )
2143        .expect_err("a zero crash window must refuse parse");
2144        assert!(
2145            matches!(err, DaemonConfigError::InvalidValue { .. }),
2146            "a zero window is an invalid value, not a parse failure: {err:?}"
2147        );
2148        let text = format!("{err}");
2149        assert!(
2150            text.contains("restart.window_secs"),
2151            "error must name the offending key: {text}"
2152        );
2153        assert!(
2154            text.contains("broken"),
2155            "error must name the offending module id: {text}"
2156        );
2157        assert!(
2158            text.contains("max_restarts: 0"),
2159            "error must name the setting that actually stops restarts: {text}"
2160        );
2161    }
2162
2163    #[test]
2164    fn restart_max_backoff_below_backoff_is_refused_by_name() {
2165        let path = Path::new("/tmp/subc.jsonc");
2166        let err = parse_doc(
2167            r#"
2168            {
2169              "version": 1,
2170              "modules": {
2171                "broken": {
2172                  "program": "broken",
2173                  "restart": { "backoff_ms": 1000, "max_backoff_ms": 999 }
2174                }
2175              }
2176            }
2177            "#,
2178            path,
2179        )
2180        .expect_err("a maximum below the base backoff must refuse parse");
2181        assert!(
2182            matches!(err, DaemonConfigError::InvalidValue { .. }),
2183            "an invalid restart bound must be an InvalidValue: {err:?}"
2184        );
2185        let text = format!("{err}");
2186        assert!(
2187            text.contains("restart.max_backoff_ms"),
2188            "error must name max_backoff_ms: {text}"
2189        );
2190        assert!(
2191            text.contains("restart.backoff_ms"),
2192            "error must name backoff_ms: {text}"
2193        );
2194        assert!(
2195            text.contains("broken"),
2196            "error must name the offending module id: {text}"
2197        );
2198    }
2199
2200    #[test]
2201    fn parse_jsonc_defaults_and_ignores_unknown_fields() {
2202        let path = Path::new("/tmp/subc.jsonc");
2203        let config = parse_doc(
2204            r#"
2205            {
2206              // forward-compatible root field
2207              "version": 1,
2208              "unknown": { "ignored": true },
2209              "modules": {
2210                "aft": {
2211                  "program": "aft",
2212                  "args": ["module",],
2213                  "env": { "A": "B", },
2214                  "future": 42,
2215                },
2216                "disabled": { "program": "disabled", "enabled": false }
2217              },
2218            }
2219            "#,
2220            path,
2221        )
2222        .unwrap();
2223
2224        assert_eq!(config.port, None);
2225        assert_eq!(config.modules.len(), 2);
2226        assert_eq!(config.modules[0].module_id, "aft");
2227        assert_eq!(config.modules[0].program, PathBuf::from("aft"));
2228        assert_eq!(config.modules[0].args, ["module"]);
2229        assert_eq!(config.modules[0].env, [("A".to_string(), "B".to_string())]);
2230        assert!(config.modules[0].enabled);
2231        assert!(config.modules[0].reserved_prefixes.is_empty());
2232        assert_eq!(config.modules[0].health, HealthConfig::default());
2233        assert!(!config.modules[1].enabled);
2234    }
2235
2236    #[test]
2237    fn reserved_capabilities_accept_unknown_bound_modules_and_refuse_bad_identifiers() {
2238        let path = Path::new("/tmp/subc.jsonc");
2239        let config = parse_doc(
2240            r#"{
2241                "version": 1,
2242                "reserved_capabilities": {
2243                    "credentials-provider/v1": "future-vault"
2244                },
2245                "modules": {}
2246            }"#,
2247            path,
2248        )
2249        .expect("a binding may predate its provider installation");
2250        assert_eq!(
2251            config.reserved_capabilities,
2252            BTreeMap::from([(
2253                "credentials-provider/v1".to_string(),
2254                "future-vault".to_string()
2255            )])
2256        );
2257
2258        let error = parse_doc(
2259            r#"{
2260                "version": 1,
2261                "reserved_capabilities": { "Credentials/v1": "vault" },
2262                "modules": {}
2263            }"#,
2264            path,
2265        )
2266        .expect_err("reserved capabilities use the capability identifier grammar");
2267        assert!(error.to_string().contains("reserved_capabilities key"));
2268    }
2269
2270    /// The three accepted shapes, and the one that matters is that two of them
2271    /// are THE SAME ANSWER. A config written before this key existed and a
2272    /// config that spells out `"subc"` must produce an identical module, or the
2273    /// key would have quietly introduced a third state for every module in every
2274    /// deployed config file.
2275    #[test]
2276    fn an_absent_protocol_key_and_an_explicit_subc_are_the_same_module() {
2277        let parse = |module_body: &str| {
2278            parse_doc(
2279                &format!(
2280                    r#"{{
2281                      "version": 1,
2282                      "modules": {{ "aft": {{ "program": "aft"{module_body} }} }}
2283                    }}"#
2284                ),
2285                Path::new("subc.jsonc"),
2286            )
2287            .expect("module parses")
2288            .modules
2289            .remove(0)
2290        };
2291
2292        let absent = parse("");
2293        let explicit = parse(r#", "protocol": "subc""#);
2294        let none = parse(r#", "protocol": "none""#);
2295
2296        assert_eq!(absent.protocol, ModuleProtocol::Subc);
2297        assert_eq!(explicit.protocol, ModuleProtocol::Subc);
2298        assert_eq!(
2299            absent, explicit,
2300            "an absent protocol key must produce exactly the module an explicit subc does"
2301        );
2302        assert_eq!(none.protocol, ModuleProtocol::None);
2303        // The declaration has to survive into what the supervisor is handed;
2304        // parsing it into a field nothing reads would leave every behaviour
2305        // gated on it unreachable.
2306        assert_eq!(none.module_spec().protocol, ModuleProtocol::None);
2307    }
2308
2309    /// `overlap` defaults to exclusive, `"safe"` opts in and reaches the spec
2310    /// the supervisor is handed, and anything else is refused rather than read
2311    /// as either value.
2312    #[test]
2313    fn overlap_defaults_to_exclusive_and_only_safe_opts_in() {
2314        let parse = |module_body: &str| {
2315            parse_doc(
2316                &format!(
2317                    r#"{{
2318                      "version": 1,
2319                      "modules": {{ "aft": {{ "program": "aft"{module_body} }} }}
2320                    }}"#
2321                ),
2322                Path::new("subc.jsonc"),
2323            )
2324        };
2325
2326        let absent = parse("").unwrap().modules.remove(0);
2327        assert_eq!(absent.overlap, ModuleOverlap::Exclusive);
2328        assert_eq!(absent.module_spec().overlap, ModuleOverlap::Exclusive);
2329        let safe = parse(r#", "overlap": "safe""#).unwrap().modules.remove(0);
2330        assert_eq!(safe.module_spec().overlap, ModuleOverlap::Safe);
2331        let typo = parse(r#", "overlap": "sfae""#).expect_err("an unknown overlap is refused");
2332        assert!(typo.to_string().contains("sfae"), "{typo}");
2333    }
2334
2335    /// The spawn role is the supervisor's to set on a swap candidate. A
2336    /// configured value would reach every plain spawn and make the module pick
2337    /// its long swap warm-up budget while callers wait on a restart.
2338    #[test]
2339    fn the_spawn_role_is_refused_as_a_configured_env_key() {
2340        let error = parse_doc(
2341            r#"{
2342              "version": 1,
2343              "modules": { "aft": { "program": "aft", "env": { "SUBC_SPAWN_ROLE": "swap_candidate" } } }
2344            }"#,
2345            Path::new("subc.jsonc"),
2346        )
2347        .expect_err("SUBC_SPAWN_ROLE must not be configurable");
2348        assert!(
2349            matches!(error, DaemonConfigError::InvalidValue { .. }),
2350            "expected InvalidValue, got {error:?}"
2351        );
2352        assert!(error.to_string().contains("SUBC_SPAWN_ROLE"), "{error}");
2353    }
2354
2355    /// An unusable value is refused WITH THE VALUE IN THE MESSAGE. Falling back
2356    /// to `subc` on a typo would restore the exact supervision the operator was
2357    /// trying to turn off -- health probing, restart-on-silence, SIGKILL
2358    /// teardown -- and the config file would still read as if it had been
2359    /// applied.
2360    #[test]
2361    fn an_unsupported_protocol_value_is_refused_by_name() {
2362        let error = parse_doc(
2363            r#"{
2364              "version": 1,
2365              "modules": { "nats": { "program": "nats-server", "protocol": "grpc" } }
2366            }"#,
2367            Path::new("subc.jsonc"),
2368        )
2369        .expect_err("an unknown protocol must not fall back to a default");
2370
2371        assert!(
2372            matches!(error, DaemonConfigError::InvalidValue { .. }),
2373            "expected InvalidValue, got {error:?}"
2374        );
2375        let message = error.to_string();
2376        assert!(
2377            message.contains("grpc"),
2378            "the refusal must name the offending value: {message}"
2379        );
2380        assert!(
2381            message.contains("nats"),
2382            "the refusal must name the module so it can be found in the file: {message}"
2383        );
2384    }
2385
2386    /// `reserved` is enforced on a module's HELLO. A module that speaks no subc
2387    /// wire never sends one, so the pair declares a protection that could never
2388    /// be applied -- worse than no protection, because the config file states it.
2389    #[test]
2390    fn reserved_true_with_protocol_none_is_refused_with_the_reason() {
2391        let error = parse_doc(
2392            r#"{
2393              "version": 1,
2394              "modules": {
2395                "nats": { "program": "nats-server", "protocol": "none", "reserved": true }
2396              }
2397            }"#,
2398            Path::new("subc.jsonc"),
2399        )
2400        .expect_err("a reservation that can never be checked must not parse");
2401
2402        assert!(
2403            matches!(error, DaemonConfigError::InvalidValue { .. }),
2404            "expected InvalidValue, got {error:?}"
2405        );
2406        let message = error.to_string();
2407        assert!(
2408            message.contains("nats") && message.contains("reserved"),
2409            "the refusal must name the module and the offending key: {message}"
2410        );
2411        assert!(
2412            message.contains("HELLO") || message.contains("never registers"),
2413            "the refusal must say WHY the pair cannot work: {message}"
2414        );
2415    }
2416
2417    #[test]
2418    fn reserved_prefixes_parse_for_reserved_modules() {
2419        let config = parse_doc(
2420            r#"
2421            {
2422              "version": 1,
2423              "modules": {
2424                "federation": {
2425                  "program": "fed",
2426                  "reserved": true,
2427                  "reserved_prefixes": ["fed:"]
2428                }
2429              }
2430            }
2431            "#,
2432            Path::new("subc.jsonc"),
2433        )
2434        .unwrap();
2435
2436        assert_eq!(config.modules[0].reserved_prefixes, ["fed:".to_string()]);
2437    }
2438
2439    #[test]
2440    fn reserved_prefixes_reject_bad_boundaries_and_owners() {
2441        let missing_delimiter = parse_doc(
2442            r#"{
2443              "version": 1,
2444              "modules": {
2445                "federation": { "program": "fed", "reserved": true, "reserved_prefixes": ["fed"] }
2446              }
2447            }"#,
2448            Path::new("subc.jsonc"),
2449        )
2450        .unwrap_err();
2451        assert!(matches!(
2452            missing_delimiter,
2453            DaemonConfigError::InvalidValue { .. }
2454        ));
2455
2456        let non_reserved_owner = parse_doc(
2457            r#"{
2458              "version": 1,
2459              "modules": {
2460                "federation": { "program": "fed", "reserved_prefixes": ["fed:"] }
2461              }
2462            }"#,
2463            Path::new("subc.jsonc"),
2464        )
2465        .unwrap_err();
2466        assert!(matches!(
2467            non_reserved_owner,
2468            DaemonConfigError::InvalidValue { .. }
2469        ));
2470    }
2471
2472    #[test]
2473    fn reserved_prefixes_reject_cross_owner_overlap_and_exact_id_collisions() {
2474        let overlap = parse_doc(
2475            r#"{
2476              "version": 1,
2477              "modules": {
2478                "fed-owner": { "program": "fed", "reserved": true, "reserved_prefixes": ["fed:"] },
2479                "sub-owner": { "program": "fed-sub", "reserved": true, "reserved_prefixes": ["fed:sub:"] }
2480              }
2481            }"#,
2482            Path::new("subc.jsonc"),
2483        )
2484        .unwrap_err();
2485        assert!(matches!(overlap, DaemonConfigError::InvalidValue { .. }));
2486
2487        let exact_collision = parse_doc(
2488            r#"{
2489              "version": 1,
2490              "modules": {
2491                "federation": { "program": "fed", "reserved": true, "reserved_prefixes": ["fed:"] },
2492                "fed:special": { "program": "special" }
2493              }
2494            }"#,
2495            Path::new("subc.jsonc"),
2496        )
2497        .unwrap_err();
2498        assert!(matches!(
2499            exact_collision,
2500            DaemonConfigError::InvalidValue { .. }
2501        ));
2502    }
2503
2504    #[test]
2505    fn health_config_parses_and_ignores_unknown_fields() {
2506        let config = parse_doc(
2507            r#"
2508            {
2509              "version": 1,
2510              "modules": {
2511                "aft": {
2512                  "program": "aft",
2513                  "health": {
2514                    "cadence_ms": 100,
2515                    "deadline_ms": 20,
2516                    "failure_threshold": 2,
2517                    "on_degraded": "report",
2518                    "on_failing": "restart",
2519                    "critical": true,
2520                    "future": "ignored"
2521                  }
2522                }
2523              }
2524            }
2525            "#,
2526            Path::new("subc.jsonc"),
2527        )
2528        .unwrap();
2529
2530        let health = config.modules[0].health;
2531        assert_eq!(health.cadence, std::time::Duration::from_millis(100));
2532        assert_eq!(health.deadline, std::time::Duration::from_millis(20));
2533        assert_eq!(health.failure_threshold, 2);
2534        assert_eq!(health.on_degraded, HealthAction::Report);
2535        assert_eq!(health.on_failing, HealthAction::Restart);
2536        assert!(health.critical);
2537    }
2538
2539    #[test]
2540    fn health_config_rejects_bad_enum_and_non_positive_numbers() {
2541        let bad_enum = parse_doc(
2542            r#"{
2543              "version": 1,
2544              "modules": { "aft": { "program": "aft", "health": { "on_failing": "page" } } }
2545            }"#,
2546            Path::new("subc.jsonc"),
2547        )
2548        .unwrap_err();
2549        assert!(matches!(bad_enum, DaemonConfigError::InvalidJson { .. }));
2550
2551        let zero = parse_doc(
2552            r#"{
2553              "version": 1,
2554              "modules": { "aft": { "program": "aft", "health": { "cadence_ms": 0 } } }
2555            }"#,
2556            Path::new("subc.jsonc"),
2557        )
2558        .unwrap_err();
2559        assert!(matches!(zero, DaemonConfigError::InvalidValue { .. }));
2560    }
2561
2562    #[test]
2563    fn admission_facts_carrier_requires_non_empty_targets() {
2564        let missing_targets = parse_doc(
2565            r#"{
2566              "version": 1,
2567              "admission_facts_carrier_module_id": "fed",
2568              "modules": { "fed": { "program": "fed", "reserved": true } }
2569            }"#,
2570            Path::new("subc.jsonc"),
2571        )
2572        .unwrap_err();
2573        // Pin the message, not just the variant. Every rule in this validator
2574        // returns InvalidValue, and the guard below rejects an empty list -- so
2575        // a change that turned a missing list into an empty one would still be
2576        // refused, by a different rule, and a variant-only assertion could not
2577        // tell the two apart.
2578        assert!(
2579            matches!(&missing_targets, DaemonConfigError::InvalidValue { message, .. }
2580                if message.contains("must be present")),
2581            "expected the presence rule, got: {missing_targets:?}"
2582        );
2583
2584        let empty_targets = parse_doc(
2585            r#"{
2586              "version": 1,
2587              "admission_facts_carrier_module_id": "fed",
2588              "admission_facts_targets": [""],
2589              "modules": { "fed": { "program": "fed", "reserved": true } }
2590            }"#,
2591            Path::new("subc.jsonc"),
2592        )
2593        .unwrap_err();
2594        assert!(
2595            matches!(&empty_targets, DaemonConfigError::InvalidValue { message, .. }
2596                if message.contains("must be non-empty")),
2597            "expected the non-empty rule, got: {empty_targets:?}"
2598        );
2599    }
2600
2601    #[test]
2602    fn admission_facts_carrier_must_be_enabled_reserved_and_configured() {
2603        for module in [
2604            r#"{ "program": "fed", "enabled": false, "reserved": true }"#,
2605            r#"{ "program": "fed", "enabled": true, "reserved": false }"#,
2606        ] {
2607            let doc = format!(
2608                r#"{{
2609                  "version": 1,
2610                  "admission_facts_carrier_module_id": "fed",
2611                  "admission_facts_targets": ["target"],
2612                  "modules": {{ "fed": {module}, "target": {{ "program": "target" }} }}
2613                }}"#
2614            );
2615            let err = parse_doc(&doc, Path::new("subc.jsonc")).unwrap_err();
2616            // Pin which refusal fired. Both inputs are also missing nothing
2617            // else, so without this the neighbouring "must name a configured
2618            // module" rule would satisfy the assertion if this one were removed.
2619            assert!(
2620                matches!(&err, DaemonConfigError::InvalidValue { message, .. }
2621                    if message.contains("enabled reserved module")),
2622                "expected the enabled-and-reserved rule, got: {err:?}"
2623            );
2624        }
2625
2626        let absent = parse_doc(
2627            r#"{
2628              "version": 1,
2629              "admission_facts_carrier_module_id": "missing",
2630              "admission_facts_targets": ["target"],
2631              "modules": { "target": { "program": "target" } }
2632            }"#,
2633            Path::new("subc.jsonc"),
2634        )
2635        .unwrap_err();
2636        assert!(
2637            matches!(&absent, DaemonConfigError::InvalidValue { message, .. }
2638                if message.contains("must name a configured module")),
2639            "expected the configured-module rule, got: {absent:?}"
2640        );
2641    }
2642
2643    #[test]
2644    fn reject_unsupported_version() {
2645        let err = parse_doc(
2646            r#"{ "version": 2, "modules": {} }"#,
2647            Path::new("subc.jsonc"),
2648        )
2649        .unwrap_err();
2650        assert!(matches!(
2651            err,
2652            DaemonConfigError::UnsupportedVersion { version: 2, .. }
2653        ));
2654    }
2655
2656    #[test]
2657    fn reject_unterminated_block_comment() {
2658        let err = parse_doc(r#"{ "version": 1, /*"#, Path::new("subc.jsonc")).unwrap_err();
2659        assert!(matches!(err, DaemonConfigError::InvalidJsonc { .. }));
2660    }
2661}