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