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