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