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