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