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