Skip to main content

subc_daemon/
daemon_config.rs

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