Skip to main content

aion_server/config/
load.rs

1//! [`ServerConfig`]: the complete merged server configuration, its load /
2//! merge / validate pipeline, and the workflow-package discovery helpers.
3//!
4//! This is the assembly point of the config surface: [`ServerConfig`] holds one
5//! field per `[section]` (defined in [`super::sections`]), and its impls carry
6//! the load/merge/CLI-override/default-fill/validate pipeline plus
7//! [`ServerConfig::into_parts`], which splits the durable store config from the
8//! non-secret [`RuntimeConfig`] runtime view. It is re-exported from the
9//! `config` module so every existing `crate::config::X` path resolves
10//! identically.
11
12use std::{
13    collections::HashSet,
14    fs,
15    path::{Path, PathBuf},
16    time::Duration,
17};
18
19use serde::Deserialize;
20
21use crate::error::ServerError;
22
23use super::{
24    AUTHORING_GLEAM_PATH_EMPTY, AUTHORING_PROJECT_ROOT_REQUIRED, AuthConfig, AuthoringConfig,
25    CORS_ALLOWED_ORIGIN_INVALID, CliOverrides, ClusterConfig, ConfigResolution,
26    DEFAULT_CLUSTER_BROADCAST_CAPACITY, DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES,
27    DEFAULT_DEPLOY_MAX_INFLATED_BYTES, DEFAULT_EVENT_BROADCAST_CAPACITY,
28    DEFAULT_OUTBOX_BACKOFF_BASE_MS, DEFAULT_OUTBOX_BACKOFF_MAX_MS,
29    DEFAULT_OUTBOX_BACKOFF_MULTIPLIER, DEFAULT_OUTBOX_BATCH_SIZE, DEFAULT_OUTBOX_MAX_ATTEMPTS,
30    DEFAULT_OUTBOX_POLL_INTERVAL_MS, DEFAULT_QUERY_TIMEOUT_MS, DEPLOY_MAX_ARCHIVE_BYTES_REQUIRED,
31    DEPLOY_MAX_INFLATED_BYTES_REQUIRED, DeployConfig, DevConfig, DrainConfig, HomeSource,
32    ListenConfig, McpConfig, MetricsConfig, NamespaceConfig, NamespaceMode, NamespacesConfig,
33    OUTBOX_BACKOFF_BASE_REQUIRED, OUTBOX_BACKOFF_MAX_REQUIRED, OUTBOX_BACKOFF_MULTIPLIER_REQUIRED,
34    OUTBOX_BATCH_SIZE_REQUIRED, OUTBOX_MAX_ATTEMPTS_REQUIRED, OUTBOX_POLL_INTERVAL_REQUIRED,
35    OUTBOX_RECONCILE_INTERVAL_REQUIRED, OUTBOX_RECONCILE_STALE_AFTER_REQUIRED, ObservabilityConfig,
36    OpsConsoleAssetSource, OpsConsoleConfig, OutboxConfig, QUERY_TIMEOUT_REQUIRED, RuntimeConfig,
37    RuntimeSection, ServerSection, StoreBackend, StoreConfig, TlsConfig, WebSocketConfig,
38    WorkerConfig, WorkerSupervisionConfig, aion_home, config_error, env, file, home,
39    resolution::fill_home_defaults, sections::RetiredStoreInput,
40};
41
42/// Complete merged server configuration.
43#[derive(Clone, Debug, Deserialize)]
44#[serde(default, deny_unknown_fields)]
45#[derive(Default)]
46pub struct ServerConfig {
47    /// Public listener and transport addresses.
48    pub server: ServerSection,
49    /// Event-store backend configuration.
50    pub store: StoreConfig,
51    /// Engine runtime settings.
52    pub runtime: RuntimeSection,
53    /// Shutdown drain settings.
54    pub drain: DrainConfig,
55    /// Authentication settings defined by the operations config surface.
56    pub auth: AuthConfig,
57    /// Metrics endpoint settings.
58    pub metrics: MetricsConfig,
59    /// Namespace defaults.
60    pub namespaces: NamespacesConfig,
61    /// Optional TLS material for transports that require it.
62    pub tls: Option<TlsConfig>,
63    /// Static ops-console asset bundle location.
64    #[serde(alias = "dashboard")]
65    pub ops_console: OpsConsoleConfig,
66    /// Namespace resolver construction mode retained for existing transports.
67    pub namespace: NamespaceConfig,
68    /// Remote-worker heartbeat policy.
69    pub worker: WorkerConfig,
70    /// WebSocket event streaming policy.
71    pub websocket: WebSocketConfig,
72    /// Workflow package archives loaded into the engine at startup.
73    pub workflow_packages: Vec<PathBuf>,
74    /// Operator deploy API settings.
75    pub deploy: DeployConfig,
76    /// AWL studio and optional server-side Gleam authoring settings.
77    pub authoring: AuthoringConfig,
78    /// Local dev-server surface settings.
79    pub dev: DevConfig,
80    /// Durable-outbox fan-out dispatcher settings.
81    pub outbox: OutboxConfig,
82    /// Agent-observability transcript retention bounds.
83    pub observability: ObservabilityConfig,
84    /// Model Context Protocol surface settings.
85    pub mcp: McpConfig,
86    /// Managed-worker supervision policy. Absent = supervision uncommissioned:
87    /// the server runs no worker processes and says so with the remedy.
88    pub worker_supervision: WorkerSupervisionConfig,
89}
90
91pub(crate) struct LoadedConfig {
92    pub(crate) config: ServerConfig,
93    pub(crate) resolution: ConfigResolution,
94}
95
96/// Expand a leading `~` in the operator-supplied filesystem paths.
97///
98/// #180 review m10: `~` already expands for `AION_HOME` and `--config`, so an
99/// operator naturally writes it in the path VALUES too — where it used to pass
100/// through untouched and silently become a literal `./~` directory. The same
101/// [`home::expand_tilde`] rule applies to `store.data_dir` and
102/// `authoring.workspace_dir` (the two home-adjacent state roots): `~`/`~/…`
103/// expand against `$HOME`, `~user` is refused by name, and a path with no
104/// tilde passes through byte-for-byte.
105fn expand_operator_path_tildes(config: &mut ServerConfig) -> Result<(), ServerError> {
106    if let Some(data_dir) = config.store.data_dir.as_deref() {
107        let expanded = home::expand_tilde(Path::new(data_dir))?;
108        let expanded = expanded.to_str().ok_or_else(|| ServerError::Config {
109            message: format!(
110                "store.data_dir `{data_dir}` expands to a path that is not valid UTF-8"
111            ),
112        })?;
113        config.store.data_dir = Some(expanded.to_owned());
114    }
115    if let Some(workspace_dir) = config.authoring.workspace_dir.as_deref() {
116        config.authoring.workspace_dir = Some(home::expand_tilde(workspace_dir)?);
117    }
118    Ok(())
119}
120
121impl ServerConfig {
122    /// Load and merge config from defaults, optional TOML file, environment, and CLI overrides.
123    ///
124    /// # Errors
125    ///
126    /// Returns [`ServerError::Config`] when file discovery, parsing, environment parsing, CLI
127    /// values, or validation fail.
128    pub fn load(cli: &CliOverrides) -> Result<Self, ServerError> {
129        Ok(Self::load_resolved(cli)?.config)
130    }
131
132    pub(crate) fn load_resolved(cli: &CliOverrides) -> Result<LoadedConfig, ServerError> {
133        let home = aion_home()?;
134        let working_dir = std::env::current_dir().map_err(|source| ServerError::Config {
135            message: format!(
136                "failed to resolve the current directory for config discovery: {source}"
137            ),
138        })?;
139        Self::load_in(cli, &home.path, home.source, &working_dir, true)
140    }
141
142    fn load_in(
143        cli: &CliOverrides,
144        home: &Path,
145        home_source: HomeSource,
146        working_dir: &Path,
147        overlay_environment: bool,
148    ) -> Result<LoadedConfig, ServerError> {
149        // #152: `--config ~/aion.toml` reaches this point unexpanded whenever a
150        // shell did not get to it (quoted values, env files, process managers);
151        // expand it here so the operator's spelling and the loader agree.
152        let explicit = cli
153            .config_path
154            .as_deref()
155            .map(super::home::expand_tilde)
156            .transpose()?;
157        let discovered = file::discover(explicit.as_deref(), home, working_dir)?;
158        let mut config = match discovered.bytes {
159            Some(bytes) => Self::parse_unresolved(&bytes).map_err(|error| ServerError::Config {
160                message: format!("failed to parse {}: {error}", discovered.source),
161            })?,
162            None => Self::default(),
163        };
164        if overlay_environment {
165            env::overlay(&mut config)?;
166        }
167        config.apply_cli_overrides(cli);
168        expand_operator_path_tildes(&mut config)?;
169        #[cfg(not(unix))]
170        let home_explicit = !overlay_environment || std::env::var_os("AION_HOME").is_some();
171        #[cfg(not(unix))]
172        let data_dir_explicit = config.store.data_dir.is_some();
173        #[cfg(not(unix))]
174        let data_root_required = matches!(config.store.backend, StoreBackend::Haematite);
175        #[cfg(not(unix))]
176        let authoring_workspace_explicit = config.authoring.workspace_dir.is_some();
177        config.load_discovered_workflow_packages(cli, working_dir)?;
178        let legacy_notices = fill_home_defaults(&mut config, home, home_source, working_dir)?;
179        config.fill_operational_defaults();
180        config.validate()?;
181        let resolution = ConfigResolution {
182            home: home.to_owned(),
183            source: discovered.source,
184            data_dir: config.store.data_dir.clone(),
185            authoring_workspace: config.authoring.workspace_dir.clone(),
186            legacy_notices,
187            #[cfg(not(unix))]
188            home_explicit,
189            #[cfg(not(unix))]
190            data_dir_explicit,
191            #[cfg(not(unix))]
192            data_root_required,
193            #[cfg(not(unix))]
194            authoring_workspace_explicit,
195        };
196        Ok(LoadedConfig { config, resolution })
197    }
198
199    /// Load with an explicitly supplied home, without environment or CLI
200    /// overlays.
201    ///
202    /// `home_source` is a parameter rather than a constant because the two
203    /// worlds it selects between are exactly what the legacy-directory guard
204    /// turns on, and a test must be able to say which world it is standing in.
205    /// A test that could only ever simulate one of them would pin half the
206    /// behaviour and call it covered.
207    #[cfg(test)]
208    pub(super) fn load_for_test(
209        cli: &CliOverrides,
210        home: &Path,
211        home_source: HomeSource,
212        working_dir: &Path,
213    ) -> Result<LoadedConfig, ServerError> {
214        Self::load_in(cli, home, home_source, working_dir, false)
215    }
216
217    /// Fill operational tuning knobs that have a sane default when omitted, so a
218    /// minimal or empty config boots without forcing the operator to hand-author
219    /// values that are pure tuning. Uses `get_or_insert`, so an explicitly set
220    /// value (including a misconfigured `0`, which [`Self::validate`] still
221    /// rejects) is left untouched; only an absent (`None`) field is defaulted.
222    fn fill_operational_defaults(&mut self) {
223        self.runtime
224            .query_timeout_ms
225            .get_or_insert(DEFAULT_QUERY_TIMEOUT_MS);
226        self.websocket
227            .event_broadcast_capacity
228            .get_or_insert(DEFAULT_EVENT_BROADCAST_CAPACITY);
229        self.websocket
230            .cluster_broadcast_capacity
231            .get_or_insert(DEFAULT_CLUSTER_BROADCAST_CAPACITY);
232        self.fill_outbox_defaults();
233        self.fill_deploy_defaults();
234    }
235
236    /// Fill the durable-outbox tuning knobs with sane defaults when the
237    /// dispatcher is enabled but a knob was omitted, so turning the feature on
238    /// does not force hand-authoring pure tuning. Inert while `outbox.enabled`
239    /// is false (the knobs are never read behind the gate). The reconciliation
240    /// pair is intentionally NOT defaulted: when both are absent reconciliation
241    /// stays dark, so forcing a default would silently commission a sweep.
242    /// `get_or_insert` leaves any explicit value (including a misconfigured `0`,
243    /// which [`Self::validate_outbox`] still rejects) untouched.
244    fn fill_outbox_defaults(&mut self) {
245        if !self.outbox.enabled {
246            return;
247        }
248        self.outbox
249            .poll_interval_ms
250            .get_or_insert(DEFAULT_OUTBOX_POLL_INTERVAL_MS);
251        self.outbox
252            .batch_size
253            .get_or_insert(DEFAULT_OUTBOX_BATCH_SIZE);
254        self.outbox
255            .max_attempts
256            .get_or_insert(DEFAULT_OUTBOX_MAX_ATTEMPTS);
257        self.outbox
258            .backoff_base_ms
259            .get_or_insert(DEFAULT_OUTBOX_BACKOFF_BASE_MS);
260        self.outbox
261            .backoff_multiplier
262            .get_or_insert(DEFAULT_OUTBOX_BACKOFF_MULTIPLIER);
263        self.outbox
264            .backoff_max_ms
265            .get_or_insert(DEFAULT_OUTBOX_BACKOFF_MAX_MS);
266    }
267
268    /// Fill the deploy decompression-bomb ceilings with conservative defaults
269    /// when the deploy surface is enabled but a ceiling was omitted, so turning
270    /// the feature on boots rather than refusing for want of a security knob.
271    /// Inert while `deploy.enabled` is false (the ceilings are never read with
272    /// the surface dark). `get_or_insert` leaves any explicit value (including a
273    /// misconfigured `0` or an inflate ceiling below the archive ceiling, both
274    /// still rejected by [`Self::validate`]) untouched.
275    fn fill_deploy_defaults(&mut self) {
276        if !self.deploy.enabled {
277            return;
278        }
279        self.deploy
280            .max_archive_bytes
281            .get_or_insert(DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES);
282        self.deploy
283            .max_inflated_bytes
284            .get_or_insert(DEFAULT_DEPLOY_MAX_INFLATED_BYTES);
285    }
286
287    fn load_discovered_workflow_packages(
288        &mut self,
289        cli: &CliOverrides,
290        directory: &Path,
291    ) -> Result<(), ServerError> {
292        let discovered_packages = discover_workflow_packages(directory)?;
293        merge_workflow_packages(
294            &mut self.workflow_packages,
295            discovered_packages,
296            &cli.workflow_packages,
297        );
298        Ok(())
299    }
300
301    /// Parse server configuration from TOML bytes and validate it.
302    ///
303    /// # Errors
304    ///
305    /// Returns [`ServerError::Config`] when parsing fails or values are invalid.
306    pub fn from_slice(bytes: &[u8]) -> Result<Self, ServerError> {
307        let home = aion_home()?;
308        Self::from_slice_with_home(bytes, &home.path)
309    }
310
311    /// Parse server configuration using an explicitly supplied Aion home.
312    ///
313    /// This is useful for embedded callers and tests that must not consult or
314    /// touch the process user's real home. It applies the same dynamic defaults
315    /// as [`Self::load`], but does not apply environment or CLI overlays, and
316    /// naming the home here suppresses the working-directory legacy fallback
317    /// exactly as setting `AION_HOME` does — an embedded caller that supplies a
318    /// home is isolating this server's state, not asking to inherit whatever
319    /// directory the process happens to have been started in.
320    ///
321    /// # Errors
322    ///
323    /// Returns [`ServerError::Config`] when parsing, current-directory
324    /// resolution, dynamic path defaulting, or validation fails.
325    pub fn from_slice_with_home(bytes: &[u8], home: &Path) -> Result<Self, ServerError> {
326        let working_dir = std::env::current_dir().map_err(|source| ServerError::Config {
327            message: format!(
328                "failed to resolve the current directory for config defaults: {source}"
329            ),
330        })?;
331        let mut config = Self::parse_unresolved(bytes)?;
332        // The caller named the home in the call itself, which is the same
333        // assertion `AION_HOME` makes and is treated identically: a legacy
334        // directory in the working directory must not override it.
335        fill_home_defaults(&mut config, home, HomeSource::Explicit, &working_dir)?;
336        config.fill_operational_defaults();
337        config.validate()?;
338        Ok(config)
339    }
340
341    fn parse_unresolved(bytes: &[u8]) -> Result<Self, ServerError> {
342        toml::from_slice(bytes).map_err(|source| ServerError::Config {
343            message: format!("invalid server config: {source}"),
344        })
345    }
346
347    /// Load server configuration from an explicit TOML file path.
348    ///
349    /// # Errors
350    ///
351    /// Returns [`ServerError::Config`] when the file is missing, unreadable, unparsable, or invalid.
352    pub fn load_from_path(path: impl Into<PathBuf>) -> Result<Self, ServerError> {
353        file::load_required(&path.into())
354    }
355
356    /// Split store configuration from non-secret runtime settings.
357    #[must_use]
358    pub fn into_parts(self) -> (StoreConfig, RuntimeConfig) {
359        let runtime = RuntimeConfig {
360            listen: ListenConfig {
361                grpc: self.server.grpc_address,
362                http: self.server.listen_address,
363            },
364            tls: self.tls,
365            auth: self.auth,
366            ops_console: self.ops_console,
367            namespace: self.namespace,
368            worker: self.worker,
369            websocket: self.websocket,
370            workflow_packages: self.workflow_packages,
371            deploy: self.deploy,
372            authoring: self.authoring,
373            dev: self.dev,
374            outbox: self.outbox,
375            observability: self.observability,
376            mcp: self.mcp.resolved(),
377            scheduler_threads: self.runtime.scheduler_threads,
378            query_timeout: self.runtime.query_timeout_ms.map(Duration::from_millis),
379            default_namespace: self.namespaces.default,
380            auto_create: self.namespaces.auto_create,
381            max_in_flight_activities: self.namespaces.max_in_flight_activities,
382            drain_timeout: Duration::from_secs(self.drain.timeout_seconds),
383            metrics: self.metrics,
384            owned_shards: self.store.owned_shards.clone(),
385            cors_allowed_origins: self.server.cors_allowed_origins.clone(),
386        };
387        (self.store, runtime)
388    }
389
390    fn apply_cli_overrides(&mut self, cli: &CliOverrides) {
391        if let Some(address) = cli.listen_address {
392            self.server.listen_address = address;
393        }
394        if let Some(address) = cli.grpc_address {
395            self.server.grpc_address = address;
396        }
397        if cli.store_url.is_some() {
398            self.store.retired_input = Some(RetiredStoreInput::Flag);
399        }
400        if let Some(threads) = cli.scheduler_threads {
401            self.runtime.scheduler_threads = threads;
402        }
403        if let Some(timeout) = cli.drain_timeout_seconds {
404            self.drain.timeout_seconds = timeout;
405        }
406        if let Some(gleam_path) = &cli.gleam_path {
407            self.authoring.gleam_path = Some(gleam_path.clone());
408        }
409        if let Some(project_root) = &cli.authoring_project_root {
410            self.authoring.project_root = Some(project_root.clone());
411        }
412    }
413
414    fn validate(&self) -> Result<(), ServerError> {
415        if let Some(input) = self.store.retired_input {
416            let found = match input {
417                RetiredStoreInput::Backend => "backend = \"libsql\"",
418                RetiredStoreInput::BackendEnvironment => "AION_STORE_BACKEND=libsql",
419                RetiredStoreInput::Url => "store.url",
420                RetiredStoreInput::Environment => "AION_STORE_URL",
421                RetiredStoreInput::Flag => "--store-url",
422            };
423            return config_error(format!(
424                "found retired {found}; use backend = \"haematite\" with store.data_dir and store.node_cache_budget (all three are required — haematite refuses to start without the budget); existing libsql database files are not read by this build"
425            ));
426        }
427        if self.server.listen_address.port() == 0 {
428            return config_error("server.listen_address must use an explicit non-zero port");
429        }
430        if self.server.grpc_address.port() == 0 {
431            return config_error("server.grpc_address must use an explicit non-zero port");
432        }
433        validate_cors_origins(&self.server.cors_allowed_origins)?;
434        if self.runtime.scheduler_threads == 0 {
435            return config_error("runtime.scheduler_threads must be greater than zero");
436        }
437        if self.drain.timeout_seconds == 0 {
438            return config_error("drain.timeout_seconds must be greater than zero");
439        }
440        if self.auth.enabled && self.auth.jwks_url.as_deref().is_none_or(str::is_empty) {
441            return config_error("auth.jwks_url must not be empty when auth.enabled is true");
442        }
443        if self.auth.jwks_refresh_seconds == 0 {
444            return config_error("auth.jwks_refresh_seconds must be greater than zero");
445        }
446        if self.namespaces.default.is_empty() {
447            return config_error("namespaces.default must not be empty");
448        }
449        if matches!(self.store.backend, StoreBackend::Haematite) {
450            if self.store.data_dir.as_deref().is_none_or(str::is_empty) {
451                return config_error(
452                    "store.data_dir must not be empty when store.backend is haematite",
453                );
454            }
455            if self.store.shard_count == 0 {
456                return config_error("store.shard_count must be greater than zero");
457            }
458            if let Some(cluster) = &self.store.cluster {
459                validate_cluster(cluster)?;
460            }
461        } else if self.store.cluster.is_some() {
462            return config_error("store.cluster is only valid when store.backend is haematite");
463        }
464        if let OpsConsoleAssetSource::FileSystem { asset_path } = &self.ops_console.source {
465            if asset_path.as_os_str().is_empty() {
466                return config_error("ops_console.source.FileSystem.asset_path must not be empty");
467            }
468        }
469        if let NamespaceMode::SingleTenant { namespace } = &self.namespace.mode {
470            if namespace.is_empty() {
471                return config_error("namespace.mode.SingleTenant.namespace must not be empty");
472            }
473        }
474        if self.worker.heartbeat_window.is_zero() {
475            return config_error("worker.heartbeat_window must be greater than zero");
476        }
477        self.websocket.validate()?;
478        self.observability.validate()?;
479        self.mcp.validate()?;
480        match self.runtime.query_timeout_ms {
481            None | Some(0) => return config_error(QUERY_TIMEOUT_REQUIRED),
482            Some(_) => {}
483        }
484        if self.deploy.enabled {
485            let max_archive_bytes = match self.deploy.max_archive_bytes {
486                None | Some(0) => return config_error(DEPLOY_MAX_ARCHIVE_BYTES_REQUIRED),
487                Some(value) => value,
488            };
489            let max_inflated_bytes = match self.deploy.max_inflated_bytes {
490                None | Some(0) => return config_error(DEPLOY_MAX_INFLATED_BYTES_REQUIRED),
491                Some(value) => value,
492            };
493            // Both ceilings size in-memory buffers, so they must be
494            // addressable on this platform (32-bit targets).
495            ensure_fits_usize("deploy.max_archive_bytes", max_archive_bytes)?;
496            ensure_fits_usize("deploy.max_inflated_bytes", max_inflated_bytes)?;
497            if max_inflated_bytes < max_archive_bytes {
498                return config_error(format!(
499                    "deploy.max_inflated_bytes ({max_inflated_bytes}) must be at least deploy.max_archive_bytes ({max_archive_bytes}): an inflate ceiling below the upload ceiling would refuse archives the upload ceiling admits, even stored uncompressed"
500                ));
501            }
502        }
503        if let Some(gleam_path) = &self.authoring.gleam_path {
504            // The authoring surface is commissioned by a non-empty gleam_path;
505            // an empty value is a misconfiguration, not "dark".
506            if gleam_path.as_os_str().is_empty() {
507                return config_error(AUTHORING_GLEAM_PATH_EMPTY);
508            }
509            // Commissioning the loop requires a project root with no default
510            // (a Gleam project cannot be invented; the operator provisions it).
511            match &self.authoring.project_root {
512                Some(root) if !root.as_os_str().is_empty() => {}
513                _ => return config_error(AUTHORING_PROJECT_ROOT_REQUIRED),
514            }
515        }
516        self.validate_outbox()?;
517        // Resolution IS the validation: a partial or nonsensical
518        // `[worker_supervision]` section refuses here, at load, rather than at
519        // the first `aion worker start` hours later.
520        self.worker_supervision.resolve()?;
521        Ok(())
522    }
523
524    /// Validate the durable-outbox dispatcher knobs.
525    ///
526    /// All knobs are inert while `outbox.enabled` is false (the dispatcher is
527    /// never spawned), so they are only required — and only checked — once the
528    /// operator commissions the dispatcher. This mirrors the dark-by-default
529    /// `deploy` surface: the on/off gate carries no defaults, and every
530    /// operational value behind it is an explicit operator decision.
531    fn validate_outbox(&self) -> Result<(), ServerError> {
532        if !self.outbox.enabled {
533            return Ok(());
534        }
535        match self.outbox.poll_interval_ms {
536            None | Some(0) => return config_error(OUTBOX_POLL_INTERVAL_REQUIRED),
537            Some(_) => {}
538        }
539        match self.outbox.batch_size {
540            None | Some(0) => return config_error(OUTBOX_BATCH_SIZE_REQUIRED),
541            Some(_) => {}
542        }
543        match self.outbox.max_attempts {
544            None | Some(0) => return config_error(OUTBOX_MAX_ATTEMPTS_REQUIRED),
545            Some(_) => {}
546        }
547        let backoff_base_ms = match self.outbox.backoff_base_ms {
548            None | Some(0) => return config_error(OUTBOX_BACKOFF_BASE_REQUIRED),
549            Some(value) => value,
550        };
551        match self.outbox.backoff_multiplier {
552            None | Some(0) => return config_error(OUTBOX_BACKOFF_MULTIPLIER_REQUIRED),
553            Some(_) => {}
554        }
555        match self.outbox.backoff_max_ms {
556            Some(max) if max >= backoff_base_ms => {}
557            _ => return config_error(OUTBOX_BACKOFF_MAX_REQUIRED),
558        }
559        match (
560            self.outbox.reconcile_interval_ms,
561            self.outbox.reconcile_stale_after_ms,
562        ) {
563            (None, None) => {}
564            (None | Some(0), _) => return config_error(OUTBOX_RECONCILE_INTERVAL_REQUIRED),
565            (_, None | Some(0)) => return config_error(OUTBOX_RECONCILE_STALE_AFTER_REQUIRED),
566            (Some(_), Some(_)) => {}
567        }
568        Ok(())
569    }
570}
571
572/// Validate a `[store.cluster]` section: a non-empty node id, and every member /
573/// peer name non-empty. A cluster of one (no peers, members empty or `[node_id]`)
574/// is valid.
575fn validate_cluster(cluster: &ClusterConfig) -> Result<(), ServerError> {
576    if cluster.node_id.is_empty() {
577        return config_error("store.cluster.node_id must not be empty");
578    }
579    if cluster.members.iter().any(String::is_empty) {
580        return config_error("store.cluster.members entries must not be empty");
581    }
582    if cluster.peers.iter().any(|peer| peer.name.is_empty()) {
583        return config_error("store.cluster.peers entries must name a non-empty node");
584    }
585    if matches!(cluster.failover_poll_interval_ms, Some(0)) {
586        return config_error(
587            "store.cluster.failover_poll_interval_ms must be greater than zero when set",
588        );
589    }
590    if matches!(cluster.failover_confirmations, Some(0)) {
591        return config_error("store.cluster.failover_confirmations must be at least one when set");
592    }
593    Ok(())
594}
595
596/// Validate every `[server] cors_allowed_origins` entry.
597fn validate_cors_origins(origins: &[String]) -> Result<(), ServerError> {
598    for origin in origins {
599        validate_cors_origin(origin)?;
600    }
601    Ok(())
602}
603
604/// Validate one `[server] cors_allowed_origins` entry: it must be a non-empty,
605/// parseable HTTP origin so the `CorsLayer` can match it against the browser's
606/// `Origin` header. A malformed origin can never match a real request, so it is
607/// a misconfiguration caught at startup rather than silently never matching.
608fn validate_cors_origin(origin: &str) -> Result<(), ServerError> {
609    if origin.is_empty() {
610        return config_error(CORS_ALLOWED_ORIGIN_INVALID);
611    }
612    // An origin is scheme + host + optional port and carries no path: reject a
613    // trailing slash or any path segment, which would never equal a browser
614    // `Origin` header value.
615    let scheme_split = origin.split_once("://");
616    let Some((scheme, authority)) = scheme_split else {
617        return config_error(CORS_ALLOWED_ORIGIN_INVALID);
618    };
619    if scheme.is_empty() || authority.is_empty() || authority.contains('/') {
620        return config_error(CORS_ALLOWED_ORIGIN_INVALID);
621    }
622    // It must parse as an HTTP header value (the form the CorsLayer compares).
623    if origin.parse::<axum::http::HeaderValue>().is_err() {
624        return config_error(CORS_ALLOWED_ORIGIN_INVALID);
625    }
626    Ok(())
627}
628
629/// Refuses byte-ceiling values that cannot index memory on this platform.
630fn ensure_fits_usize(key: &str, value: u64) -> Result<(), ServerError> {
631    if usize::try_from(value).is_err() {
632        return config_error(format!(
633            "{key} ({value}) exceeds this platform's addressable memory; set it to at most {}",
634            usize::MAX
635        ));
636    }
637    Ok(())
638}
639
640fn discover_workflow_packages(directory: &Path) -> Result<Vec<PathBuf>, ServerError> {
641    let mut packages = Vec::new();
642    let entries = fs::read_dir(directory).map_err(|source| ServerError::Config {
643        message: format!(
644            "failed to scan workflow packages in `{}`: {source}",
645            directory.display()
646        ),
647    })?;
648
649    for entry in entries {
650        let entry = entry.map_err(|source| ServerError::Config {
651            message: format!(
652                "failed to read workflow package entry in `{}`: {source}",
653                directory.display()
654            ),
655        })?;
656        let path = entry.path();
657        let has_aion_extension = path
658            .extension()
659            .is_some_and(|extension| extension == "aion");
660        if path.is_file() && has_aion_extension {
661            packages.push(path);
662        }
663    }
664
665    packages.sort_by(|left, right| left.as_os_str().cmp(right.as_os_str()));
666    Ok(packages)
667}
668
669fn merge_workflow_packages(
670    workflow_packages: &mut Vec<PathBuf>,
671    discovered_packages: Vec<PathBuf>,
672    cli_packages: &[PathBuf],
673) {
674    let mut seen: HashSet<PathBuf> = workflow_packages
675        .iter()
676        .map(|package| deduplicated_package_key(package))
677        .collect();
678    for package in discovered_packages
679        .into_iter()
680        .chain(cli_packages.iter().cloned())
681    {
682        if seen.insert(deduplicated_package_key(&package)) {
683            workflow_packages.push(package);
684        }
685    }
686}
687
688fn deduplicated_package_key(path: &Path) -> PathBuf {
689    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
690}
691
692#[cfg(test)]
693#[path = "load_home_tests.rs"]
694mod home_tests;
695
696#[cfg(test)]
697mod tests {
698    use crate::config::{
699        AutoCreate, DEFAULT_AUTHORING_WORKSPACE_DIR, DEFAULT_MAX_IN_FLIGHT_ACTIVITIES, HomeSource,
700        OpsConsoleAssetSource,
701    };
702
703    use super::{
704        CliOverrides, DEFAULT_CLUSTER_BROADCAST_CAPACITY, DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES,
705        DEFAULT_DEPLOY_MAX_INFLATED_BYTES, DEFAULT_EVENT_BROADCAST_CAPACITY,
706        DEFAULT_OUTBOX_BACKOFF_BASE_MS, DEFAULT_OUTBOX_BACKOFF_MAX_MS,
707        DEFAULT_OUTBOX_BACKOFF_MULTIPLIER, DEFAULT_OUTBOX_BATCH_SIZE, DEFAULT_OUTBOX_MAX_ATTEMPTS,
708        DEFAULT_OUTBOX_POLL_INTERVAL_MS, DEFAULT_QUERY_TIMEOUT_MS, ServerConfig, StoreBackend,
709        discover_workflow_packages, merge_workflow_packages,
710    };
711
712    #[test]
713    fn valid_toml_is_parsed_into_typed_config() -> Result<(), Box<dyn std::error::Error>> {
714        let config = ServerConfig::from_slice(
715            br#"
716                [server]
717                listen_address = "127.0.0.1:18080"
718                grpc_address = "127.0.0.1:15051"
719
720                [store]
721                backend = "haematite"
722                data_dir = "aion-data"
723
724                [runtime]
725                scheduler_threads = 2
726                query_timeout_ms = 10000
727
728                [drain]
729                timeout_seconds = 45
730
731                [auth]
732                enabled = true
733                jwks_url = "https://issuer.example.com/.well-known/jwks.json"
734                jwks_refresh_seconds = 60
735
736                [metrics]
737                enabled = true
738
739                [namespaces]
740                default = "production"
741
742                [websocket]
743                outbound_buffer_bound = 16
744                event_broadcast_capacity = 1024
745                cluster_broadcast_capacity = 1024
746            "#,
747        )?;
748
749        assert_eq!(config.store.backend, StoreBackend::Haematite);
750        assert_eq!(config.store.data_dir.as_deref(), Some("aion-data"));
751        assert_eq!(config.runtime.scheduler_threads, 2);
752        assert_eq!(config.runtime.query_timeout_ms, Some(10_000));
753        assert_eq!(config.namespaces.default, "production");
754        // `auto_create` is omitted above, so it resolves to the Open default.
755        assert_eq!(config.namespaces.auto_create, AutoCreate::Open);
756        // `max_in_flight_activities` is omitted above, so it resolves to the
757        // generous platform default.
758        assert_eq!(
759            config.namespaces.max_in_flight_activities,
760            DEFAULT_MAX_IN_FLIGHT_ACTIVITIES
761        );
762        assert_eq!(config.websocket.outbound_buffer_bound, 16);
763        assert_eq!(config.websocket.event_broadcast_capacity, Some(1024));
764        Ok(())
765    }
766
767    #[test]
768    fn namespaces_auto_create_closed_parses() -> Result<(), Box<dyn std::error::Error>> {
769        let config = ServerConfig::from_slice(
770            br#"
771                [namespaces]
772                default = "production"
773                auto_create = "closed"
774            "#,
775        )?;
776        assert_eq!(config.namespaces.default, "production");
777        assert_eq!(config.namespaces.auto_create, AutoCreate::Closed);
778        Ok(())
779    }
780
781    #[test]
782    fn namespaces_auto_create_open_parses() -> Result<(), Box<dyn std::error::Error>> {
783        let config = ServerConfig::from_slice(
784            br#"
785                [namespaces]
786                auto_create = "open"
787            "#,
788        )?;
789        assert_eq!(config.namespaces.auto_create, AutoCreate::Open);
790        Ok(())
791    }
792
793    #[test]
794    fn namespaces_max_in_flight_activities_override_parses()
795    -> Result<(), Box<dyn std::error::Error>> {
796        let config = ServerConfig::from_slice(
797            br#"
798                [namespaces]
799                default = "production"
800                max_in_flight_activities = 32
801            "#,
802        )?;
803        assert_eq!(config.namespaces.max_in_flight_activities, 32);
804        // The override also propagates into the runtime view.
805        let (_store, runtime) = config.into_parts();
806        assert_eq!(runtime.max_in_flight_activities, 32);
807        Ok(())
808    }
809
810    #[test]
811    fn namespaces_max_in_flight_activities_defaults_when_omitted()
812    -> Result<(), Box<dyn std::error::Error>> {
813        // An old/minimal config that predates the field omits it entirely and
814        // resolves to the generous platform default (additive, not a migration).
815        let config = ServerConfig::from_slice(
816            br#"
817                [namespaces]
818                default = "production"
819            "#,
820        )?;
821        assert_eq!(
822            config.namespaces.max_in_flight_activities,
823            DEFAULT_MAX_IN_FLIGHT_ACTIVITIES
824        );
825        Ok(())
826    }
827
828    #[test]
829    fn namespaces_auto_create_rejects_unknown_variant() {
830        let result = ServerConfig::from_slice(
831            br#"
832                [namespaces]
833                auto_create = "sometimes"
834            "#,
835        );
836        assert!(
837            result.is_err(),
838            "an unknown auto_create variant must fail to parse"
839        );
840    }
841
842    #[test]
843    fn missing_event_broadcast_capacity_uses_default() -> Result<(), Box<dyn std::error::Error>> {
844        // The server unconditionally mounts /events/stream, but the channel
845        // capacity is a tuning knob: omitting it must resolve to the default and
846        // boot, not fail startup.
847        let config = ServerConfig::from_slice(
848            br"
849                [runtime]
850                query_timeout_ms = 10000
851
852                [websocket]
853                cluster_broadcast_capacity = 64
854            ",
855        )?;
856        assert_eq!(
857            config.websocket.event_broadcast_capacity,
858            Some(DEFAULT_EVENT_BROADCAST_CAPACITY),
859            "omitted event_broadcast_capacity must resolve to the default"
860        );
861        Ok(())
862    }
863
864    #[test]
865    fn zero_event_broadcast_capacity_fails_startup_validation() {
866        let result = ServerConfig::from_slice(
867            br"
868                [websocket]
869                event_broadcast_capacity = 0
870            ",
871        );
872
873        let message = result
874            .err()
875            .map_or_else(String::new, |error| error.to_string());
876        assert!(
877            message.contains("websocket.event_broadcast_capacity"),
878            "validation message must name the zero-valued key: {message}"
879        );
880    }
881
882    #[test]
883    fn missing_cluster_broadcast_capacity_uses_default() -> Result<(), Box<dyn std::error::Error>> {
884        // A config that sizes the workflow channel but omits the low-rate cluster
885        // channel must resolve the cluster capacity to its default and boot, not
886        // fail loudly.
887        let config = ServerConfig::from_slice(
888            br"
889                [runtime]
890                scheduler_threads = 1
891                query_timeout_ms = 10000
892
893                [websocket]
894                event_broadcast_capacity = 64
895            ",
896        )?;
897        assert_eq!(
898            config.websocket.cluster_broadcast_capacity,
899            Some(DEFAULT_CLUSTER_BROADCAST_CAPACITY),
900            "omitted cluster_broadcast_capacity must resolve to the default"
901        );
902        Ok(())
903    }
904
905    #[test]
906    fn zero_cluster_broadcast_capacity_fails_startup_validation() {
907        let result = ServerConfig::from_slice(
908            br"
909                [runtime]
910                query_timeout_ms = 10000
911
912                [websocket]
913                event_broadcast_capacity = 64
914                cluster_broadcast_capacity = 0
915            ",
916        );
917
918        let message = result
919            .err()
920            .map_or_else(String::new, |error| error.to_string());
921        assert!(
922            message.contains("websocket.cluster_broadcast_capacity"),
923            "validation message must name the zero-valued cluster key: {message}"
924        );
925    }
926
927    /// An omitted `[observability]` section resolves both retention bounds to
928    /// their defaults and boots — retention is on out of the box, never a
929    /// forced operator decision.
930    #[test]
931    fn missing_observability_section_uses_defaults() -> Result<(), Box<dyn std::error::Error>> {
932        let config = ServerConfig::from_slice(
933            br"
934                [runtime]
935                query_timeout_ms = 10000
936
937                [websocket]
938                event_broadcast_capacity = 64
939                cluster_broadcast_capacity = 64
940            ",
941        )?;
942        assert_eq!(
943            config.observability.max_event_bytes,
944            crate::config::DEFAULT_OBSERVABILITY_MAX_EVENT_BYTES
945        );
946        assert_eq!(
947            config.observability.max_stream_events,
948            crate::config::DEFAULT_OBSERVABILITY_MAX_STREAM_EVENTS
949        );
950        // The bounds also ride into the runtime view the server state reads.
951        let (_store, runtime) = config.into_parts();
952        assert_eq!(
953            runtime.observability.max_event_bytes,
954            crate::config::DEFAULT_OBSERVABILITY_MAX_EVENT_BYTES
955        );
956        Ok(())
957    }
958
959    /// Explicit `[observability]` values parse and round-trip into the runtime
960    /// view.
961    #[test]
962    fn observability_section_parses_and_round_trips() -> Result<(), Box<dyn std::error::Error>> {
963        let config = ServerConfig::from_slice(
964            br"
965                [runtime]
966                query_timeout_ms = 10000
967
968                [websocket]
969                event_broadcast_capacity = 64
970                cluster_broadcast_capacity = 64
971
972                [observability]
973                max_event_bytes = 512
974                max_stream_events = 3
975            ",
976        )?;
977        assert_eq!(config.observability.max_event_bytes, 512);
978        assert_eq!(config.observability.max_stream_events, 3);
979        let (_store, runtime) = config.into_parts();
980        assert_eq!(runtime.observability.max_event_bytes, 512);
981        assert_eq!(runtime.observability.max_stream_events, 3);
982        Ok(())
983    }
984
985    /// An omitted `[mcp]` section leaves the surface DARK and every lifetime at
986    /// its documented default. A config that never mentions MCP must not end up
987    /// serving one.
988    #[test]
989    fn missing_mcp_section_leaves_the_surface_dark() -> Result<(), Box<dyn std::error::Error>> {
990        let config = ServerConfig::from_slice(
991            br"
992                [runtime]
993                query_timeout_ms = 10000
994
995                [websocket]
996                event_broadcast_capacity = 64
997                cluster_broadcast_capacity = 64
998            ",
999        )?;
1000        let (_store, runtime) = config.into_parts();
1001        assert!(!runtime.mcp.enabled);
1002        assert!(runtime.mcp.allowed_origins.is_empty());
1003        assert_eq!(
1004            runtime.mcp.discover_ttl_ms,
1005            crate::config::DEFAULT_MCP_DISCOVER_TTL_MS
1006        );
1007        assert_eq!(
1008            runtime.mcp.task_poll_interval_ms,
1009            crate::config::DEFAULT_MCP_TASK_POLL_INTERVAL_MS
1010        );
1011        assert_eq!(
1012            runtime.mcp.task_ttl_ms,
1013            Some(crate::config::DEFAULT_MCP_TASK_TTL_MS)
1014        );
1015        Ok(())
1016    }
1017
1018    /// Explicit `[mcp]` values parse and round-trip, and `task_ttl_ms = 0` is
1019    /// the operator's way of saying "unlimited" — which the wire spells `null`.
1020    #[test]
1021    fn mcp_section_parses_and_round_trips() -> Result<(), Box<dyn std::error::Error>> {
1022        let config = ServerConfig::from_slice(
1023            br#"
1024                [runtime]
1025                query_timeout_ms = 10000
1026
1027                [websocket]
1028                event_broadcast_capacity = 64
1029                cluster_broadcast_capacity = 64
1030
1031                [mcp]
1032                enabled = true
1033                allowed_origins = ["http://localhost:5173"]
1034                discover_ttl_ms = 1000
1035                tools_list_ttl_ms = 2000
1036                task_ttl_ms = 0
1037                task_poll_interval_ms = 250
1038                await_poll_interval_ms = 125
1039            "#,
1040        )?;
1041        let (_store, runtime) = config.into_parts();
1042        assert!(runtime.mcp.enabled);
1043        assert_eq!(runtime.mcp.allowed_origins, vec!["http://localhost:5173"]);
1044        assert_eq!(runtime.mcp.discover_ttl_ms, 1_000);
1045        assert_eq!(runtime.mcp.tools_list_ttl_ms, 2_000);
1046        assert_eq!(runtime.mcp.task_ttl_ms, None, "zero means unlimited");
1047        assert_eq!(runtime.mcp.task_poll_interval_ms, 250);
1048        assert_eq!(runtime.mcp.await_poll_interval_ms, 125);
1049        Ok(())
1050    }
1051
1052    /// A zero poll interval is a busy loop, not a fast one. It is refused at
1053    /// startup with an operator-facing message rather than accepted.
1054    #[test]
1055    fn zero_mcp_poll_intervals_fail_startup_validation() {
1056        for key in ["task_poll_interval_ms", "await_poll_interval_ms"] {
1057            let source = format!(
1058                "
1059                [runtime]
1060                query_timeout_ms = 10000
1061
1062                [websocket]
1063                event_broadcast_capacity = 64
1064                cluster_broadcast_capacity = 64
1065
1066                [mcp]
1067                enabled = true
1068                {key} = 0
1069            "
1070            );
1071            let result = ServerConfig::from_slice(source.as_bytes());
1072            let message = match result {
1073                Err(crate::ServerError::Config { message }) => message,
1074                _ => String::new(),
1075            };
1076            assert!(message.contains(key), "{key} must be refused: {message}");
1077        }
1078    }
1079
1080    /// The same zero on a DARK surface is not a boot failure: nothing reads it,
1081    /// so refusing the boot would be refusing over a value that has no effect.
1082    #[test]
1083    fn a_dark_mcp_surface_does_not_validate_its_unused_knobs()
1084    -> Result<(), Box<dyn std::error::Error>> {
1085        let config = ServerConfig::from_slice(
1086            br"
1087                [runtime]
1088                query_timeout_ms = 10000
1089
1090                [websocket]
1091                event_broadcast_capacity = 64
1092                cluster_broadcast_capacity = 64
1093
1094                [mcp]
1095                task_poll_interval_ms = 0
1096            ",
1097        )?;
1098        let (_store, runtime) = config.into_parts();
1099        assert!(!runtime.mcp.enabled);
1100        assert_eq!(
1101            runtime.mcp.task_poll_interval_ms,
1102            crate::config::DEFAULT_MCP_TASK_POLL_INTERVAL_MS,
1103            "an unused zero resolves to the default rather than to a busy loop"
1104        );
1105        Ok(())
1106    }
1107
1108    /// The flush policy parses and round-trips into the runtime view. Both keys
1109    /// are `Option` with NO default, so what a config states is exactly what the
1110    /// boot path sees — including a hold of zero, which is a real setting
1111    /// ("never wait"), not an absence.
1112    #[test]
1113    fn observability_flush_policy_parses_and_round_trips() -> Result<(), Box<dyn std::error::Error>>
1114    {
1115        let config = ServerConfig::from_slice(
1116            br"
1117                [runtime]
1118                query_timeout_ms = 10000
1119
1120                [websocket]
1121                event_broadcast_capacity = 64
1122                cluster_broadcast_capacity = 64
1123
1124                [observability]
1125                max_batch_events = 32
1126                max_batch_hold_ms = 0
1127            ",
1128        )?;
1129        assert_eq!(config.observability.max_batch_events, Some(32));
1130        assert_eq!(config.observability.max_batch_hold_ms, Some(0));
1131        let (_store, runtime) = config.into_parts();
1132        assert_eq!(runtime.observability.max_batch_events, Some(32));
1133        assert_eq!(
1134            runtime.observability.max_batch_hold_ms,
1135            Some(0),
1136            "a stated zero hold survives as a stated zero, never collapsing to absent"
1137        );
1138        Ok(())
1139    }
1140
1141    /// The node cache budget parses in BOTH of haematite's spellings and
1142    /// round-trips into the store view unchanged. There is no default, so what
1143    /// a config states is exactly what the boot path sees.
1144    #[test]
1145    fn node_cache_budget_parses_both_spellings() -> Result<(), Box<dyn std::error::Error>> {
1146        let bounded = ServerConfig::from_slice(
1147            br"
1148                [runtime]
1149                query_timeout_ms = 10000
1150
1151                [store]
1152                node_cache_budget = { bytes = 1073741824 }
1153            ",
1154        )?;
1155        assert_eq!(
1156            bounded.store.node_cache_budget,
1157            Some(haematite::NodeCacheBudget::bytes(1 << 30)?),
1158            "a 1 GiB ceiling arrives as a 1 GiB ceiling"
1159        );
1160
1161        let unlimited = ServerConfig::from_slice(
1162            br#"
1163                [runtime]
1164                query_timeout_ms = 10000
1165
1166                [store]
1167                node_cache_budget = "unlimited"
1168            "#,
1169        )?;
1170        assert_eq!(
1171            unlimited.store.node_cache_budget,
1172            Some(haematite::NodeCacheBudget::Unlimited),
1173            "`unlimited` is a stated choice, never collapsed to absent"
1174        );
1175        Ok(())
1176    }
1177
1178    /// An omitted budget parses to `None` rather than to a guess. The refusal
1179    /// happens where the value is USED (the haematite boot path), which
1180    /// `state.rs`'s tests pin; here the point is that nothing invents one on the
1181    /// way through — and that `unlimited` is distinguishable from absent.
1182    #[test]
1183    fn an_omitted_node_cache_budget_stays_absent() -> Result<(), Box<dyn std::error::Error>> {
1184        let config = ServerConfig::from_slice(
1185            br"
1186                [runtime]
1187                query_timeout_ms = 10000
1188
1189                [store]
1190                shard_count = 8
1191            ",
1192        )?;
1193        assert_eq!(config.store.node_cache_budget, None);
1194        Ok(())
1195    }
1196
1197    /// An omitted flush policy parses to `None` rather than to a guess. The
1198    /// refusal happens where the value is used (the publisher build), which
1199    /// `state.rs`'s tests pin; here the point is that nothing invents a value on
1200    /// the way through.
1201    #[test]
1202    fn an_omitted_flush_policy_stays_absent() -> Result<(), Box<dyn std::error::Error>> {
1203        let config = ServerConfig::from_slice(
1204            br"
1205                [runtime]
1206                query_timeout_ms = 10000
1207
1208                [observability]
1209                max_event_bytes = 512
1210            ",
1211        )?;
1212        assert_eq!(config.observability.max_batch_events, None);
1213        assert_eq!(config.observability.max_batch_hold_ms, None);
1214        Ok(())
1215    }
1216
1217    #[test]
1218    fn zero_observability_max_event_bytes_fails_startup_validation() {
1219        let result = ServerConfig::from_slice(
1220            br"
1221                [runtime]
1222                query_timeout_ms = 10000
1223
1224                [websocket]
1225                event_broadcast_capacity = 64
1226                cluster_broadcast_capacity = 64
1227
1228                [observability]
1229                max_event_bytes = 0
1230            ",
1231        );
1232        let message = result
1233            .err()
1234            .map_or_else(String::new, |error| error.to_string());
1235        assert!(
1236            message.contains("observability.max_event_bytes"),
1237            "validation message must name the zero-valued key: {message}"
1238        );
1239    }
1240
1241    #[test]
1242    fn zero_observability_max_stream_events_fails_startup_validation() {
1243        let result = ServerConfig::from_slice(
1244            br"
1245                [runtime]
1246                query_timeout_ms = 10000
1247
1248                [websocket]
1249                event_broadcast_capacity = 64
1250                cluster_broadcast_capacity = 64
1251
1252                [observability]
1253                max_stream_events = 0
1254            ",
1255        );
1256        let message = result
1257            .err()
1258            .map_or_else(String::new, |error| error.to_string());
1259        assert!(
1260            message.contains("observability.max_stream_events"),
1261            "validation message must name the zero-valued key: {message}"
1262        );
1263    }
1264
1265    #[test]
1266    fn missing_query_timeout_uses_default() -> Result<(), Box<dyn std::error::Error>> {
1267        // The server unconditionally mounts /workflows/query, but the reply
1268        // deadline is a tuning knob: omitting it must resolve to the default and
1269        // boot, not fail startup.
1270        let config = ServerConfig::from_slice(
1271            br"
1272                [runtime]
1273                scheduler_threads = 1
1274
1275                [websocket]
1276                event_broadcast_capacity = 64
1277                cluster_broadcast_capacity = 64
1278            ",
1279        )?;
1280        assert_eq!(
1281            config.runtime.query_timeout_ms,
1282            Some(DEFAULT_QUERY_TIMEOUT_MS),
1283            "omitted query_timeout_ms must resolve to the default"
1284        );
1285        Ok(())
1286    }
1287
1288    #[test]
1289    fn empty_config_boots_on_operational_defaults() -> Result<(), Box<dyn std::error::Error>> {
1290        // The headline zero-config contract: an empty TOML must parse, fill every
1291        // operational tuning knob with its default, and validate — so `aion
1292        // server` runs with no hand-authored file. The durable default backend
1293        // (haematite under its default data_dir) carries the store side.
1294        let config = ServerConfig::from_slice(b"")?;
1295        assert_eq!(config.store.backend, StoreBackend::Haematite);
1296        assert_eq!(
1297            config.runtime.query_timeout_ms,
1298            Some(DEFAULT_QUERY_TIMEOUT_MS)
1299        );
1300        assert_eq!(
1301            config.websocket.event_broadcast_capacity,
1302            Some(DEFAULT_EVENT_BROADCAST_CAPACITY)
1303        );
1304        assert_eq!(
1305            config.websocket.cluster_broadcast_capacity,
1306            Some(DEFAULT_CLUSTER_BROADCAST_CAPACITY)
1307        );
1308        Ok(())
1309    }
1310
1311    #[test]
1312    fn zero_query_timeout_fails_startup_validation() {
1313        let result = ServerConfig::from_slice(
1314            br"
1315                [runtime]
1316                query_timeout_ms = 0
1317
1318                [websocket]
1319                event_broadcast_capacity = 64
1320                cluster_broadcast_capacity = 64
1321            ",
1322        );
1323
1324        let message = result
1325            .err()
1326            .map_or_else(String::new, |error| error.to_string());
1327        assert!(
1328            message.contains("runtime.query_timeout_ms"),
1329            "validation message must name the zero-valued key: {message}"
1330        );
1331    }
1332
1333    /// The deploy surface is commissioned explicitly: enabling it without
1334    /// the archive ceiling is a conservative security default, not a forced
1335    /// operator decision: enabling deploy without it must resolve the ceiling
1336    /// to [`DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES`] and boot, not fail startup.
1337    #[test]
1338    fn deploy_enabled_defaults_max_archive_bytes() -> Result<(), Box<dyn std::error::Error>> {
1339        let config = ServerConfig::from_slice(
1340            br"
1341                [runtime]
1342                query_timeout_ms = 10000
1343
1344                [websocket]
1345                event_broadcast_capacity = 64
1346                cluster_broadcast_capacity = 64
1347
1348                [deploy]
1349                enabled = true
1350            ",
1351        )?;
1352
1353        assert_eq!(
1354            config.deploy.max_archive_bytes,
1355            Some(DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES),
1356            "omitted max_archive_bytes must resolve to the conservative default"
1357        );
1358        assert_eq!(
1359            config.deploy.max_inflated_bytes,
1360            Some(DEFAULT_DEPLOY_MAX_INFLATED_BYTES),
1361            "omitted max_inflated_bytes must resolve to the conservative default"
1362        );
1363        Ok(())
1364    }
1365
1366    #[test]
1367    fn deploy_zero_max_archive_bytes_fails_startup_validation() {
1368        let result = ServerConfig::from_slice(
1369            br"
1370                [runtime]
1371                query_timeout_ms = 10000
1372
1373                [websocket]
1374                event_broadcast_capacity = 64
1375                cluster_broadcast_capacity = 64
1376
1377                [deploy]
1378                enabled = true
1379                max_archive_bytes = 0
1380            ",
1381        );
1382
1383        let message = result
1384            .err()
1385            .map_or_else(String::new, |error| error.to_string());
1386        assert!(
1387            message.contains("deploy.max_archive_bytes"),
1388            "validation message must name the zero-valued key: {message}"
1389        );
1390    }
1391
1392    /// The inflate ceiling defaults independently of an explicit archive
1393    /// ceiling: setting only `max_archive_bytes` must resolve the inflate
1394    /// ceiling to [`DEFAULT_DEPLOY_MAX_INFLATED_BYTES`] (which exceeds a 16 MiB
1395    /// archive, so the invariant holds) and boot.
1396    #[test]
1397    fn deploy_enabled_defaults_max_inflated_bytes() -> Result<(), Box<dyn std::error::Error>> {
1398        let config = ServerConfig::from_slice(
1399            br"
1400                [runtime]
1401                query_timeout_ms = 10000
1402
1403                [websocket]
1404                event_broadcast_capacity = 64
1405                cluster_broadcast_capacity = 64
1406
1407                [deploy]
1408                enabled = true
1409                max_archive_bytes = 16777216
1410            ",
1411        )?;
1412
1413        assert_eq!(
1414            config.deploy.max_archive_bytes,
1415            Some(16_777_216),
1416            "explicit max_archive_bytes must be left untouched"
1417        );
1418        assert_eq!(
1419            config.deploy.max_inflated_bytes,
1420            Some(DEFAULT_DEPLOY_MAX_INFLATED_BYTES),
1421            "omitted max_inflated_bytes must resolve to the conservative default"
1422        );
1423        Ok(())
1424    }
1425
1426    #[test]
1427    fn deploy_zero_max_inflated_bytes_fails_startup_validation() {
1428        let result = ServerConfig::from_slice(
1429            br"
1430                [runtime]
1431                query_timeout_ms = 10000
1432
1433                [websocket]
1434                event_broadcast_capacity = 64
1435                cluster_broadcast_capacity = 64
1436
1437                [deploy]
1438                enabled = true
1439                max_archive_bytes = 16777216
1440                max_inflated_bytes = 0
1441            ",
1442        );
1443
1444        let message = result
1445            .err()
1446            .map_or_else(String::new, |error| error.to_string());
1447        assert!(
1448            message.contains("deploy.max_inflated_bytes"),
1449            "validation message must name the zero-valued key: {message}"
1450        );
1451    }
1452
1453    /// An inflate ceiling below the upload ceiling is incoherent: archives
1454    /// the upload ceiling admits would be refused even stored uncompressed.
1455    #[test]
1456    fn deploy_max_inflated_below_max_archive_fails_startup_validation() {
1457        let result = ServerConfig::from_slice(
1458            br"
1459                [runtime]
1460                query_timeout_ms = 10000
1461
1462                [websocket]
1463                event_broadcast_capacity = 64
1464                cluster_broadcast_capacity = 64
1465
1466                [deploy]
1467                enabled = true
1468                max_archive_bytes = 16777216
1469                max_inflated_bytes = 16777215
1470            ",
1471        );
1472
1473        let message = result
1474            .err()
1475            .map_or_else(String::new, |error| error.to_string());
1476        assert!(
1477            message.contains("deploy.max_inflated_bytes")
1478                && message.contains("deploy.max_archive_bytes"),
1479            "validation message must name both ceilings: {message}"
1480        );
1481    }
1482
1483    /// An absent `[deploy]` section means the surface stays dark and the
1484    /// ceilings are not required.
1485    #[test]
1486    fn deploy_disabled_requires_no_archive_ceiling() -> Result<(), Box<dyn std::error::Error>> {
1487        let config = ServerConfig::from_slice(
1488            br"
1489                [runtime]
1490                query_timeout_ms = 10000
1491
1492                [websocket]
1493                event_broadcast_capacity = 64
1494                cluster_broadcast_capacity = 64
1495            ",
1496        )?;
1497
1498        assert!(!config.deploy.enabled);
1499        assert_eq!(config.deploy.max_archive_bytes, None);
1500        assert_eq!(config.deploy.max_inflated_bytes, None);
1501        Ok(())
1502    }
1503
1504    #[test]
1505    fn deploy_section_parses_enabled_with_ceilings() -> Result<(), Box<dyn std::error::Error>> {
1506        let config = ServerConfig::from_slice(
1507            br"
1508                [runtime]
1509                query_timeout_ms = 10000
1510
1511                [websocket]
1512                event_broadcast_capacity = 64
1513                cluster_broadcast_capacity = 64
1514
1515                [deploy]
1516                enabled = true
1517                max_archive_bytes = 16777216
1518                max_inflated_bytes = 67108864
1519            ",
1520        )?;
1521
1522        assert!(config.deploy.enabled);
1523        assert_eq!(config.deploy.max_archive_bytes, Some(16_777_216));
1524        assert_eq!(config.deploy.max_inflated_bytes, Some(67_108_864));
1525        Ok(())
1526    }
1527
1528    /// With no `[server] cors_allowed_origins` the list is empty: the secure
1529    /// default, where no cross-origin request is permitted and no `CorsLayer`
1530    /// is installed.
1531    #[test]
1532    fn cors_allowed_origins_default_empty() -> Result<(), Box<dyn std::error::Error>> {
1533        let config = ServerConfig::from_slice(
1534            br"
1535                [runtime]
1536                query_timeout_ms = 10000
1537
1538                [websocket]
1539                event_broadcast_capacity = 64
1540                cluster_broadcast_capacity = 64
1541            ",
1542        )?;
1543
1544        assert!(config.server.cors_allowed_origins.is_empty());
1545        let (_, runtime) = config.into_parts();
1546        assert!(runtime.cors_allowed_origins.is_empty());
1547        Ok(())
1548    }
1549
1550    /// A configured `[server] cors_allowed_origins` list parses and round-trips
1551    /// into `RuntimeConfig` (the value the `CorsLayer` is built from).
1552    #[test]
1553    fn cors_allowed_origins_parse_and_round_trip() -> Result<(), Box<dyn std::error::Error>> {
1554        let config = ServerConfig::from_slice(
1555            br#"
1556                [server]
1557                cors_allowed_origins = ["http://localhost:5173", "http://127.0.0.1:5173"]
1558
1559                [runtime]
1560                query_timeout_ms = 10000
1561
1562                [websocket]
1563                event_broadcast_capacity = 64
1564                cluster_broadcast_capacity = 64
1565            "#,
1566        )?;
1567
1568        assert_eq!(
1569            config.server.cors_allowed_origins,
1570            vec![
1571                "http://localhost:5173".to_owned(),
1572                "http://127.0.0.1:5173".to_owned()
1573            ]
1574        );
1575        let (_, runtime) = config.into_parts();
1576        assert_eq!(
1577            runtime.cors_allowed_origins,
1578            vec![
1579                "http://localhost:5173".to_owned(),
1580                "http://127.0.0.1:5173".to_owned()
1581            ]
1582        );
1583        Ok(())
1584    }
1585
1586    /// A malformed CORS origin (no scheme, or a trailing path) can never match a
1587    /// browser `Origin` header, so it fails startup validation rather than
1588    /// silently never matching.
1589    #[test]
1590    fn cors_allowed_origins_reject_malformed() {
1591        for bad in ["", "localhost:5173", "http://localhost:5173/"] {
1592            let toml = format!(
1593                "[server]\ncors_allowed_origins = [\"{bad}\"]\n\n[runtime]\nquery_timeout_ms = 10000\n\n[websocket]\nevent_broadcast_capacity = 64\n"
1594            );
1595            let result = ServerConfig::from_slice(toml.as_bytes());
1596            let message = result
1597                .err()
1598                .map_or_else(String::new, |error| error.to_string());
1599            assert!(
1600                message.contains("cors_allowed_origins"),
1601                "malformed origin `{bad}` must be rejected naming the key: {message}"
1602            );
1603        }
1604    }
1605
1606    /// An absent `[dev]` section leaves the dev surface dark.
1607    #[test]
1608    fn dev_absent_leaves_surface_dark() -> Result<(), Box<dyn std::error::Error>> {
1609        let config = ServerConfig::from_slice(
1610            br"
1611                [runtime]
1612                query_timeout_ms = 10000
1613
1614                [websocket]
1615                event_broadcast_capacity = 64
1616                cluster_broadcast_capacity = 64
1617            ",
1618        )?;
1619
1620        assert!(!config.dev.enabled);
1621        Ok(())
1622    }
1623
1624    /// `[dev] enabled = true` commissions the dev surface; it adds no other
1625    /// knobs (ADR-001: the only setting is the on/off gate).
1626    #[test]
1627    fn dev_section_parses_enabled() -> Result<(), Box<dyn std::error::Error>> {
1628        let config = ServerConfig::from_slice(
1629            br"
1630                [runtime]
1631                query_timeout_ms = 10000
1632
1633                [websocket]
1634                event_broadcast_capacity = 64
1635                cluster_broadcast_capacity = 64
1636
1637                [dev]
1638                enabled = true
1639            ",
1640        )?;
1641
1642        assert!(config.dev.enabled);
1643        Ok(())
1644    }
1645
1646    /// An absent `[authoring]` section commissions the AWL workspace while the
1647    /// separate Gleam loop remains dark and requires neither of its paths.
1648    #[test]
1649    fn authoring_absent_defaults_awl_workspace_but_keeps_gleam_dark()
1650    -> Result<(), Box<dyn std::error::Error>> {
1651        let home = crate::test_support::private_tempdir()?;
1652        let config = ServerConfig::from_slice_with_home(b"", home.path())?;
1653
1654        assert_eq!(config.authoring.gleam_path, None);
1655        assert_eq!(config.authoring.project_root, None);
1656        assert_eq!(
1657            config.authoring.workspace_dir.as_deref(),
1658            Some(home.path().join(DEFAULT_AUTHORING_WORKSPACE_DIR).as_path())
1659        );
1660        Ok(())
1661    }
1662
1663    /// An explicit `workspace_dir` wins over the out-of-box relative default.
1664    #[test]
1665    fn authoring_explicit_workspace_is_honored() -> Result<(), Box<dyn std::error::Error>> {
1666        let config = ServerConfig::from_slice(
1667            br#"
1668                [authoring]
1669                workspace_dir = "/srv/aion/studio"
1670            "#,
1671        )?;
1672
1673        assert_eq!(
1674            config.authoring.workspace_dir.as_deref(),
1675            Some(std::path::Path::new("/srv/aion/studio"))
1676        );
1677        Ok(())
1678    }
1679
1680    /// A configured `[authoring]` section with both `gleam_path` and
1681    /// `project_root` parses and round-trips into `RuntimeConfig`.
1682    #[test]
1683    fn authoring_section_parses_and_round_trips() -> Result<(), Box<dyn std::error::Error>> {
1684        let config = ServerConfig::from_slice(
1685            br#"
1686                [runtime]
1687                query_timeout_ms = 10000
1688
1689                [websocket]
1690                event_broadcast_capacity = 64
1691                cluster_broadcast_capacity = 64
1692
1693                [authoring]
1694                gleam_path = "/usr/local/bin/gleam"
1695                project_root = "/srv/aion/authoring"
1696            "#,
1697        )?;
1698
1699        assert_eq!(
1700            config.authoring.gleam_path.as_deref(),
1701            Some(std::path::Path::new("/usr/local/bin/gleam"))
1702        );
1703        let (_, runtime) = config.into_parts();
1704        assert_eq!(
1705            runtime.authoring.gleam_path.as_deref(),
1706            Some(std::path::Path::new("/usr/local/bin/gleam"))
1707        );
1708        assert_eq!(
1709            runtime.authoring.project_root.as_deref(),
1710            Some(std::path::Path::new("/srv/aion/authoring"))
1711        );
1712        Ok(())
1713    }
1714
1715    /// Commissioning the authoring loop (a `gleam_path`) without a
1716    /// `project_root` must fail startup naming the key and the environment
1717    /// override (the deploy required-config pattern).
1718    #[test]
1719    fn authoring_gleam_path_without_project_root_fails_naming_key_and_env() {
1720        let result = ServerConfig::from_slice(
1721            br#"
1722                [runtime]
1723                query_timeout_ms = 10000
1724
1725                [websocket]
1726                event_broadcast_capacity = 64
1727                cluster_broadcast_capacity = 64
1728
1729                [authoring]
1730                gleam_path = "/usr/local/bin/gleam"
1731            "#,
1732        );
1733
1734        let message = result
1735            .err()
1736            .map_or_else(String::new, |error| error.to_string());
1737        assert!(
1738            message.contains("authoring.project_root"),
1739            "validation message must name the missing key: {message}"
1740        );
1741        assert!(
1742            message.contains("AION_AUTHORING_PROJECT_ROOT"),
1743            "validation message must name the environment override: {message}"
1744        );
1745    }
1746
1747    /// An empty `gleam_path` is a misconfiguration, not "dark": it must fail
1748    /// startup naming the key and the environment override.
1749    #[test]
1750    fn authoring_empty_gleam_path_fails_naming_key_and_env() {
1751        let result = ServerConfig::from_slice(
1752            br#"
1753                [runtime]
1754                query_timeout_ms = 10000
1755
1756                [websocket]
1757                event_broadcast_capacity = 64
1758                cluster_broadcast_capacity = 64
1759
1760                [authoring]
1761                gleam_path = ""
1762            "#,
1763        );
1764
1765        let message = result
1766            .err()
1767            .map_or_else(String::new, |error| error.to_string());
1768        assert!(
1769            message.contains("authoring.gleam_path"),
1770            "validation message must name the empty key: {message}"
1771        );
1772        assert!(
1773            message.contains("AION_AUTHORING_GLEAM_PATH"),
1774            "validation message must name the environment override: {message}"
1775        );
1776    }
1777
1778    /// CLI overrides commission the authoring loop after file/env merge.
1779    #[test]
1780    fn cli_overrides_set_authoring_paths() -> Result<(), Box<dyn std::error::Error>> {
1781        let mut config = ServerConfig::from_slice(
1782            br"
1783                [runtime]
1784                query_timeout_ms = 10000
1785
1786                [websocket]
1787                event_broadcast_capacity = 64
1788                cluster_broadcast_capacity = 64
1789            ",
1790        )?;
1791        let cli = CliOverrides {
1792            gleam_path: Some(std::path::PathBuf::from("/opt/gleam")),
1793            authoring_project_root: Some(std::path::PathBuf::from("/opt/project")),
1794            ..CliOverrides::default()
1795        };
1796
1797        config.apply_cli_overrides(&cli);
1798        config.validate()?;
1799
1800        assert_eq!(
1801            config.authoring.gleam_path.as_deref(),
1802            Some(std::path::Path::new("/opt/gleam"))
1803        );
1804        assert_eq!(
1805            config.authoring.project_root.as_deref(),
1806            Some(std::path::Path::new("/opt/project"))
1807        );
1808        Ok(())
1809    }
1810
1811    /// The config field renamed `dashboard` -> `ops_console` carries a serde
1812    /// alias so existing `[dashboard]` TOML still parses (non-breaking rename).
1813    /// R1: the written queue-service settings reach `RuntimeConfig`, and an
1814    /// operator who writes nothing gets `strict` with no clocks.
1815    #[test]
1816    fn queue_service_settings_are_read_from_the_worker_section()
1817    -> Result<(), Box<dyn std::error::Error>> {
1818        use crate::worker::QueueServicePolicy;
1819        use std::time::Duration;
1820
1821        let bare = ServerConfig::from_slice(
1822            br"
1823                [websocket]
1824                event_broadcast_capacity = 64
1825                cluster_broadcast_capacity = 64
1826            ",
1827        )?;
1828        assert_eq!(
1829            bare.worker.queue_service.default_policy,
1830            QueueServicePolicy::Strict,
1831            "strict is the default with nothing written"
1832        );
1833        assert_eq!(
1834            bare.worker.queue_service.service_availability_deadline,
1835            None
1836        );
1837        assert_eq!(bare.worker.queue_service.schedule_to_start_timeout, None);
1838
1839        let written = ServerConfig::from_slice(
1840            br#"
1841                [websocket]
1842                event_broadcast_capacity = 64
1843                cluster_broadcast_capacity = 64
1844
1845                [worker.queue_service]
1846                service_availability_deadline = 45000
1847                schedule_to_start_timeout = 5000
1848
1849                [[worker.queue_service.overrides]]
1850                task_queue = "general"
1851                policy = "durable_pending"
1852            "#,
1853        )?;
1854        assert_eq!(
1855            written.worker.queue_service.service_availability_deadline,
1856            Some(Duration::from_secs(45))
1857        );
1858        assert_eq!(
1859            written.worker.queue_service.schedule_to_start_timeout,
1860            Some(Duration::from_secs(5))
1861        );
1862        assert_eq!(
1863            written
1864                .worker
1865                .queue_service
1866                .policy_for("default", "general"),
1867            QueueServicePolicy::DurablePending,
1868            "the written opt-in must reach the dispatch seam"
1869        );
1870        assert_eq!(
1871            written
1872                .worker
1873                .queue_service
1874                .policy_for("default", "billing"),
1875            QueueServicePolicy::Strict,
1876            "an override must not leak onto other queues"
1877        );
1878
1879        // The same settings survive the runtime projection the server boots from.
1880        let (_store, runtime) = written.into_parts();
1881        assert_eq!(
1882            runtime
1883                .worker
1884                .queue_service
1885                .policy_for("default", "general"),
1886            QueueServicePolicy::DurablePending
1887        );
1888        Ok(())
1889    }
1890
1891    #[test]
1892    fn legacy_dashboard_section_alias_still_parses() -> Result<(), Box<dyn std::error::Error>> {
1893        let config = ServerConfig::from_slice(
1894            br#"
1895                [runtime]
1896                query_timeout_ms = 10000
1897
1898                [websocket]
1899                event_broadcast_capacity = 64
1900                cluster_broadcast_capacity = 64
1901
1902                [dashboard]
1903                source = { FileSystem = { asset_path = "/srv/aion/ui" } }
1904            "#,
1905        )?;
1906        match &config.ops_console.source {
1907            OpsConsoleAssetSource::FileSystem { asset_path } => {
1908                assert_eq!(asset_path.as_os_str(), "/srv/aion/ui");
1909            }
1910            OpsConsoleAssetSource::Embedded => {
1911                return Err("legacy [dashboard] section must map to ops_console".into());
1912            }
1913        }
1914        Ok(())
1915    }
1916
1917    /// The new `[ops_console]` section name also parses.
1918    #[test]
1919    fn ops_console_section_parses() -> Result<(), Box<dyn std::error::Error>> {
1920        let config = ServerConfig::from_slice(
1921            br#"
1922                [runtime]
1923                query_timeout_ms = 10000
1924
1925                [websocket]
1926                event_broadcast_capacity = 64
1927                cluster_broadcast_capacity = 64
1928
1929                [ops_console]
1930                source = { FileSystem = { asset_path = "/srv/aion/ui" } }
1931            "#,
1932        )?;
1933        assert!(matches!(
1934            config.ops_console.source,
1935            OpsConsoleAssetSource::FileSystem { .. }
1936        ));
1937        Ok(())
1938    }
1939
1940    #[test]
1941    fn invalid_values_name_problematic_field() {
1942        let result = ServerConfig::from_slice(
1943            br"
1944                [runtime]
1945                scheduler_threads = 0
1946            ",
1947        );
1948
1949        let message = result
1950            .err()
1951            .map_or_else(String::new, |error| error.to_string());
1952        assert!(message.contains("runtime.scheduler_threads"));
1953    }
1954
1955    const RETIRED_STORE_REMEDY: &str = "backend = \"haematite\" with store.data_dir and store.node_cache_budget (all three are required — haematite refuses to start without the budget); existing libsql database files are not read by this build";
1956
1957    fn assert_retired_store_refusal(
1958        result: Result<ServerConfig, crate::error::ServerError>,
1959        found: &str,
1960    ) {
1961        assert!(result.is_err(), "retired libsql input must be refused");
1962        let message = result
1963            .err()
1964            .map_or_else(String::new, |error| error.to_string());
1965        assert!(
1966            message.contains(found),
1967            "refusal did not name `{found}`: {message}"
1968        );
1969        assert!(
1970            message.contains(RETIRED_STORE_REMEDY),
1971            "refusal omitted the operator remedy: {message}"
1972        );
1973    }
1974
1975    #[test]
1976    fn retired_libsql_backend_is_refused_with_remedy() {
1977        assert_retired_store_refusal(
1978            ServerConfig::from_slice(
1979                br#"
1980                    [store]
1981                    backend = "libsql"
1982                "#,
1983            ),
1984            "backend = \"libsql\"",
1985        );
1986    }
1987
1988    #[test]
1989    fn retired_store_url_key_is_refused_with_remedy() {
1990        assert_retired_store_refusal(
1991            ServerConfig::from_slice(
1992                br#"
1993                    [store]
1994                    backend = "haematite"
1995                    url = "old.db"
1996                "#,
1997            ),
1998            "store.url",
1999        );
2000    }
2001
2002    /// The FIFTH door: `AION_STORE_BACKEND=libsql` names the retired backend as
2003    /// squarely as `backend = "libsql"` in the file does, and must reach the same
2004    /// named refusal rather than the parser's "must be one of" list, which names
2005    /// no remedy and reads like a typo.
2006    #[test]
2007    fn retired_libsql_backend_environment_is_refused_with_remedy() {
2008        let mut config = ServerConfig::default();
2009        let result = super::env::overlay_vars(
2010            &mut config,
2011            [("AION_STORE_BACKEND".to_owned(), "libsql".to_owned())],
2012        )
2013        .and_then(|()| config.validate().map(|()| config));
2014        assert_retired_store_refusal(result, "AION_STORE_BACKEND=libsql");
2015    }
2016
2017    #[test]
2018    fn retired_store_url_environment_is_refused_with_remedy() {
2019        let mut config = ServerConfig::default();
2020        let result = super::env::overlay_vars(
2021            &mut config,
2022            [("AION_STORE_URL".to_owned(), "old.db".to_owned())],
2023        )
2024        .and_then(|()| config.validate().map(|()| config));
2025        assert_retired_store_refusal(result, "AION_STORE_URL");
2026    }
2027
2028    #[test]
2029    fn retired_store_url_flag_is_refused_with_remedy() {
2030        let mut config = ServerConfig::default();
2031        config.apply_cli_overrides(&CliOverrides {
2032            store_url: Some("old.db".to_owned()),
2033            ..CliOverrides::default()
2034        });
2035        let result = config.validate().map(|()| config);
2036        assert_retired_store_refusal(result, "--store-url");
2037    }
2038
2039    /// A CLI flag beats the value the config file loaded. Only the `--store-url`
2040    /// arm of the original pin died with libSQL; the precedence claim itself is
2041    /// backend-agnostic and is the reason `apply_cli_overrides` exists.
2042    #[test]
2043    fn cli_overrides_win_over_loaded_values() -> Result<(), Box<dyn std::error::Error>> {
2044        let mut config = ServerConfig::from_slice(
2045            br#"
2046                [store]
2047                backend = "haematite"
2048                data_dir = "from-the-file"
2049
2050                [runtime]
2051                scheduler_threads = 1
2052                query_timeout_ms = 10000
2053
2054                [websocket]
2055                event_broadcast_capacity = 64
2056                cluster_broadcast_capacity = 64
2057
2058                [observability]
2059                max_batch_events = 64
2060                max_batch_hold_ms = 0
2061            "#,
2062        )?;
2063        assert_eq!(
2064            config.runtime.scheduler_threads, 1,
2065            "the file's value must be what the flag then beats"
2066        );
2067        let cli = CliOverrides {
2068            scheduler_threads: Some(
2069                std::num::NonZeroUsize::new(3)
2070                    .ok_or("3 is not zero")?
2071                    .into(),
2072            ),
2073            ..CliOverrides::default()
2074        };
2075
2076        config.apply_cli_overrides(&cli);
2077        config.validate()?;
2078
2079        assert_eq!(config.runtime.scheduler_threads, 3);
2080        assert_eq!(
2081            config.store.data_dir.as_deref(),
2082            Some("from-the-file"),
2083            "a value the CLI did not override keeps the file's value"
2084        );
2085        Ok(())
2086    }
2087
2088    #[test]
2089    fn haematite_store_config_remains_accepted() -> Result<(), Box<dyn std::error::Error>> {
2090        let config = ServerConfig::from_slice(
2091            br#"
2092                [store]
2093                backend = "haematite"
2094                data_dir = "aion-data"
2095            "#,
2096        )?;
2097        assert_eq!(config.store.backend, StoreBackend::Haematite);
2098        assert_eq!(config.store.data_dir.as_deref(), Some("aion-data"));
2099        Ok(())
2100    }
2101
2102    #[test]
2103    fn default_config_defaults() -> Result<(), Box<dyn std::error::Error>> {
2104        let mut config = ServerConfig::default();
2105
2106        // Raw serde defaults defer user-level paths until the merged loader has
2107        // resolved Aion home and applied every operator override.
2108        assert_eq!(config.store.backend, StoreBackend::Haematite);
2109        assert_eq!(config.store.data_dir, None);
2110        // 64 pending #187: 4096 defeated its own lazy-materialization premise
2111        // (boot scan_prefix materializes all shards; commit then fsyncs per
2112        // shard and blows the actor timeout). See StoreConfig::default().
2113        assert_eq!(config.store.shard_count, 64);
2114        assert_eq!(config.server.grpc_address.to_string(), "127.0.0.1:50051");
2115        assert_eq!(config.server.listen_address.to_string(), "127.0.0.1:8080");
2116        assert_eq!(config.namespaces.default, "default");
2117        // Minted-on-use is OPEN by default to preserve the zero-config,
2118        // no-pre-provision model: a namespace comes into being on first
2119        // worker reference.
2120        assert_eq!(config.namespaces.auto_create, AutoCreate::Open);
2121        // The cluster-wide in-flight ceiling defaults to the generous platform
2122        // headroom value (P2-Q1); nothing enforces it yet.
2123        assert_eq!(
2124            config.namespaces.max_in_flight_activities,
2125            DEFAULT_MAX_IN_FLIGHT_ACTIVITIES
2126        );
2127        assert_eq!(config.namespaces.max_in_flight_activities, 1024);
2128        assert!(!config.auth.enabled);
2129        assert!(config.metrics.enabled);
2130        // event_broadcast_capacity and query_timeout_ms are the deliberately
2131        // defaultless values: defaults validate only once the operator
2132        // supplies them.
2133        assert_eq!(config.websocket.event_broadcast_capacity, None);
2134        assert_eq!(config.websocket.cluster_broadcast_capacity, None);
2135        assert_eq!(config.runtime.query_timeout_ms, None);
2136        config.websocket.event_broadcast_capacity = Some(64);
2137        config.websocket.cluster_broadcast_capacity = Some(64);
2138        config.runtime.query_timeout_ms = Some(10_000);
2139        let home = crate::test_support::private_tempdir()?;
2140        let working_dir = crate::test_support::private_tempdir()?;
2141        super::fill_home_defaults(
2142            &mut config,
2143            home.path(),
2144            HomeSource::Derived,
2145            working_dir.path(),
2146        )?;
2147        assert_eq!(
2148            config.store.data_dir.as_deref(),
2149            home.path().join("data").to_str()
2150        );
2151        config.validate()?;
2152        Ok(())
2153    }
2154
2155    #[test]
2156    fn outbox_is_disabled_by_default_and_needs_no_knobs() -> Result<(), Box<dyn std::error::Error>>
2157    {
2158        let mut config = ServerConfig::default();
2159        config.store.data_dir = Some("test-data".to_owned());
2160        config.websocket.event_broadcast_capacity = Some(64);
2161        config.websocket.cluster_broadcast_capacity = Some(64);
2162        config.runtime.query_timeout_ms = Some(10_000);
2163
2164        // The dispatcher is dark by default and its operational knobs are all
2165        // absent — yet validation passes, because a disabled dispatcher never
2166        // reads them (no assumed defaults behind the gate).
2167        assert!(!config.outbox.enabled);
2168        assert_eq!(config.outbox.poll_interval_ms, None);
2169        assert_eq!(config.outbox.batch_size, None);
2170        assert_eq!(config.outbox.max_attempts, None);
2171        assert_eq!(config.outbox.backoff_base_ms, None);
2172        assert_eq!(config.outbox.backoff_multiplier, None);
2173        assert_eq!(config.outbox.backoff_max_ms, None);
2174        assert_eq!(config.outbox.reconcile_interval_ms, None);
2175        assert_eq!(config.outbox.reconcile_stale_after_ms, None);
2176        config.validate()?;
2177        Ok(())
2178    }
2179
2180    fn outbox_enabled_base() -> ServerConfig {
2181        let mut config = ServerConfig::default();
2182        config.store.data_dir = Some("test-data".to_owned());
2183        config.websocket.event_broadcast_capacity = Some(64);
2184        config.websocket.cluster_broadcast_capacity = Some(64);
2185        config.runtime.query_timeout_ms = Some(10_000);
2186        config.outbox.enabled = true;
2187        config.outbox.poll_interval_ms = Some(250);
2188        config.outbox.batch_size = Some(64);
2189        config.outbox.max_attempts = Some(5);
2190        config.outbox.backoff_base_ms = Some(100);
2191        config.outbox.backoff_multiplier = Some(2);
2192        config.outbox.backoff_max_ms = Some(30_000);
2193        config.outbox.reconcile_interval_ms = Some(1_000);
2194        config.outbox.reconcile_stale_after_ms = Some(60_000);
2195        config
2196    }
2197
2198    #[test]
2199    fn outbox_enabled_with_all_knobs_validates() -> Result<(), Box<dyn std::error::Error>> {
2200        outbox_enabled_base().validate()?;
2201        Ok(())
2202    }
2203
2204    #[test]
2205    fn outbox_enabled_defaults_poll_interval() -> Result<(), Box<dyn std::error::Error>> {
2206        // Enabling the dispatcher but omitting the poll cadence must resolve it
2207        // to the default and boot, not fail startup — the cadence is pure
2208        // tuning, not a forced operator decision.
2209        let config = ServerConfig::from_slice(
2210            br"
2211                [runtime]
2212                query_timeout_ms = 10000
2213
2214                [websocket]
2215                event_broadcast_capacity = 64
2216                cluster_broadcast_capacity = 64
2217
2218                [outbox]
2219                enabled = true
2220            ",
2221        )?;
2222        assert_eq!(
2223            config.outbox.poll_interval_ms,
2224            Some(DEFAULT_OUTBOX_POLL_INTERVAL_MS),
2225            "omitted poll_interval_ms must resolve to the default"
2226        );
2227        Ok(())
2228    }
2229
2230    #[test]
2231    fn outbox_enabled_defaults_max_attempts() -> Result<(), Box<dyn std::error::Error>> {
2232        // Setting a tuning knob explicitly but omitting the retry budget must
2233        // leave the explicit knob untouched and default only the omitted one.
2234        let config = ServerConfig::from_slice(
2235            br"
2236                [runtime]
2237                query_timeout_ms = 10000
2238
2239                [websocket]
2240                event_broadcast_capacity = 64
2241                cluster_broadcast_capacity = 64
2242
2243                [outbox]
2244                enabled = true
2245                poll_interval_ms = 250
2246            ",
2247        )?;
2248        assert_eq!(
2249            config.outbox.poll_interval_ms,
2250            Some(250),
2251            "explicit poll_interval_ms must be left untouched"
2252        );
2253        assert_eq!(
2254            config.outbox.max_attempts,
2255            Some(DEFAULT_OUTBOX_MAX_ATTEMPTS),
2256            "omitted max_attempts must resolve to the default"
2257        );
2258        Ok(())
2259    }
2260
2261    #[test]
2262    fn outbox_enabled_with_only_enabled_flag_uses_all_defaults()
2263    -> Result<(), Box<dyn std::error::Error>> {
2264        // Headline conditional-default contract: an outbox section with nothing
2265        // but `enabled = true` validates with every tuning knob resolved to its
2266        // default. The reconciliation pair stays dark (both absent), as before.
2267        let config = ServerConfig::from_slice(
2268            br"
2269                [runtime]
2270                query_timeout_ms = 10000
2271
2272                [websocket]
2273                event_broadcast_capacity = 64
2274                cluster_broadcast_capacity = 64
2275
2276                [outbox]
2277                enabled = true
2278            ",
2279        )?;
2280        assert!(config.outbox.enabled);
2281        assert_eq!(
2282            config.outbox.poll_interval_ms,
2283            Some(DEFAULT_OUTBOX_POLL_INTERVAL_MS)
2284        );
2285        assert_eq!(config.outbox.batch_size, Some(DEFAULT_OUTBOX_BATCH_SIZE));
2286        assert_eq!(
2287            config.outbox.max_attempts,
2288            Some(DEFAULT_OUTBOX_MAX_ATTEMPTS)
2289        );
2290        assert_eq!(
2291            config.outbox.backoff_base_ms,
2292            Some(DEFAULT_OUTBOX_BACKOFF_BASE_MS)
2293        );
2294        assert_eq!(
2295            config.outbox.backoff_multiplier,
2296            Some(DEFAULT_OUTBOX_BACKOFF_MULTIPLIER)
2297        );
2298        assert_eq!(
2299            config.outbox.backoff_max_ms,
2300            Some(DEFAULT_OUTBOX_BACKOFF_MAX_MS)
2301        );
2302        // Reconciliation is not force-defaulted: both knobs stay absent so the
2303        // live sweep remains dark.
2304        assert_eq!(config.outbox.reconcile_interval_ms, None);
2305        assert_eq!(config.outbox.reconcile_stale_after_ms, None);
2306        Ok(())
2307    }
2308
2309    #[test]
2310    fn outbox_enabled_zero_poll_interval_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
2311        // An explicit zero is a misconfiguration the default never masks:
2312        // `get_or_insert` leaves `Some(0)` untouched and validate rejects it.
2313        let mut config = outbox_enabled_base();
2314        config.outbox.poll_interval_ms = Some(0);
2315        let error = config
2316            .validate()
2317            .err()
2318            .ok_or("enabled outbox with zero poll interval must fail")?;
2319        assert!(
2320            error.to_string().contains("outbox.poll_interval_ms"),
2321            "error must name the zero-valued key: {error}"
2322        );
2323        Ok(())
2324    }
2325
2326    #[test]
2327    fn outbox_enabled_zero_max_attempts_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
2328        let mut config = outbox_enabled_base();
2329        config.outbox.max_attempts = Some(0);
2330        let error = config
2331            .validate()
2332            .err()
2333            .ok_or("enabled outbox with zero max attempts must fail")?;
2334        assert!(
2335            error.to_string().contains("outbox.max_attempts"),
2336            "error must name the zero-valued key: {error}"
2337        );
2338        Ok(())
2339    }
2340
2341    #[test]
2342    fn outbox_backoff_max_below_base_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
2343        let mut config = outbox_enabled_base();
2344        config.outbox.backoff_base_ms = Some(1_000);
2345        config.outbox.backoff_max_ms = Some(500);
2346        let error = config
2347            .validate()
2348            .err()
2349            .ok_or("backoff_max below backoff_base must fail")?;
2350        assert!(
2351            error.to_string().contains("outbox.backoff_max_ms"),
2352            "error must name the offending key: {error}"
2353        );
2354        Ok(())
2355    }
2356
2357    #[test]
2358    fn outbox_enabled_can_leave_reconciliation_dark() -> Result<(), Box<dyn std::error::Error>> {
2359        let mut config = outbox_enabled_base();
2360        config.outbox.reconcile_interval_ms = None;
2361        config.outbox.reconcile_stale_after_ms = None;
2362        config.validate()?;
2363        Ok(())
2364    }
2365
2366    #[test]
2367    fn outbox_reconciliation_requires_interval_when_partially_enabled()
2368    -> Result<(), Box<dyn std::error::Error>> {
2369        let mut config = outbox_enabled_base();
2370        config.outbox.reconcile_interval_ms = None;
2371        let error = config
2372            .validate()
2373            .err()
2374            .ok_or("reconciliation without interval must fail")?;
2375        assert!(error.to_string().contains("outbox.reconcile_interval_ms"));
2376        Ok(())
2377    }
2378
2379    #[test]
2380    fn outbox_reconciliation_requires_stale_threshold_when_partially_enabled()
2381    -> Result<(), Box<dyn std::error::Error>> {
2382        let mut config = outbox_enabled_base();
2383        config.outbox.reconcile_stale_after_ms = None;
2384        let error = config
2385            .validate()
2386            .err()
2387            .ok_or("reconciliation without stale threshold must fail")?;
2388        assert!(
2389            error
2390                .to_string()
2391                .contains("outbox.reconcile_stale_after_ms")
2392        );
2393        Ok(())
2394    }
2395
2396    #[test]
2397    fn package_discovery_is_sorted() -> Result<(), Box<dyn std::error::Error>> {
2398        let temp_dir = crate::test_support::private_tempdir()?;
2399        std::fs::write(temp_dir.path().join("zeta.aion"), b"package")?;
2400        std::fs::write(temp_dir.path().join("alpha.aion"), b"package")?;
2401        std::fs::write(temp_dir.path().join("ignored.txt"), b"package")?;
2402        std::fs::create_dir(temp_dir.path().join("nested"))?;
2403        std::fs::write(
2404            temp_dir.path().join("nested").join("nested.aion"),
2405            b"package",
2406        )?;
2407
2408        let packages = discover_workflow_packages(temp_dir.path())?;
2409
2410        assert_eq!(
2411            packages,
2412            vec![
2413                temp_dir.path().join("alpha.aion"),
2414                temp_dir.path().join("zeta.aion"),
2415            ]
2416        );
2417        Ok(())
2418    }
2419
2420    #[test]
2421    fn workflow_package_merge_is_additive_and_deduplicated() {
2422        let mut packages = vec!["config.aion".into(), "shared.aion".into()];
2423        let discovered = vec!["auto.aion".into(), "shared.aion".into()];
2424        let cli = vec!["cli.aion".into(), "auto.aion".into()];
2425
2426        merge_workflow_packages(&mut packages, discovered, &cli);
2427
2428        assert_eq!(
2429            packages,
2430            vec![
2431                std::path::PathBuf::from("config.aion"),
2432                std::path::PathBuf::from("shared.aion"),
2433                std::path::PathBuf::from("auto.aion"),
2434                std::path::PathBuf::from("cli.aion"),
2435            ]
2436        );
2437    }
2438
2439    #[test]
2440    fn package_merge_deduplicates_canonical_files() -> Result<(), Box<dyn std::error::Error>> {
2441        let temp_dir = crate::test_support::private_tempdir()?;
2442        let package = temp_dir.path().join("hello.aion");
2443        std::fs::write(&package, b"package")?;
2444        let mut packages = vec![package.clone()];
2445        let discovered = vec![temp_dir.path().join(".").join("hello.aion")];
2446
2447        merge_workflow_packages(&mut packages, discovered, &[]);
2448
2449        assert_eq!(packages, vec![package]);
2450        Ok(())
2451    }
2452
2453    #[test]
2454    fn zero_config_cli_workflow_package_uses_in_memory_defaults()
2455    -> Result<(), Box<dyn std::error::Error>> {
2456        let temp_dir = crate::test_support::private_tempdir()?;
2457
2458        let cli = CliOverrides {
2459            workflow_packages: vec!["hello-world.aion".into()],
2460            ..CliOverrides::default()
2461        };
2462        let mut config = ServerConfig::default();
2463        // This test exercises CLI workflow-package discovery against the ephemeral
2464        // in-memory store, so it opts OUT of the new durable haematite default
2465        // explicitly (the default would otherwise carry a haematite data_dir).
2466        config.store.backend = StoreBackend::Memory;
2467        config.store.data_dir = None;
2468        // Even zero-config development runs must size event streaming and the
2469        // query reply deadline explicitly (config keys or the
2470        // AION_WEBSOCKET_EVENT_BROADCAST_CAPACITY /
2471        // AION_RUNTIME_QUERY_TIMEOUT_MS environment overrides).
2472        config.websocket.event_broadcast_capacity = Some(64);
2473        config.websocket.cluster_broadcast_capacity = Some(64);
2474        config.runtime.query_timeout_ms = Some(10_000);
2475        config.load_discovered_workflow_packages(&cli, temp_dir.path())?;
2476
2477        config.validate()?;
2478
2479        assert_eq!(config.store.backend, StoreBackend::Memory);
2480        assert_eq!(
2481            config.workflow_packages,
2482            vec![std::path::PathBuf::from("hello-world.aion")]
2483        );
2484        Ok(())
2485    }
2486
2487    #[test]
2488    fn cli_packages_are_additive() -> Result<(), Box<dyn std::error::Error>> {
2489        let mut config = ServerConfig::from_slice(
2490            br#"
2491                workflow_packages = ["config.aion"]
2492
2493                [runtime]
2494                query_timeout_ms = 10000
2495
2496                [websocket]
2497                event_broadcast_capacity = 64
2498                cluster_broadcast_capacity = 64
2499            "#,
2500        )?;
2501        let cli = CliOverrides {
2502            workflow_packages: vec!["cli-one.aion".into(), "cli-two.aion".into()],
2503            ..CliOverrides::default()
2504        };
2505
2506        merge_workflow_packages(
2507            &mut config.workflow_packages,
2508            Vec::new(),
2509            &cli.workflow_packages,
2510        );
2511
2512        assert_eq!(
2513            config.workflow_packages,
2514            vec![
2515                std::path::PathBuf::from("config.aion"),
2516                std::path::PathBuf::from("cli-one.aion"),
2517                std::path::PathBuf::from("cli-two.aion"),
2518            ]
2519        );
2520        Ok(())
2521    }
2522}