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