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