Skip to main content

aion_server/config/
sections.rs

1//! Typed `[section]` sub-configurations that make up [`ServerConfig`].
2//!
3//! Each struct/enum here maps one `[section]` (or nested value) of the server
4//! TOML surface, with its `serde` attributes, `Default` impl, and the small
5//! amount of per-section validation that belongs with the type
6//! ([`WebSocketConfig::validate`]). They are re-exported from the `config`
7//! module so every existing `crate::config::X` path resolves identically.
8//!
9//! [`ServerConfig`]: super::ServerConfig
10
11use std::{net::SocketAddr, path::PathBuf, time::Duration};
12
13use serde::Deserialize;
14
15use crate::error::ServerError;
16
17use super::{
18    config_error,
19    defaults::{
20        CLUSTER_BROADCAST_CAPACITY_REQUIRED, DEFAULT_GRPC_ADDRESS, DEFAULT_HTTP_ADDRESS,
21        DEFAULT_MAX_IN_FLIGHT_ACTIVITIES, DEFAULT_MCP_AWAIT_POLL_INTERVAL_MS,
22        DEFAULT_MCP_DISCOVER_TTL_MS, DEFAULT_MCP_TASK_POLL_INTERVAL_MS, DEFAULT_MCP_TASK_TTL_MS,
23        DEFAULT_MCP_TOOLS_LIST_TTL_MS, DEFAULT_OBSERVABILITY_MAX_EVENT_BYTES,
24        DEFAULT_OBSERVABILITY_MAX_STREAM_EVENTS, EVENT_BROADCAST_CAPACITY_REQUIRED,
25        MCP_AWAIT_POLL_INTERVAL_REQUIRED, MCP_TASK_POLL_INTERVAL_REQUIRED,
26        OBSERVABILITY_MAX_EVENT_BYTES_REQUIRED, OBSERVABILITY_MAX_STREAM_EVENTS_REQUIRED,
27    },
28};
29
30/// Public transport listener addresses from `[server]`.
31#[derive(Clone, Debug, Deserialize)]
32#[serde(default, deny_unknown_fields)]
33pub struct ServerSection {
34    /// HTTP/JSON and dashboard listener.
35    pub listen_address: SocketAddr,
36    /// gRPC API and worker-protocol listener.
37    pub grpc_address: SocketAddr,
38    /// Browser origins allowed to make cross-origin (CORS) requests to the
39    /// public HTTP API. Empty (the default) is the SECURE default: no
40    /// cross-origin request is permitted and no `CorsLayer` is installed, so a
41    /// same-origin deployment behaves byte-identically to before this field
42    /// existed. When set, each entry is an exact origin (scheme + host + port,
43    /// e.g. `http://localhost:5173`) the browser dashboard is served from; the
44    /// router then answers preflight and emits `Access-Control-Allow-Origin`
45    /// for exactly those origins. There is no wildcard/allow-all default
46    /// (ADR-001): cross-origin access is an explicit operator decision, and the
47    /// layer never pairs `Any` with credentials.
48    #[serde(default)]
49    pub cors_allowed_origins: Vec<String>,
50}
51
52/// Supported event-store backend names.
53#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
54#[serde(rename_all = "lowercase")]
55pub enum StoreBackend {
56    /// In-memory store for local development.
57    Memory,
58    /// libSQL durable store.
59    LibSql,
60    /// haematite durable store (single-node, shardable).
61    Haematite,
62}
63
64/// Event-store backend configuration from `[store]`.
65#[derive(Clone, Debug, Deserialize)]
66#[serde(default, deny_unknown_fields)]
67pub struct StoreConfig {
68    /// Selected backing store implementation.
69    pub backend: StoreBackend,
70    /// Backend URL/path. For libSQL this is the embedded database path; for memory it is ignored.
71    pub url: Option<String>,
72    /// Static distribution-shard assignment for this node (multi-shard
73    /// active-active). When empty (the default) the node owns ALL shards — the
74    /// single-node default, byte-identical to today. When set, the engine boot
75    /// path scopes recovery and enumeration to exactly these shards. Single-shard
76    /// backends (memory, libSQL) ignore the assignment; it is meaningful only for
77    /// a sharded backend. No election is performed: assignment is static config.
78    pub owned_shards: Vec<usize>,
79    /// Filesystem data directory for the haematite backend. Required when
80    /// `backend = haematite`; ignored by every other backend. The directory is
81    /// opened if it already holds a haematite database, otherwise created.
82    pub data_dir: Option<String>,
83    /// Number of haematite shards to create on a fresh database. Defaults to 64.
84    ///
85    /// This is an IMMUTABLE virtual-shard count: nodes own shard *ranges* and
86    /// routing is `BLAKE3(key) % shard_count` with no reshard path, so a
87    /// single-node deployment can later grow into a cluster WITHOUT a data
88    /// migration — but only up to `shard_count` nodes, and the value is fixed
89    /// at create.
90    ///
91    /// The default was briefly 4096 on the premise that lazy shard-actor
92    /// materialization (haematite >= 0.4.0) made a high count ~free. That
93    /// premise fails in practice (#187): aion-server's boot restores
94    /// packages/routes/namespaces via full-prefix scans, which materialize
95    /// EVERY shard, and haematite 0.4.0 then fans each commit out to every
96    /// materialized shard with an unconditional fsync — ~2 fsyncs x 4096 per
97    /// logical commit — blowing the 5s shard-actor timeout and bricking
98    /// deploy/start/timers/outbox on a fresh server. Re-raise only after
99    /// haematite makes commit O(dirty shards) and the scaffold e2es pass at
100    /// the new default. Set explicitly (config or `AION_STORE_SHARD_COUNT`)
101    /// to override. Ignored by every other backend, and ignored when opening
102    /// an existing haematite database (the on-disk shard count wins).
103    pub shard_count: usize,
104    /// Optional distributed-cluster membership for the haematite backend (SS-2).
105    ///
106    /// Absent (the default) selects the SINGLE-NODE haematite path, byte-identical
107    /// to today: no endpoint is bound, no shard is elected, the store owns
108    /// everything locally. Present selects the DISTRIBUTED path: the boot path
109    /// binds a replication endpoint, builds a quorum membership from `members` +
110    /// `peers`, and the engine boot path elects (`acquire_shard_and_serve`) this
111    /// node's `owned_shards` before recovery. Ignored by every non-haematite
112    /// backend.
113    pub cluster: Option<ClusterConfig>,
114}
115
116/// Distributed-cluster membership for the haematite backend, from `[store.cluster]`.
117///
118/// This is the minimal, well-defaulted seam that turns the single-node haematite
119/// store into a distributed one (SS-2). A "cluster of one" — `node_id` set,
120/// `members` either empty or naming only `node_id`, and no `peers` — is a valid,
121/// non-flaky configuration: election self-quorums (quorum denominator 1) and the
122/// node boots through the production builder as the fenced owner of its shards.
123#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
124#[serde(deny_unknown_fields)]
125pub struct ClusterConfig {
126    /// This node's globally-unique distribution name (e.g. `node-0@127.0.0.1`).
127    /// Used as the local endpoint name and the local membership identity.
128    pub node_id: String,
129    /// The replication endpoint listen address this node binds for peer
130    /// quorum/election traffic (e.g. `127.0.0.1:7000`).
131    pub bind_address: SocketAddr,
132    /// The FULL cluster membership by node id — the quorum DENOMINATOR. Never the
133    /// reachable subset. May be empty or omit peers for a cluster of one, in which
134    /// case it is treated as `[node_id]` (denominator 1). `node_id` is always
135    /// counted in the denominator whether or not it appears here.
136    #[serde(default)]
137    pub members: Vec<String>,
138    /// Dialable peers (name + address) this node connects to for replication. A
139    /// cluster of one leaves this empty. Peers not in `members` do not inflate the
140    /// quorum denominator.
141    #[serde(default)]
142    pub peers: Vec<ClusterPeer>,
143    /// SS-5b automatic-failover poll interval in milliseconds: how often the
144    /// cluster supervisor checks each watched peer's replication liveness.
145    /// Defaults to [`DEFAULT_FAILOVER_POLL_INTERVAL_MS`] when omitted.
146    ///
147    /// [`DEFAULT_FAILOVER_POLL_INTERVAL_MS`]: super::DEFAULT_FAILOVER_POLL_INTERVAL_MS
148    #[serde(default)]
149    pub failover_poll_interval_ms: Option<u64>,
150    /// SS-5b debounce: the number of CONSECUTIVE polls a peer must be observed
151    /// disconnected before its shards are adopted, so a transient blip does not
152    /// trigger a disruptive failover. Defaults to
153    /// [`DEFAULT_FAILOVER_CONFIRMATIONS`] when omitted; must be at least one.
154    ///
155    /// [`DEFAULT_FAILOVER_CONFIRMATIONS`]: super::DEFAULT_FAILOVER_CONFIRMATIONS
156    #[serde(default)]
157    pub failover_confirmations: Option<u32>,
158}
159
160/// One dialable cluster peer: its distribution name and replication address.
161#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
162#[serde(deny_unknown_fields)]
163pub struct ClusterPeer {
164    /// The peer's globally-unique distribution name (matches its `node_id`).
165    pub name: String,
166    /// The peer's replication endpoint address to dial.
167    pub address: SocketAddr,
168    /// The peer's gRPC client-API address, for request forwarding (R-2/R-3).
169    /// This is DISTINCT from `address` (the replication/quorum endpoint): a
170    /// forwarded client `signal`/`query`/`cancel` is dialed here, not on the
171    /// replication port. Absent (the default) means the peer is not
172    /// forwardable — its shards still resolve to a remote owner, but routing
173    /// falls back to returning the typed `NotOwner` instead of forwarding (R-3).
174    #[serde(default)]
175    pub grpc_address: Option<SocketAddr>,
176    /// The distribution shards this peer owns. Empty (the default) means the
177    /// operator did not declare the peer's shards, so the SS-5b cluster
178    /// supervisor cannot adopt them automatically when the peer dies — automatic
179    /// failover for a peer requires its `owned_shards` to be declared here so the
180    /// survivor knows exactly which shards to elect + resume. Declaring them does
181    /// not change replication or quorum; it only tells the supervisor what to
182    /// adopt on this peer's death.
183    #[serde(default)]
184    pub owned_shards: Vec<usize>,
185}
186
187/// Engine runtime settings from `[runtime]`.
188#[derive(Clone, Debug, Deserialize)]
189#[serde(default, deny_unknown_fields)]
190pub struct RuntimeSection {
191    /// Number of scheduler worker threads.
192    pub scheduler_threads: usize,
193    /// Engine reply deadline for workflow queries, in milliseconds.
194    /// REQUIRED — the server always mounts `/workflows/query`, so the query
195    /// reply deadline must be an explicit operator decision; there is no
196    /// default. The engine builder is equally explicit-no-default.
197    pub query_timeout_ms: Option<u64>,
198}
199
200/// Graceful drain settings from `[drain]`.
201#[derive(Clone, Debug, Deserialize)]
202#[serde(default, deny_unknown_fields)]
203pub struct DrainConfig {
204    /// Maximum drain duration in seconds.
205    pub timeout_seconds: u64,
206}
207
208/// Authentication configuration applied at adapter boundaries.
209#[derive(Clone, Debug, Deserialize)]
210#[serde(default, deny_unknown_fields)]
211pub struct AuthConfig {
212    /// Whether authentication is enabled.
213    pub enabled: bool,
214    /// JWKS URL used by AO-006 auth validation.
215    pub jwks_url: Option<String>,
216    /// JWKS refresh interval in seconds.
217    pub jwks_refresh_seconds: u64,
218}
219
220/// Metrics endpoint settings from `[metrics]`.
221#[derive(Clone, Debug, Deserialize)]
222#[serde(default, deny_unknown_fields)]
223pub struct MetricsConfig {
224    /// Whether metrics are exposed.
225    pub enabled: bool,
226}
227
228/// Namespace defaults from `[namespaces]`.
229#[derive(Clone, Debug, Deserialize)]
230#[serde(default, deny_unknown_fields)]
231pub struct NamespacesConfig {
232    /// Default namespace used for local callers and worker dispatch.
233    pub default: String,
234    /// Minted-on-use policy: whether referencing an unseen namespace at worker
235    /// registration durably mints it ([`AutoCreate::Open`], the zero-config
236    /// default) or is rejected ([`AutoCreate::Closed`]).
237    pub auto_create: AutoCreate,
238    /// Platform-wide default for a namespace's **cluster-wide** concurrent
239    /// in-flight-activity ceiling, applied when a namespace record carries no
240    /// explicit `max_in_flight_activities` override (Control-Plane Phase 2,
241    /// P2-Q1). This is the GENEROUS platform default, NOT a low hard cap: it is
242    /// a cluster-wide tenant contract (never "per-node × N"), so a tenant is
243    /// promised ≈this many concurrent activities across the whole cluster.
244    /// Defaults to [`DEFAULT_MAX_IN_FLIGHT_ACTIVITIES`].
245    ///
246    /// **Stored-only in this slice.** Nothing enforces it yet — the outbox
247    /// dispatcher's keyed backpressure (P2-Q2) consults it in a later slice.
248    ///
249    /// [`DEFAULT_MAX_IN_FLIGHT_ACTIVITIES`]: super::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES
250    pub max_in_flight_activities: u32,
251}
252
253/// Minted-on-use namespace policy (Control-Plane Phase 1).
254///
255/// Governs what happens when a worker registers for a namespace that has no
256/// durable registry record. Defaults to [`AutoCreate::Open`] to preserve the
257/// zero-ceremony, no-pre-provision model: a namespace comes into being on first
258/// reference.
259#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
260#[serde(rename_all = "snake_case")]
261pub enum AutoCreate {
262    /// A worker registering for an unseen namespace durably mints it (an
263    /// idempotent upsert through the registry). Zero-config default.
264    #[default]
265    Open,
266    /// A worker registering for a namespace with no durable registry record is
267    /// rejected; the namespace is never created at the registration hook. The
268    /// escape hatch for locked-down deployments is an explicit create
269    /// (`POST /namespaces`).
270    Closed,
271}
272
273/// Public transport listener addresses retained for existing adapter code.
274#[derive(Clone, Debug, Deserialize)]
275#[serde(default, deny_unknown_fields)]
276pub struct ListenConfig {
277    /// gRPC API and worker-protocol listener.
278    pub grpc: SocketAddr,
279    /// HTTP/JSON and dashboard listener.
280    pub http: SocketAddr,
281}
282
283/// TLS certificate and private-key material.
284#[derive(Clone, Debug, Deserialize)]
285#[serde(deny_unknown_fields)]
286pub struct TlsConfig {
287    /// Certificate chain path supplied by the operator.
288    pub certificate_chain_path: PathBuf,
289    /// Private-key path supplied by the operator.
290    pub private_key_path: PathBuf,
291}
292
293/// Static ops-console asset configuration.
294#[derive(Clone, Debug, Deserialize)]
295#[serde(default, deny_unknown_fields)]
296pub struct OpsConsoleConfig {
297    /// Operator-selected bundle source.
298    pub source: OpsConsoleAssetSource,
299}
300
301/// Static ops-console bundle source.
302#[derive(Clone, Debug, Deserialize)]
303pub enum OpsConsoleAssetSource {
304    /// Serve the built bundle from an operator-supplied directory.
305    FileSystem {
306        /// Directory containing `index.html` and built asset files.
307        asset_path: PathBuf,
308    },
309    /// Serve the compile-time embedded bundle.
310    Embedded,
311}
312
313/// Namespace resolver construction mode.
314#[derive(Clone, Debug, Deserialize)]
315#[serde(default, deny_unknown_fields)]
316pub struct NamespaceConfig {
317    /// Deployment-selected namespace mapping mode.
318    pub mode: NamespaceMode,
319}
320
321/// Supported namespace mapping modes.
322#[derive(Clone, Debug, Deserialize)]
323pub enum NamespaceMode {
324    /// All authorized namespaces share the configured engine instance.
325    SharedEngine,
326    /// Namespace authorization is disabled only for single-tenant deployments.
327    SingleTenant {
328        /// The only namespace accepted by the deployment.
329        namespace: String,
330    },
331}
332
333/// Remote worker heartbeat configuration.
334#[derive(Clone, Debug, Deserialize)]
335#[serde(default, deny_unknown_fields)]
336pub struct WorkerConfig {
337    /// Window after which a silent worker is considered lost.
338    #[serde(with = "duration_millis")]
339    pub heartbeat_window: Duration,
340    /// R1 queue-service policies and clocks (`[worker.queue_service]`).
341    ///
342    /// Absent, every queue is served `strict` with no clocks: structural
343    /// unservability refuses, everything else waits — loudly, typed, and
344    /// queryable. Writing a deadline is how an operator declares how long a
345    /// dispatch may wait; writing a `durable_pending` override is how a queue
346    /// declares that its runs park rather than refuse.
347    #[serde(default)]
348    pub queue_service: crate::worker::queue_service::QueueServiceConfig,
349}
350
351/// WebSocket stream configuration.
352#[derive(Clone, Debug, Deserialize)]
353#[serde(default, deny_unknown_fields)]
354pub struct WebSocketConfig {
355    /// Per-connection outbound buffer bound.
356    pub outbound_buffer_bound: usize,
357    /// Capacity of the engine-global event broadcast channel that backs
358    /// `/events/stream`. REQUIRED — the server always mounts the streaming
359    /// endpoint, so streaming capacity must be an explicit operator decision;
360    /// there is no default. Lag is filter-blind, so size this for global event
361    /// volume across all namespaces, not per-subscription volume.
362    pub event_broadcast_capacity: Option<usize>,
363    /// Capacity of the deployment-global cluster topology/ownership broadcast
364    /// channel that backs the WS3 `cluster` subscription on `/events/stream`.
365    /// REQUIRED with the same non-zero startup guard as
366    /// [`Self::event_broadcast_capacity`]: the cluster channel uses the same
367    /// lag -> one-error-frame -> close contract, which has no defined buffer to
368    /// lag against unless a capacity is configured. Cluster events are low-rate
369    /// (peer/shard/worker topology deltas), so this is typically far smaller
370    /// than the workflow event capacity.
371    pub cluster_broadcast_capacity: Option<usize>,
372}
373
374impl WebSocketConfig {
375    /// Validate the three unconditionally-mounted WebSocket seams: the
376    /// per-connection buffer bound, the workflow event broadcast capacity, and
377    /// the WS3 cluster broadcast capacity. The two broadcast capacities are
378    /// explicit-no-default with a non-zero guard, since both back a lag ->
379    /// one-error-frame -> close contract that needs a defined buffer to lag
380    /// against.
381    pub(super) fn validate(&self) -> Result<(), ServerError> {
382        if self.outbound_buffer_bound == 0 {
383            return config_error("websocket.outbound_buffer_bound must be greater than zero");
384        }
385        match self.event_broadcast_capacity {
386            None | Some(0) => return config_error(EVENT_BROADCAST_CAPACITY_REQUIRED),
387            Some(_) => {}
388        }
389        match self.cluster_broadcast_capacity {
390            None | Some(0) => return config_error(CLUSTER_BROADCAST_CAPACITY_REQUIRED),
391            Some(_) => {}
392        }
393        Ok(())
394    }
395}
396
397/// Model Context Protocol surface settings from `[mcp]`.
398///
399/// The surface is DARK by default: with `enabled = false` (or the section
400/// absent) the `/mcp` route is not mounted and the path is a plain 404. An MCP
401/// endpoint hands a model the ability to start and cancel durable executions,
402/// so commissioning it is an explicit operator decision, never a side effect of
403/// upgrading.
404///
405/// `allowed_origins` is EMPTY by default and empty means "no browser origin".
406/// A programmatic client sends no `Origin` header and is unaffected; a page in
407/// a browser always sends one, so the empty list refuses every browser-borne
408/// request. That is the DNS-rebinding defence the transport requires, and it is
409/// a real policy rather than an unset value.
410#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
411#[serde(default, deny_unknown_fields)]
412pub struct McpConfig {
413    /// Whether the `/mcp` endpoint is mounted. Defaults to false.
414    pub enabled: bool,
415    /// Browser origins permitted to reach the MCP endpoint. Empty permits none.
416    pub allowed_origins: Vec<String>,
417    /// `ttlMs` on the `server/discover` result.
418    ///
419    /// Defaults to [`DEFAULT_MCP_DISCOVER_TTL_MS`] when omitted and
420    /// `enabled = true`, so switching the surface on does not force sizing two
421    /// cache lifetimes first.
422    ///
423    /// [`DEFAULT_MCP_DISCOVER_TTL_MS`]: super::DEFAULT_MCP_DISCOVER_TTL_MS
424    pub discover_ttl_ms: Option<u64>,
425    /// `ttlMs` on the `tools/list` result. Defaults to
426    /// [`DEFAULT_MCP_TOOLS_LIST_TTL_MS`].
427    ///
428    /// [`DEFAULT_MCP_TOOLS_LIST_TTL_MS`]: super::DEFAULT_MCP_TOOLS_LIST_TTL_MS
429    pub tools_list_ttl_ms: Option<u64>,
430    /// How long a created task lives before the server may fail and delete it.
431    ///
432    /// Zero means UNLIMITED and is written that way on the wire (`ttlMs: null`).
433    /// An unlimited task is a real operator choice for a workflow that
434    /// legitimately runs for months; it is not the default, because a task
435    /// nobody polls holds an executor for as long as it lives. Defaults to
436    /// [`DEFAULT_MCP_TASK_TTL_MS`].
437    ///
438    /// [`DEFAULT_MCP_TASK_TTL_MS`]: super::DEFAULT_MCP_TASK_TTL_MS
439    pub task_ttl_ms: Option<u64>,
440    /// The `pollIntervalMs` advertised on a created task. Defaults to
441    /// [`DEFAULT_MCP_TASK_POLL_INTERVAL_MS`].
442    ///
443    /// [`DEFAULT_MCP_TASK_POLL_INTERVAL_MS`]: super::DEFAULT_MCP_TASK_POLL_INTERVAL_MS
444    pub task_poll_interval_ms: Option<u64>,
445    /// How often an awaited `start_run` re-reads the run's projected status.
446    /// Defaults to [`DEFAULT_MCP_AWAIT_POLL_INTERVAL_MS`].
447    ///
448    /// [`DEFAULT_MCP_AWAIT_POLL_INTERVAL_MS`]: super::DEFAULT_MCP_AWAIT_POLL_INTERVAL_MS
449    pub await_poll_interval_ms: Option<u64>,
450}
451
452impl McpConfig {
453    /// Refuse a zero poll interval at startup.
454    ///
455    /// Only meaningful when the surface is enabled: a dark surface's knobs are
456    /// never read, so refusing a boot over one would be refusing over a value
457    /// nothing uses.
458    ///
459    /// # Errors
460    ///
461    /// [`ServerError::Config`] when an explicitly-set interval is zero. A zero
462    /// poll interval is a busy loop, not a fast one, and the operator is told
463    /// so rather than being handed one.
464    pub(super) fn validate(&self) -> Result<(), ServerError> {
465        if !self.enabled {
466            return Ok(());
467        }
468        if self.task_poll_interval_ms == Some(0) {
469            return config_error(MCP_TASK_POLL_INTERVAL_REQUIRED);
470        }
471        if self.await_poll_interval_ms == Some(0) {
472            return config_error(MCP_AWAIT_POLL_INTERVAL_REQUIRED);
473        }
474        Ok(())
475    }
476
477    /// Resolve every omitted knob to its default.
478    ///
479    /// A zero poll interval resolves to the DEFAULT rather than to zero.
480    /// [`Self::validate`] has already refused it on any loaded configuration,
481    /// so this arm is reachable only from a hand-constructed config that
482    /// skipped validation — and for that case a documented default is the safe
483    /// direction, where a literal zero would be a busy loop.
484    #[must_use]
485    pub fn resolved(&self) -> ResolvedMcpConfig {
486        ResolvedMcpConfig {
487            enabled: self.enabled,
488            allowed_origins: self.allowed_origins.clone(),
489            discover_ttl_ms: self.discover_ttl_ms.unwrap_or(DEFAULT_MCP_DISCOVER_TTL_MS),
490            tools_list_ttl_ms: self
491                .tools_list_ttl_ms
492                .unwrap_or(DEFAULT_MCP_TOOLS_LIST_TTL_MS),
493            task_ttl_ms: match self.task_ttl_ms.unwrap_or(DEFAULT_MCP_TASK_TTL_MS) {
494                0 => None,
495                value => Some(value),
496            },
497            task_poll_interval_ms: self
498                .task_poll_interval_ms
499                .filter(|value| *value > 0)
500                .unwrap_or(DEFAULT_MCP_TASK_POLL_INTERVAL_MS),
501            await_poll_interval_ms: self
502                .await_poll_interval_ms
503                .filter(|value| *value > 0)
504                .unwrap_or(DEFAULT_MCP_AWAIT_POLL_INTERVAL_MS),
505        }
506    }
507}
508
509impl Default for ResolvedMcpConfig {
510    /// The dark surface: not mounted, no origin permitted, every lifetime at
511    /// its documented default. This is what an embedder that never configures
512    /// MCP gets, and it serves nothing.
513    fn default() -> Self {
514        McpConfig::default().resolved()
515    }
516}
517
518/// The `[mcp]` section with every omitted knob resolved.
519///
520/// Held in [`RuntimeConfig`](super::RuntimeConfig) so the mount reads settled
521/// values rather than re-deriving defaults at each composition point.
522#[derive(Clone, Debug, PartialEq, Eq)]
523pub struct ResolvedMcpConfig {
524    /// Whether the `/mcp` endpoint is mounted.
525    pub enabled: bool,
526    /// Browser origins permitted to reach the endpoint.
527    pub allowed_origins: Vec<String>,
528    /// `ttlMs` on `server/discover`.
529    pub discover_ttl_ms: u64,
530    /// `ttlMs` on `tools/list`.
531    pub tools_list_ttl_ms: u64,
532    /// Task lifetime; `None` is unlimited.
533    pub task_ttl_ms: Option<u64>,
534    /// Advertised client poll interval for a task.
535    pub task_poll_interval_ms: u64,
536    /// Server-side poll interval for an awaited start.
537    pub await_poll_interval_ms: u64,
538}
539
540/// Agent-observability transcript retention bounds from `[observability]`.
541///
542/// Both knobs default (a minimal/empty config boots) and both guard the durable
543/// `O` keyspace against unbounded growth: `max_event_bytes` truncates one
544/// oversized transcript event before it is persisted, and `max_stream_events`
545/// caps how many events one `(workflow, activity, attempt)` stream retains
546/// (one marker record is persisted at the cap; live streaming continues).
547#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
548#[serde(default, deny_unknown_fields)]
549pub struct ObservabilityConfig {
550    /// Ceiling on one persisted transcript event's serialized size, bytes.
551    pub max_event_bytes: usize,
552    /// Ceiling on retained events per `(workflow, activity, attempt)` stream.
553    pub max_stream_events: u64,
554}
555
556impl ObservabilityConfig {
557    /// Validate the retention bounds: both must be positive — a zero event
558    /// ceiling truncates every event to nothing and a zero stream cap retains
559    /// no transcript at all, so each is a genuine misconfiguration caught at
560    /// startup with an operator-facing message (the `WebSocketConfig` pattern).
561    pub(super) fn validate(&self) -> Result<(), ServerError> {
562        if self.max_event_bytes == 0 {
563            return config_error(OBSERVABILITY_MAX_EVENT_BYTES_REQUIRED);
564        }
565        if self.max_stream_events == 0 {
566            return config_error(OBSERVABILITY_MAX_STREAM_EVENTS_REQUIRED);
567        }
568        Ok(())
569    }
570}
571
572impl Default for ObservabilityConfig {
573    fn default() -> Self {
574        Self {
575            max_event_bytes: DEFAULT_OBSERVABILITY_MAX_EVENT_BYTES,
576            max_stream_events: DEFAULT_OBSERVABILITY_MAX_STREAM_EVENTS,
577        }
578    }
579}
580
581/// Operator deploy API settings from `[deploy]`.
582///
583/// The deploy surface is dark by default: with `enabled = false` (or the
584/// section absent) neither the `/deploy/*` HTTP routes nor the gRPC
585/// `DeployService` are mounted, so a workflow server that is not a deploy
586/// target exposes no deploy attack surface at all.
587#[derive(Clone, Debug, Default, Deserialize)]
588#[serde(default, deny_unknown_fields)]
589pub struct DeployConfig {
590    /// Whether the deploy surface is mounted. Defaults to false.
591    pub enabled: bool,
592    /// Upload-size ceiling for `.aion` archives, in bytes. Defaults to
593    /// [`DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES`] when omitted and `enabled = true`,
594    /// so turning deploy on does not force sizing a security ceiling; the
595    /// operator overrides it for their packages.
596    ///
597    /// [`DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES`]: super::DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES
598    pub max_archive_bytes: Option<u64>,
599    /// Inflate ceiling for uploaded archive contents, in bytes: the total
600    /// decompressed size of all archive entries an upload may extract to
601    /// (DEFLATE bombs inflate ~1000:1 past `max_archive_bytes`). Defaults to
602    /// [`DEFAULT_DEPLOY_MAX_INFLATED_BYTES`] when omitted and `enabled = true`;
603    /// must be at least `max_archive_bytes`.
604    ///
605    /// [`DEFAULT_DEPLOY_MAX_INFLATED_BYTES`]: super::DEFAULT_DEPLOY_MAX_INFLATED_BYTES
606    pub max_inflated_bytes: Option<u64>,
607}
608
609/// Local dev-server surface settings from `[dev]`.
610///
611/// The dev surface is dark by default, gated on `enabled`: with it false (the
612/// section absent or `enabled = false`) the `/dev/*` routes are not mounted,
613/// the engine installs the bare production activity dispatcher (no mocking
614/// decorator), and nothing dev-specific is ever reachable. Setting `enabled =
615/// true` mounts the dev endpoints and installs the per-run activity-mock
616/// decorator — a development affordance, never on in production. It adds no
617/// arbitrary defaults (ADR-001): the only knob is the on/off gate.
618#[derive(Clone, Debug, Default, Deserialize)]
619#[serde(default, deny_unknown_fields)]
620pub struct DevConfig {
621    /// Whether the local dev-server surface is mounted. Defaults to false.
622    pub enabled: bool,
623}
624
625/// Durable-outbox fan-out dispatcher settings from `[outbox]`.
626///
627/// The outbox dispatcher is dark by default, gated on `enabled`: with it false
628/// (the section absent or `enabled = false`) the non-replayed background task
629/// that claims pending outbox rows and dispatches them to connected workers is
630/// never spawned, so default server behaviour is unchanged and the live
631/// workflow dispatch path is the only dispatch path. Setting `enabled = true`
632/// commissions the dispatcher; its operational knobs below — poll interval,
633/// claim batch size, retry budget, and the backoff curve — are pure tuning, so
634/// each resolves to a sane default when omitted rather than forcing the
635/// operator to hand-author tuning values just to turn the feature on. An
636/// explicitly set value (including a misconfigured `0`) is still validated.
637///
638/// Scope: this Phase-2 dispatcher dispatches claimed rows and marks each row's
639/// terminal outbox state (done / retry / failed). Routing the worker completion
640/// back into workflow history through the Recorder is Phase 3 and is not wired
641/// here; with the flag off there is no behavioural difference at all.
642#[derive(Clone, Debug, Default, Deserialize)]
643#[serde(default, deny_unknown_fields)]
644pub struct OutboxConfig {
645    /// Whether the outbox dispatcher background task is spawned. Defaults to
646    /// false, leaving the dispatcher dark and server behaviour unchanged.
647    pub enabled: bool,
648    /// Interval between successive claim sweeps, in milliseconds. Defaults to
649    /// [`DEFAULT_OUTBOX_POLL_INTERVAL_MS`] when omitted and `enabled = true`;
650    /// override to size the poll cadence for fan-out volume and latency budget.
651    ///
652    /// [`DEFAULT_OUTBOX_POLL_INTERVAL_MS`]: super::DEFAULT_OUTBOX_POLL_INTERVAL_MS
653    pub poll_interval_ms: Option<u64>,
654    /// Maximum number of pending rows claimed per sweep. Defaults to
655    /// [`DEFAULT_OUTBOX_BATCH_SIZE`] when omitted and `enabled = true`.
656    ///
657    /// [`DEFAULT_OUTBOX_BATCH_SIZE`]: super::DEFAULT_OUTBOX_BATCH_SIZE
658    pub batch_size: Option<u32>,
659    /// Dispatch attempts before a row is dead-lettered to `failed`. Defaults to
660    /// [`DEFAULT_OUTBOX_MAX_ATTEMPTS`] when omitted and `enabled = true`. Must
661    /// be at least one.
662    ///
663    /// [`DEFAULT_OUTBOX_MAX_ATTEMPTS`]: super::DEFAULT_OUTBOX_MAX_ATTEMPTS
664    pub max_attempts: Option<u32>,
665    /// Base retry backoff applied to the first retry, in milliseconds. Defaults
666    /// to [`DEFAULT_OUTBOX_BACKOFF_BASE_MS`] when omitted and `enabled = true`.
667    /// Successive retries multiply this by `backoff_multiplier` raised to the
668    /// prior-attempt count, capped at `backoff_max_ms`.
669    ///
670    /// [`DEFAULT_OUTBOX_BACKOFF_BASE_MS`]: super::DEFAULT_OUTBOX_BACKOFF_BASE_MS
671    pub backoff_base_ms: Option<u64>,
672    /// Geometric growth factor applied to the backoff per prior attempt.
673    /// Defaults to [`DEFAULT_OUTBOX_BACKOFF_MULTIPLIER`] when omitted and
674    /// `enabled = true`. Must be at least one so backoff never shrinks.
675    ///
676    /// [`DEFAULT_OUTBOX_BACKOFF_MULTIPLIER`]: super::DEFAULT_OUTBOX_BACKOFF_MULTIPLIER
677    pub backoff_multiplier: Option<u32>,
678    /// Upper bound on a single retry's backoff, in milliseconds. Defaults to
679    /// [`DEFAULT_OUTBOX_BACKOFF_MAX_MS`] when omitted and `enabled = true`. Must
680    /// be at least `backoff_base_ms`.
681    ///
682    /// [`DEFAULT_OUTBOX_BACKOFF_MAX_MS`]: super::DEFAULT_OUTBOX_BACKOFF_MAX_MS
683    pub backoff_max_ms: Option<u64>,
684    /// Interval between live stale-claim reconciliation sweeps, in milliseconds. When both
685    /// reconciliation knobs are absent the live sweep remains dark; setting either knob opts into
686    /// reconciliation and requires both values to be positive.
687    pub reconcile_interval_ms: Option<u64>,
688    /// Age after which a durable `claimed` outbox row is considered stranded, in milliseconds. The
689    /// reconciler re-arms only rows with `claimed_at` older than this threshold, preserving their
690    /// attempt count.
691    pub reconcile_stale_after_ms: Option<u64>,
692    /// Wire transport the dispatcher uses to place a claimed row with a worker.
693    /// Defaults to [`OutboxTransport::Liminal`] whenever the `liminal-transport`
694    /// Cargo feature is compiled (the default ablative-stack build), so an
695    /// outbox-enabled server uses the liminal cross-node transport out of the box;
696    /// a slim build without that feature defaults to [`OutboxTransport::Grpc`].
697    /// Selecting `liminal` in a build without the feature is a configuration error
698    /// surfaced at spawn. The transport only matters when `outbox.enabled = true`.
699    pub transport: OutboxTransport,
700    /// Address (`host:port`) the aion-server LISTENS on for inbound liminal
701    /// worker connections, used only when `transport = liminal`. REQUIRED in that
702    /// mode; ignored otherwise.
703    ///
704    /// The aion-server HOSTS the liminal listener: a remote `LiminalActivityWorker`
705    /// connects IN to this address and self-registers in-band, so the server's
706    /// [`ConnectionSupervisor`](liminal_server::server::connection::ConnectionSupervisor)
707    /// owns the worker's connection and can push a dispatch out on it
708    /// (`push_to_connection`). This replaces the superseded 13-0 spike's
709    /// client-connect address: the dispatcher no longer *connects out* to publish
710    /// to a channel — it pushes to a connected worker the server already owns.
711    ///
712    /// The dispatch *channel* is not configured here: it is derived per-row from
713    /// each row's durable `(namespace, task_queue)` via `dispatch_channel_name`
714    /// (NSTQ-5), so one listener fans different worker pools out by selection.
715    pub liminal_listen_address: Option<String>,
716}
717
718/// Wire transport selected for outbox dispatch.
719///
720/// `liminal` (the default whenever the `liminal-transport` feature is compiled —
721/// which it is in the default ablative-stack build) routes the dispatch over the
722/// liminal cross-node bus. `grpc` keeps the connected-worker registry path. A
723/// slim build compiled WITHOUT `liminal-transport` falls back to `grpc` as the
724/// default so the default is always constructible under the active feature set.
725#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize)]
726#[serde(rename_all = "lowercase")]
727pub enum OutboxTransport {
728    /// Dispatch over the in-process connected-worker gRPC registry. The default
729    /// in a slim build compiled WITHOUT `liminal-transport`, which cannot
730    /// construct the liminal path.
731    // The default variant is feature-selected: gRPC when the liminal transport is
732    // absent (it is the only constructible path), liminal otherwise.
733    #[cfg_attr(not(feature = "liminal-transport"), default)]
734    Grpc,
735    /// Dispatch over the liminal cross-node bus (requires `liminal-transport`).
736    /// The out-of-box default whenever `liminal-transport` is compiled (the
737    /// default ablative-stack feature set), so an outbox-enabled server uses the
738    /// ablative messaging transport without extra configuration.
739    #[cfg_attr(feature = "liminal-transport", default)]
740    Liminal,
741}
742
743/// Server-side authoring settings from `[authoring]`.
744///
745/// The AWL studio is on by default, rooted at `<AION_HOME>/authoring`, so a
746/// stock `aion server` provides its document, layout, check, deploy, revision,
747/// run, and scaffold surfaces without preliminary configuration. Operators can
748/// override it explicitly.
749///
750/// Only the separate Gleam authoring loop remains dark by default: without
751/// `gleam_path`, `/authoring/*` is not mounted and nothing invokes `gleam`
752/// (CN7). Setting `gleam_path` commissions that loop and makes `project_root`
753/// required.
754#[derive(Clone, Debug, Default, Deserialize)]
755#[serde(default, deny_unknown_fields)]
756pub struct AuthoringConfig {
757    /// Path to the external `gleam` binary the toolchain spawns. `None`
758    /// (the default) leaves only the Gleam authoring loop dark; setting it gates
759    /// the `/authoring/*` endpoints on. There is no default binary — the
760    /// operator names it explicitly.
761    pub gleam_path: Option<PathBuf>,
762    /// Built Gleam workflow project root submitted source is written into and
763    /// packaged from. REQUIRED when `gleam_path` is set; no default (house
764    /// rule) — a Gleam project needs `gleam.toml`, the `aion_flow` dependency,
765    /// `workflow.toml`, and `schemas/`, so the operator provisions and names
766    /// the project root.
767    pub project_root: Option<PathBuf>,
768    /// Root directory exposed by the full AWL studio surface. The merged server
769    /// loader defaults this to `<AION_HOME>/authoring` after config and
770    /// environment overlays. `authoring.workspace_dir` or
771    /// `AION_AUTHORING_WORKSPACE_DIR` therefore overrides it explicitly.
772    pub workspace_dir: Option<PathBuf>,
773}
774
775impl Default for ServerSection {
776    fn default() -> Self {
777        Self {
778            listen_address: DEFAULT_HTTP_ADDRESS,
779            grpc_address: DEFAULT_GRPC_ADDRESS,
780            cors_allowed_origins: Vec::new(),
781        }
782    }
783}
784
785impl Default for StoreConfig {
786    fn default() -> Self {
787        Self {
788            // The ablative stack is the out-of-box durable default: an empty
789            // config selects the haematite backend. The merged loader fills
790            // `<AION_HOME>/data`; `memory` (ephemeral) and `libsql`
791            // (lightweight, opt-in) remain explicit operator choices.
792            backend: StoreBackend::Haematite,
793            url: None,
794            owned_shards: Vec::new(),
795            data_dir: None,
796            // 64, NOT 4096 (#187): raising the default to 4096 bricked every
797            // fresh server — engine boot's scan_prefix materializes every
798            // shard, then each haematite 0.4.0 commit fans out one thread +
799            // fsync PER MATERIALIZED SHARD (~8k fsyncs/commit), blowing the
800            // 5s shard-actor timeout on deploy/start/timers/outbox. Re-raise
801            // only after haematite makes commit O(dirty shards) and the
802            // scaffold e2es pass at the new default (see #187 fix plan).
803            shard_count: 64,
804            cluster: None,
805        }
806    }
807}
808
809impl Default for RuntimeSection {
810    fn default() -> Self {
811        Self {
812            scheduler_threads: 1,
813            // Deliberately absent: validation fails loudly until the operator
814            // sets the workflow query reply deadline for the deployment.
815            query_timeout_ms: None,
816        }
817    }
818}
819
820impl Default for DrainConfig {
821    fn default() -> Self {
822        Self {
823            timeout_seconds: 30,
824        }
825    }
826}
827
828impl Default for AuthConfig {
829    fn default() -> Self {
830        Self {
831            enabled: false,
832            jwks_url: None,
833            jwks_refresh_seconds: 300,
834        }
835    }
836}
837
838impl Default for MetricsConfig {
839    fn default() -> Self {
840        Self { enabled: true }
841    }
842}
843
844impl Default for NamespacesConfig {
845    fn default() -> Self {
846        Self {
847            default: "default".to_owned(),
848            auto_create: AutoCreate::default(),
849            max_in_flight_activities: DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
850        }
851    }
852}
853
854impl Default for ListenConfig {
855    fn default() -> Self {
856        Self {
857            grpc: DEFAULT_GRPC_ADDRESS,
858            http: DEFAULT_HTTP_ADDRESS,
859        }
860    }
861}
862
863impl Default for OpsConsoleConfig {
864    fn default() -> Self {
865        Self {
866            source: OpsConsoleAssetSource::Embedded,
867        }
868    }
869}
870
871impl Default for NamespaceConfig {
872    fn default() -> Self {
873        Self {
874            mode: NamespaceMode::SharedEngine,
875        }
876    }
877}
878
879impl Default for WorkerConfig {
880    fn default() -> Self {
881        Self {
882            heartbeat_window: Duration::from_secs(30),
883            queue_service: crate::worker::queue_service::QueueServiceConfig::default(),
884        }
885    }
886}
887
888impl Default for WebSocketConfig {
889    fn default() -> Self {
890        Self {
891            outbound_buffer_bound: 32,
892            // Deliberately absent: validation fails loudly until the operator
893            // sizes the engine-global broadcast channel for the deployment.
894            event_broadcast_capacity: None,
895            // Deliberately absent for the same reason: the cluster channel has
896            // no defined lag buffer until sized.
897            cluster_broadcast_capacity: None,
898        }
899    }
900}
901
902mod duration_millis {
903    use std::time::Duration;
904
905    use serde::{Deserialize, Deserializer};
906
907    pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
908    where
909        D: Deserializer<'de>,
910    {
911        let millis = u64::deserialize(deserializer)?;
912        Ok(Duration::from_millis(millis))
913    }
914}