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, Deserializer, de};
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_DISCOVER_TTL_MS,
22        DEFAULT_MCP_TASK_POLL_INTERVAL_MS, DEFAULT_MCP_TOOLS_LIST_TTL_MS,
23        DEFAULT_OBSERVABILITY_MAX_EVENT_BYTES, DEFAULT_OBSERVABILITY_MAX_STREAM_EVENTS,
24        EVENT_BROADCAST_CAPACITY_REQUIRED, MCP_TASK_POLL_INTERVAL_REQUIRED,
25        OBSERVABILITY_MAX_EVENT_BYTES_REQUIRED, OBSERVABILITY_MAX_STREAM_EVENTS_REQUIRED,
26    },
27};
28
29/// Public transport listener addresses from `[server]`.
30#[derive(Clone, Debug, Deserialize)]
31#[serde(default, deny_unknown_fields)]
32pub struct ServerSection {
33    /// HTTP/JSON and dashboard listener.
34    pub listen_address: SocketAddr,
35    /// gRPC API and worker-protocol listener.
36    pub grpc_address: SocketAddr,
37    /// Browser origins allowed to make cross-origin (CORS) requests to the
38    /// public HTTP API. Empty (the default) is the SECURE default: no
39    /// cross-origin request is permitted and no `CorsLayer` is installed, so a
40    /// same-origin deployment behaves byte-identically to before this field
41    /// existed. When set, each entry is an exact origin (scheme + host + port,
42    /// e.g. `http://localhost:5173`) the browser dashboard is served from; the
43    /// router then answers preflight and emits `Access-Control-Allow-Origin`
44    /// for exactly those origins. There is no wildcard/allow-all default
45    /// (ADR-001): cross-origin access is an explicit operator decision, and the
46    /// layer never pairs `Any` with credentials.
47    #[serde(default)]
48    pub cors_allowed_origins: Vec<String>,
49}
50
51/// Supported event-store backend names.
52///
53/// Deliberately NOT `Deserialize`. `StoreConfigWire` parses the operator's
54/// spelling itself, case-insensitively, so that the file door and the
55/// `AION_STORE_BACKEND` door accept exactly the same set — a derived
56/// `rename_all = "lowercase"` impl was a second, case-SENSITIVE parser for the
57/// same value that nothing called and that disagreed with the one that runs.
58#[derive(Clone, Copy, Debug, Eq, PartialEq)]
59pub enum StoreBackend {
60    /// In-memory store for local development.
61    Memory,
62    /// haematite durable store (single-node, shardable).
63    Haematite,
64}
65
66/// A store-selection input that named the retired libSQL backend.
67///
68/// Recorded at parse/overlay time and refused by `ServerConfig::validate`, which
69/// names the input it found and prescribes haematite. Recorded rather than
70/// refused on the spot so all five doors converge on ONE refusal with one
71/// remedy, instead of five differently-worded errors from five parsers.
72#[derive(Clone, Copy, Debug, Eq, PartialEq)]
73pub(crate) enum RetiredStoreInput {
74    /// `backend = "libsql"` in the config file's `[store]` section.
75    Backend,
76    /// `AION_STORE_BACKEND=libsql` in the environment.
77    BackendEnvironment,
78    /// `url = ...` in the config file's `[store]` section.
79    Url,
80    /// The `AION_STORE_URL` environment variable.
81    Environment,
82    /// The `--store-url` command-line flag.
83    Flag,
84}
85
86/// Event-store backend configuration from `[store]`.
87#[derive(Clone, Debug)]
88pub struct StoreConfig {
89    /// Selected backing store implementation.
90    pub backend: StoreBackend,
91    pub(crate) retired_input: Option<RetiredStoreInput>,
92    /// Static distribution-shard assignment for this node (multi-shard
93    /// active-active). When empty (the default) the node owns ALL shards — the
94    /// single-node default, byte-identical to today. When set, the engine boot
95    /// path scopes recovery and enumeration to exactly these shards. The
96    /// single-shard memory backend ignores the assignment; it is meaningful only
97    /// for a sharded backend. No election is performed: assignment is static
98    /// config.
99    pub owned_shards: Vec<usize>,
100    /// Filesystem data directory for the haematite backend. Required when
101    /// `backend = haematite`; ignored by every other backend. The directory is
102    /// opened if it already holds a haematite database, otherwise created.
103    pub data_dir: Option<String>,
104    /// Number of haematite shards to create on a fresh database. Defaults to 64.
105    ///
106    /// This is an IMMUTABLE virtual-shard count: nodes own shard *ranges* and
107    /// routing is `BLAKE3(key) % shard_count` with no reshard path, so a
108    /// single-node deployment can later grow into a cluster WITHOUT a data
109    /// migration — but only up to `shard_count` nodes, and the value is fixed
110    /// at create.
111    ///
112    /// The default was briefly 4096 on the premise that lazy shard-actor
113    /// materialization (haematite >= 0.4.0) made a high count ~free. That
114    /// premise fails in practice (#187): aion-server's boot restores
115    /// packages/routes/namespaces via full-prefix scans, which materialize
116    /// EVERY shard, and haematite 0.4.0 then fans each commit out to every
117    /// materialized shard with an unconditional fsync — ~2 fsyncs x 4096 per
118    /// logical commit — blowing the 5s shard-actor timeout and bricking
119    /// deploy/start/timers/outbox on a fresh server. Re-raise only after
120    /// haematite makes commit O(dirty shards) and the scaffold e2es pass at
121    /// the new default. Set explicitly (config or `AION_STORE_SHARD_COUNT`)
122    /// to override. Ignored by every other backend, and ignored when opening
123    /// an existing haematite database (the on-disk shard count wins).
124    pub shard_count: usize,
125    /// Optional distributed-cluster membership for the haematite backend (SS-2).
126    ///
127    /// Absent (the default) selects the SINGLE-NODE haematite path, byte-identical
128    /// to today: no endpoint is bound, no shard is elected, the store owns
129    /// everything locally. Present selects the DISTRIBUTED path: the boot path
130    /// binds a replication endpoint, builds a quorum membership from `members` +
131    /// `peers`, and the engine boot path elects (`acquire_shard_and_serve`) this
132    /// node's `owned_shards` before recovery. Ignored by every non-haematite
133    /// backend.
134    pub cluster: Option<ClusterConfig>,
135    /// The byte ceiling the haematite node caches may hold.
136    /// **Required; no default** — the haematite boot path refuses to start
137    /// without it. Ignored by every other backend (only haematite has a node
138    /// cache), which is why it is required where it is USED rather than at
139    /// parse time: a `memory` deployment is not asked to rule on a cache it does
140    /// not have.
141    ///
142    /// A node is not a fixed-size thing — under byte-aware chunking a leaf
143    /// reaches the ~96KB class — so a cache bounded only by `max_entries`
144    /// carries a standing footprint of `entries x (whatever a node weighs)`,
145    /// unbounded in bytes by construction. This is the missing bound, and
146    /// haematite 0.8.2 refuses a `DatabaseConfig` that does not carry it.
147    ///
148    /// Deserialized through haematite's own wire shape, so there is exactly one
149    /// parser for this value estate-wide:
150    ///
151    /// ```toml
152    /// [store]
153    /// node_cache_budget = { bytes = 1073741824 }  # 1 GiB
154    /// # or, the pre-budget behaviour said out loud:
155    /// node_cache_budget = "unlimited"
156    /// ```
157    ///
158    /// Absence refuses [`STORE_NODE_CACHE_BUDGET_REQUIRED`] for an
159    /// embedder-built config; for a config FILE the boot-side heal
160    /// (`config::heal`) inserts the declared default (`"unlimited"`) on a
161    /// haematite boot instead of refusing, preserving the previous file
162    /// beside it. A zero byte count is refused by haematite at parse time (a
163    /// zero ceiling admits nothing, which is "disable the cache" and must be
164    /// spelled differently) — the heal never touches a present value.
165    ///
166    /// [`STORE_NODE_CACHE_BUDGET_REQUIRED`]: super::STORE_NODE_CACHE_BUDGET_REQUIRED
167    pub node_cache_budget: Option<haematite::NodeCacheBudget>,
168    /// RETIRED (2026-08-24) and ignored: boot now waits indefinitely for the
169    /// data-directory writer lock — a kernel-released flock whose holder is
170    /// either live mid-handover or already gone, so a patience ceiling only
171    /// converted slow handovers into refused boots. Kept so a configuration
172    /// that still carries the key stays LEGAL (warned, never refused) and
173    /// existing estates boot unchanged.
174    pub lock_acquisition_patience_ms: Option<u64>,
175    /// RETIRED (2026-08-24) and ignored, with `lock_acquisition_patience_ms`:
176    /// the wait's internal probe cadence is an implementation constant, not
177    /// configuration. Kept only so the key stays legal in existing files.
178    pub lock_acquisition_retry_cadence_ms: Option<u64>,
179}
180
181#[derive(Default, Deserialize)]
182#[serde(default, deny_unknown_fields)]
183struct StoreConfigWire {
184    backend: Option<String>,
185    /// Retired: recorded so `validate` can refuse it. Kept on the wire — and
186    /// NOT left to `deny_unknown_fields` — so an operator carrying a 0.15
187    /// config is told the key is retired and what replaces it, rather than
188    /// getting an unknown-field parse error that names no remedy.
189    url: Option<String>,
190    owned_shards: Vec<usize>,
191    data_dir: Option<String>,
192    shard_count: Option<usize>,
193    cluster: Option<ClusterConfig>,
194    node_cache_budget: Option<haematite::NodeCacheBudget>,
195    lock_acquisition_patience_ms: Option<u64>,
196    lock_acquisition_retry_cadence_ms: Option<u64>,
197}
198
199impl<'de> Deserialize<'de> for StoreConfig {
200    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
201    where
202        D: Deserializer<'de>,
203    {
204        let wire = StoreConfigWire::deserialize(deserializer)?;
205        let mut config = Self::default();
206        if let Some(backend) = wire.backend.as_deref() {
207            match backend.to_ascii_lowercase().as_str() {
208                "memory" => config.backend = StoreBackend::Memory,
209                "haematite" => config.backend = StoreBackend::Haematite,
210                "libsql" => config.retired_input = Some(RetiredStoreInput::Backend),
211                _ => {
212                    return Err(de::Error::custom(
213                        "store.backend must be one of: memory, haematite",
214                    ));
215                }
216            }
217        }
218        if wire.url.is_some() && config.retired_input.is_none() {
219            config.retired_input = Some(RetiredStoreInput::Url);
220        }
221        config.owned_shards = wire.owned_shards;
222        config.data_dir = wire.data_dir;
223        if let Some(shard_count) = wire.shard_count {
224            config.shard_count = shard_count;
225        }
226        config.cluster = wire.cluster;
227        config.node_cache_budget = wire.node_cache_budget;
228        config.lock_acquisition_patience_ms = wire.lock_acquisition_patience_ms;
229        config.lock_acquisition_retry_cadence_ms = wire.lock_acquisition_retry_cadence_ms;
230        Ok(config)
231    }
232}
233
234/// Distributed-cluster membership for the haematite backend, from `[store.cluster]`.
235///
236/// This is the minimal, well-defaulted seam that turns the single-node haematite
237/// store into a distributed one (SS-2). A "cluster of one" — `node_id` set,
238/// `members` either empty or naming only `node_id`, and no `peers` — is a valid,
239/// non-flaky configuration: election self-quorums (quorum denominator 1) and the
240/// node boots through the production builder as the fenced owner of its shards.
241#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
242#[serde(deny_unknown_fields)]
243pub struct ClusterConfig {
244    /// This node's globally-unique distribution name (e.g. `node-0@127.0.0.1`).
245    /// Used as the local endpoint name and the local membership identity.
246    pub node_id: String,
247    /// The replication endpoint listen address this node binds for peer
248    /// quorum/election traffic (e.g. `127.0.0.1:7000`).
249    pub bind_address: SocketAddr,
250    /// The FULL cluster membership by node id — the quorum DENOMINATOR. Never the
251    /// reachable subset. May be empty or omit peers for a cluster of one, in which
252    /// case it is treated as `[node_id]` (denominator 1). `node_id` is always
253    /// counted in the denominator whether or not it appears here.
254    #[serde(default)]
255    pub members: Vec<String>,
256    /// Dialable peers (name + address) this node connects to for replication. A
257    /// cluster of one leaves this empty. Peers not in `members` do not inflate the
258    /// quorum denominator.
259    #[serde(default)]
260    pub peers: Vec<ClusterPeer>,
261    /// SS-5b automatic-failover poll interval in milliseconds: how often the
262    /// cluster supervisor checks each watched peer's replication liveness.
263    /// Defaults to [`DEFAULT_FAILOVER_POLL_INTERVAL_MS`] when omitted.
264    ///
265    /// [`DEFAULT_FAILOVER_POLL_INTERVAL_MS`]: super::DEFAULT_FAILOVER_POLL_INTERVAL_MS
266    #[serde(default)]
267    pub failover_poll_interval_ms: Option<u64>,
268    /// SS-5b debounce: the number of CONSECUTIVE polls a peer must be observed
269    /// disconnected before its shards are adopted, so a transient blip does not
270    /// trigger a disruptive failover. Defaults to
271    /// [`DEFAULT_FAILOVER_CONFIRMATIONS`] when omitted; must be at least one.
272    ///
273    /// [`DEFAULT_FAILOVER_CONFIRMATIONS`]: super::DEFAULT_FAILOVER_CONFIRMATIONS
274    #[serde(default)]
275    pub failover_confirmations: Option<u32>,
276}
277
278/// One dialable cluster peer: its distribution name and replication address.
279#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
280#[serde(deny_unknown_fields)]
281pub struct ClusterPeer {
282    /// The peer's globally-unique distribution name (matches its `node_id`).
283    pub name: String,
284    /// The peer's replication endpoint address to dial.
285    pub address: SocketAddr,
286    /// The peer's gRPC client-API address, for request forwarding (R-2/R-3).
287    /// This is DISTINCT from `address` (the replication/quorum endpoint): a
288    /// forwarded client `signal`/`query`/`cancel` is dialed here, not on the
289    /// replication port. Absent (the default) means the peer is not
290    /// forwardable — its shards still resolve to a remote owner, but routing
291    /// falls back to returning the typed `NotOwner` instead of forwarding (R-3).
292    #[serde(default)]
293    pub grpc_address: Option<SocketAddr>,
294    /// The distribution shards this peer owns. Empty (the default) means the
295    /// operator did not declare the peer's shards, so the SS-5b cluster
296    /// supervisor cannot adopt them automatically when the peer dies — automatic
297    /// failover for a peer requires its `owned_shards` to be declared here so the
298    /// survivor knows exactly which shards to elect + resume. Declaring them does
299    /// not change replication or quorum; it only tells the supervisor what to
300    /// adopt on this peer's death.
301    #[serde(default)]
302    pub owned_shards: Vec<usize>,
303}
304
305/// Engine runtime settings from `[runtime]`.
306#[derive(Clone, Debug, Deserialize)]
307#[serde(default, deny_unknown_fields)]
308pub struct RuntimeSection {
309    /// Number of scheduler worker threads.
310    pub scheduler_threads: usize,
311    /// JIT compilation threshold: recorded calls to one
312    /// `(module, function, arity)` before beamr compiles it.
313    ///
314    /// Absent means beamr applies its own default; the server invents no value.
315    /// Read `aion::RuntimeConfig::jit_threshold` before setting it — a large
316    /// value defers compilation past any realistic workload but is **not** a
317    /// JIT off-switch.
318    pub jit_threshold: Option<u32>,
319    /// Engine reply deadline for workflow queries, in milliseconds.
320    /// REQUIRED — the server always mounts `/workflows/query`, so the query
321    /// reply deadline must be an explicit operator decision; there is no
322    /// default. The engine builder is equally explicit-no-default.
323    pub query_timeout_ms: Option<u64>,
324    /// How often the engine sweeps its registered workloops, in milliseconds.
325    ///
326    /// This is the DEAD-MAN DETECTION interval, not a scheduling precision
327    /// knob: a loop's cadence window is checked once per sweep, so the sweep
328    /// interval bounds how late a fire or a missed window can be noticed. Cost
329    /// scales with the number of REGISTERED loops, not with how many are due.
330    pub workloop_sweep_interval_ms: Option<u64>,
331    /// The engine stop's drain bound, in milliseconds (AE-017).
332    ///
333    /// A NO-PROGRESS bound: when the server stops, each engine worker thread
334    /// (cleanup executor, exit drainer, exit-callback dispatcher) is drained,
335    /// and the drain fails only when that worker has completed NOTHING for
336    /// this long — the clock restarts on every completed job. A loaded box
337    /// that is still finishing callbacks is never called a wedge; a callback
338    /// that is genuinely stuck is named within this window of sticking, with
339    /// the in-flight age and queue depth in the log. Omitted → 30 s (the
340    /// estate's ruled patience of one deploy); zero is refused.
341    pub stop_drain_timeout_ms: Option<u64>,
342}
343
344/// Graceful drain settings from `[drain]`.
345#[derive(Clone, Debug, Deserialize)]
346#[serde(default, deny_unknown_fields)]
347pub struct DrainConfig {
348    /// Maximum drain duration in seconds.
349    pub timeout_seconds: u64,
350}
351
352/// Authentication configuration applied at adapter boundaries.
353#[derive(Clone, Debug, Deserialize)]
354#[serde(default, deny_unknown_fields)]
355pub struct AuthConfig {
356    /// Whether authentication is enabled.
357    pub enabled: bool,
358    /// JWKS URL used by AO-006 auth validation.
359    pub jwks_url: Option<String>,
360    /// JWKS refresh interval in seconds.
361    pub jwks_refresh_seconds: u64,
362}
363
364/// Metrics endpoint settings from `[metrics]`.
365#[derive(Clone, Debug, Deserialize)]
366#[serde(default, deny_unknown_fields)]
367pub struct MetricsConfig {
368    /// Whether metrics are exposed.
369    pub enabled: bool,
370}
371
372/// Namespace defaults from `[namespaces]`.
373#[derive(Clone, Debug, Deserialize)]
374#[serde(default, deny_unknown_fields)]
375pub struct NamespacesConfig {
376    /// Default namespace used for local callers and worker dispatch.
377    pub default: String,
378    /// Minted-on-use policy: whether referencing an unseen namespace at worker
379    /// registration durably mints it ([`AutoCreate::Open`], the zero-config
380    /// default) or is rejected ([`AutoCreate::Closed`]).
381    pub auto_create: AutoCreate,
382    /// Platform-wide default for a namespace's **cluster-wide** concurrent
383    /// in-flight-activity ceiling, applied when a namespace record carries no
384    /// explicit `max_in_flight_activities` override (Control-Plane Phase 2,
385    /// P2-Q1). This is the GENEROUS platform default, NOT a low hard cap: it is
386    /// a cluster-wide tenant contract (never "per-node × N"), so a tenant is
387    /// promised ≈this many concurrent activities across the whole cluster.
388    /// Defaults to [`DEFAULT_MAX_IN_FLIGHT_ACTIVITIES`].
389    ///
390    /// **Stored-only in this slice.** Nothing enforces it yet — the outbox
391    /// dispatcher's keyed backpressure (P2-Q2) consults it in a later slice.
392    ///
393    /// [`DEFAULT_MAX_IN_FLIGHT_ACTIVITIES`]: super::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES
394    pub max_in_flight_activities: u32,
395}
396
397/// Minted-on-use namespace policy (Control-Plane Phase 1).
398///
399/// Governs what happens when a worker registers for a namespace that has no
400/// durable registry record. Defaults to [`AutoCreate::Open`] to preserve the
401/// zero-ceremony, no-pre-provision model: a namespace comes into being on first
402/// reference.
403#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
404#[serde(rename_all = "snake_case")]
405pub enum AutoCreate {
406    /// A worker registering for an unseen namespace durably mints it (an
407    /// idempotent upsert through the registry). Zero-config default.
408    #[default]
409    Open,
410    /// A worker registering for a namespace with no durable registry record is
411    /// rejected; the namespace is never created at the registration hook. The
412    /// escape hatch for locked-down deployments is an explicit create
413    /// (`POST /namespaces`).
414    Closed,
415}
416
417/// Public transport listener addresses retained for existing adapter code.
418#[derive(Clone, Debug, Deserialize)]
419#[serde(default, deny_unknown_fields)]
420pub struct ListenConfig {
421    /// gRPC API and worker-protocol listener.
422    pub grpc: SocketAddr,
423    /// HTTP/JSON and dashboard listener.
424    pub http: SocketAddr,
425}
426
427/// TLS certificate and private-key material.
428#[derive(Clone, Debug, Deserialize)]
429#[serde(deny_unknown_fields)]
430pub struct TlsConfig {
431    /// Certificate chain path supplied by the operator.
432    pub certificate_chain_path: PathBuf,
433    /// Private-key path supplied by the operator.
434    pub private_key_path: PathBuf,
435}
436
437/// Static ops-console asset configuration.
438#[derive(Clone, Debug, Deserialize)]
439#[serde(default, deny_unknown_fields)]
440pub struct OpsConsoleConfig {
441    /// Operator-selected bundle source.
442    pub source: OpsConsoleAssetSource,
443}
444
445/// Static ops-console bundle source.
446#[derive(Clone, Debug, Deserialize)]
447pub enum OpsConsoleAssetSource {
448    /// Serve the built bundle from an operator-supplied directory.
449    FileSystem {
450        /// Directory containing `index.html`, `client-routes.json` (the
451        /// bundle's own declaration of the URLs it owns) and built asset files.
452        asset_path: PathBuf,
453    },
454    /// Serve the compile-time embedded bundle.
455    Embedded,
456}
457
458/// Namespace resolver construction mode.
459#[derive(Clone, Debug, Deserialize)]
460#[serde(default, deny_unknown_fields)]
461pub struct NamespaceConfig {
462    /// Deployment-selected namespace mapping mode.
463    pub mode: NamespaceMode,
464}
465
466/// Supported namespace mapping modes.
467#[derive(Clone, Debug, Deserialize)]
468pub enum NamespaceMode {
469    /// All authorized namespaces share the configured engine instance.
470    SharedEngine,
471    /// Namespace authorization is disabled only for single-tenant deployments.
472    SingleTenant {
473        /// The only namespace accepted by the deployment.
474        namespace: String,
475    },
476}
477
478/// Remote worker heartbeat configuration.
479#[derive(Clone, Debug, Deserialize)]
480#[serde(default, deny_unknown_fields)]
481pub struct WorkerConfig {
482    /// Window after which a silent worker is considered lost.
483    #[serde(with = "duration_millis")]
484    pub heartbeat_window: Duration,
485    /// R1 queue-service policies and clocks (`[worker.queue_service]`).
486    ///
487    /// Absent, every queue is served `strict` with no clocks: structural
488    /// unservability refuses, everything else waits — loudly, typed, and
489    /// queryable. Writing a deadline is how an operator declares how long a
490    /// dispatch may wait; writing a `durable_pending` override is how a queue
491    /// declares that its runs park rather than refuse.
492    #[serde(default)]
493    pub queue_service: crate::worker::queue_service::QueueServiceConfig,
494}
495
496/// WebSocket stream configuration.
497#[derive(Clone, Debug, Deserialize)]
498#[serde(default, deny_unknown_fields)]
499pub struct WebSocketConfig {
500    /// Per-connection outbound buffer bound.
501    pub outbound_buffer_bound: usize,
502    /// Capacity of the engine-global event broadcast channel that backs
503    /// `/events/stream`. REQUIRED — the server always mounts the streaming
504    /// endpoint, so streaming capacity must be an explicit operator decision;
505    /// there is no default. Lag is filter-blind, so size this for global event
506    /// volume across all namespaces, not per-subscription volume.
507    pub event_broadcast_capacity: Option<usize>,
508    /// Capacity of the deployment-global cluster topology/ownership broadcast
509    /// channel that backs the WS3 `cluster` subscription on `/events/stream`.
510    /// REQUIRED with the same non-zero startup guard as
511    /// [`Self::event_broadcast_capacity`]: the cluster channel uses the same
512    /// lag -> one-error-frame -> close contract, which has no defined buffer to
513    /// lag against unless a capacity is configured. Cluster events are low-rate
514    /// (peer/shard/worker topology deltas), so this is typically far smaller
515    /// than the workflow event capacity.
516    pub cluster_broadcast_capacity: Option<usize>,
517}
518
519impl WebSocketConfig {
520    /// Validate the three unconditionally-mounted WebSocket seams: the
521    /// per-connection buffer bound, the workflow event broadcast capacity, and
522    /// the WS3 cluster broadcast capacity. The two broadcast capacities are
523    /// explicit-no-default with a non-zero guard, since both back a lag ->
524    /// one-error-frame -> close contract that needs a defined buffer to lag
525    /// against.
526    pub(super) fn validate(&self) -> Result<(), ServerError> {
527        if self.outbound_buffer_bound == 0 {
528            return config_error("websocket.outbound_buffer_bound must be greater than zero");
529        }
530        match self.event_broadcast_capacity {
531            None | Some(0) => return config_error(EVENT_BROADCAST_CAPACITY_REQUIRED),
532            Some(_) => {}
533        }
534        match self.cluster_broadcast_capacity {
535            None | Some(0) => return config_error(CLUSTER_BROADCAST_CAPACITY_REQUIRED),
536            Some(_) => {}
537        }
538        Ok(())
539    }
540}
541
542/// Model Context Protocol surface settings from `[mcp]`.
543///
544/// The surface is DARK by default: with `enabled = false` (or the section
545/// absent) the `/mcp` route is not mounted and the path is a plain 404. An MCP
546/// endpoint hands a model the ability to start and cancel durable executions,
547/// so commissioning it is an explicit operator decision, never a side effect of
548/// upgrading.
549///
550/// `allowed_origins` is EMPTY by default and empty means "no browser origin".
551/// A programmatic client sends no `Origin` header and is unaffected; a page in
552/// a browser always sends one, so the empty list refuses every browser-borne
553/// request. That is the DNS-rebinding defence the transport requires, and it is
554/// a real policy rather than an unset value.
555#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
556#[serde(default, deny_unknown_fields)]
557pub struct McpConfig {
558    /// Whether the `/mcp` endpoint is mounted. Defaults to false.
559    pub enabled: bool,
560    /// Browser origins permitted to reach the MCP endpoint. Empty permits none.
561    pub allowed_origins: Vec<String>,
562    /// `ttlMs` on the `server/discover` result.
563    ///
564    /// Defaults to [`DEFAULT_MCP_DISCOVER_TTL_MS`] when omitted and
565    /// `enabled = true`, so switching the surface on does not force sizing two
566    /// cache lifetimes first.
567    ///
568    /// [`DEFAULT_MCP_DISCOVER_TTL_MS`]: super::DEFAULT_MCP_DISCOVER_TTL_MS
569    pub discover_ttl_ms: Option<u64>,
570    /// `ttlMs` on the `tools/list` result. Defaults to
571    /// [`DEFAULT_MCP_TOOLS_LIST_TTL_MS`].
572    ///
573    /// [`DEFAULT_MCP_TOOLS_LIST_TTL_MS`]: super::DEFAULT_MCP_TOOLS_LIST_TTL_MS
574    pub tools_list_ttl_ms: Option<u64>,
575    /// The `pollIntervalMs` advertised on a created task. Defaults to
576    /// [`DEFAULT_MCP_TASK_POLL_INTERVAL_MS`].
577    ///
578    /// A hint to the client and nothing more: no server behaviour depends on
579    /// it. There is no task lifetime beside it, because an MCP task here is a
580    /// projection of a durable run and has nothing to expire.
581    ///
582    /// [`DEFAULT_MCP_TASK_POLL_INTERVAL_MS`]: super::DEFAULT_MCP_TASK_POLL_INTERVAL_MS
583    pub task_poll_interval_ms: Option<u64>,
584}
585
586impl McpConfig {
587    /// Refuse a zero poll interval at startup.
588    ///
589    /// Only meaningful when the surface is enabled: a dark surface's knobs are
590    /// never read, so refusing a boot over one would be refusing over a value
591    /// nothing uses.
592    ///
593    /// # Errors
594    ///
595    /// [`ServerError::Config`] when an explicitly-set interval is zero. A zero
596    /// poll interval is a busy loop, not a fast one, and the operator is told
597    /// so rather than being handed one.
598    pub(super) fn validate(&self) -> Result<(), ServerError> {
599        if !self.enabled {
600            return Ok(());
601        }
602        if self.task_poll_interval_ms == Some(0) {
603            return config_error(MCP_TASK_POLL_INTERVAL_REQUIRED);
604        }
605        Ok(())
606    }
607
608    /// Resolve every omitted knob to its default.
609    ///
610    /// A zero poll interval resolves to the DEFAULT rather than to zero.
611    /// [`Self::validate`] has already refused it on any loaded configuration,
612    /// so this arm is reachable only from a hand-constructed config that
613    /// skipped validation — and for that case a documented default is the safe
614    /// direction, where a literal zero would be a busy loop.
615    #[must_use]
616    pub fn resolved(&self) -> ResolvedMcpConfig {
617        ResolvedMcpConfig {
618            enabled: self.enabled,
619            allowed_origins: self.allowed_origins.clone(),
620            discover_ttl_ms: self.discover_ttl_ms.unwrap_or(DEFAULT_MCP_DISCOVER_TTL_MS),
621            tools_list_ttl_ms: self
622                .tools_list_ttl_ms
623                .unwrap_or(DEFAULT_MCP_TOOLS_LIST_TTL_MS),
624            task_poll_interval_ms: self
625                .task_poll_interval_ms
626                .filter(|value| *value > 0)
627                .unwrap_or(DEFAULT_MCP_TASK_POLL_INTERVAL_MS),
628        }
629    }
630}
631
632impl Default for ResolvedMcpConfig {
633    /// The dark surface: not mounted, no origin permitted, every lifetime at
634    /// its documented default. This is what an embedder that never configures
635    /// MCP gets, and it serves nothing.
636    fn default() -> Self {
637        McpConfig::default().resolved()
638    }
639}
640
641/// The `[mcp]` section with every omitted knob resolved.
642///
643/// Held in [`RuntimeConfig`](super::RuntimeConfig) so the mount reads settled
644/// values rather than re-deriving defaults at each composition point.
645#[derive(Clone, Debug, PartialEq, Eq)]
646pub struct ResolvedMcpConfig {
647    /// Whether the `/mcp` endpoint is mounted.
648    pub enabled: bool,
649    /// Browser origins permitted to reach the endpoint.
650    pub allowed_origins: Vec<String>,
651    /// `ttlMs` on `server/discover`.
652    pub discover_ttl_ms: u64,
653    /// `ttlMs` on `tools/list`.
654    pub tools_list_ttl_ms: u64,
655    /// Advertised client poll interval for a task.
656    pub task_poll_interval_ms: u64,
657}
658
659/// Agent-observability transcript settings from `[observability]`: retention
660/// bounds, and the transcript drain's flush policy.
661///
662/// The two RETENTION knobs default (they guard the durable `O` keyspace against
663/// unbounded growth): `max_event_bytes` truncates one oversized transcript event
664/// before it is persisted, and `max_stream_events` caps how many events one
665/// `(workflow, activity, attempt)` stream retains (one marker record is
666/// persisted at the cap; live streaming continues).
667///
668/// The two FLUSH knobs have **no default and are required** — see
669/// [`Self::max_batch_events`].
670#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
671#[serde(default, deny_unknown_fields)]
672pub struct ObservabilityConfig {
673    /// Ceiling on one persisted transcript event's serialized size, bytes.
674    pub max_event_bytes: usize,
675    /// Ceiling on retained events per `(workflow, activity, attempt)` stream.
676    pub max_stream_events: u64,
677    /// Maximum transcript events the drain commits in ONE durable append.
678    /// **Required; no default** — an embedder-built config that omits it
679    /// refuses to start; a config FILE that omits it is healed at boot with
680    /// the declared default (`64`, the teaching configs' value) by
681    /// `config::heal`.
682    ///
683    /// Every durable commit re-persists its whole containing storage leaf as a
684    /// new permanent blob, so committing one event at a time made the store cost
685    /// of a run linear in event COUNT: measured 2026-08-17, a 136 KB status
686    /// sweep left 73.7 MB of permanent store (540x) and a single 2,359-byte
687    /// append bought a 2,303,416-byte blob. Batching divides that count by (up
688    /// to) this number.
689    ///
690    /// There is deliberately no shipped value: the trade between store cost and
691    /// how many events one refused commit can lose is the operator's, and an
692    /// invented default would silently make it for them — the boot-side heal
693    /// is the one deliberate exception, minting the teaching value into a
694    /// FILE visibly, with a logged name and a preserved backup. An explicit
695    /// zero fails startup with
696    /// [`OBSERVABILITY_MAX_BATCH_EVENTS_REQUIRED`], the same
697    /// explicit-no-default guard `websocket.cluster_broadcast_capacity` uses;
698    /// the heal never rewrites a present value.
699    ///
700    /// [`OBSERVABILITY_MAX_BATCH_EVENTS_REQUIRED`]: super::OBSERVABILITY_MAX_BATCH_EVENTS_REQUIRED
701    pub max_batch_events: Option<usize>,
702    /// How long, in milliseconds, the drain may hold a PARTIAL batch open
703    /// waiting for it to fill. **Required; no default** — but `0` is a valid,
704    /// meaningful setting: never wait, commit whatever is already queued.
705    ///
706    /// This is the only knob here that touches durability timing. Transcript
707    /// events already wait in an in-memory queue before they are committed, and
708    /// everything queued there is lost if the process dies; this value bounds
709    /// how much LONGER an event may wait, and therefore how much transcript a
710    /// kill-9 can cost. It buys commits: at `0` only events that genuinely
711    /// arrived together share a commit. The `O` keyspace is observability, never
712    /// workflow replay authority, which is why the trade is offered at all.
713    ///
714    /// Absence refuses [`OBSERVABILITY_MAX_BATCH_HOLD_MS_REQUIRED`] for an
715    /// embedder-built config; a config FILE is healed at boot with the
716    /// declared default (`200`) by `config::heal`.
717    ///
718    /// [`OBSERVABILITY_MAX_BATCH_HOLD_MS_REQUIRED`]: super::OBSERVABILITY_MAX_BATCH_HOLD_MS_REQUIRED
719    pub max_batch_hold_ms: Option<u64>,
720}
721
722impl ObservabilityConfig {
723    /// The default retention bounds with the REQUIRED flush policy stated.
724    ///
725    /// For an embedder that builds its `ServerConfig` in code rather than from
726    /// a file: [`Default`] deliberately leaves the flush policy unruled (so a
727    /// server built from it refuses to start), and this is how such a caller
728    /// states its ruling in one line. `max_batch_hold_ms` of `0` means the drain
729    /// never holds a partial batch open.
730    #[must_use]
731    pub fn with_flush_policy(max_batch_events: usize, max_batch_hold_ms: u64) -> Self {
732        Self {
733            max_batch_events: Some(max_batch_events),
734            max_batch_hold_ms: Some(max_batch_hold_ms),
735            ..Self::default()
736        }
737    }
738
739    /// Validate the retention bounds: both must be positive — a zero event
740    /// ceiling truncates every event to nothing and a zero stream cap retains
741    /// no transcript at all, so each is a genuine misconfiguration caught at
742    /// startup with an operator-facing message (the `WebSocketConfig` pattern).
743    pub(super) fn validate(&self) -> Result<(), ServerError> {
744        if self.max_event_bytes == 0 {
745            return config_error(OBSERVABILITY_MAX_EVENT_BYTES_REQUIRED);
746        }
747        if self.max_stream_events == 0 {
748            return config_error(OBSERVABILITY_MAX_STREAM_EVENTS_REQUIRED);
749        }
750        // The FLUSH policy is deliberately not checked here. Both retention
751        // bounds have documented defaults, so an omitted value is a resolved
752        // value and this function can decide it at parse time; the flush policy
753        // has no default at all, and a parsed `ServerConfig` is not yet a
754        // running server (embedders and `aion config` inspect one without
755        // booting). It is required where it is USED — at publisher
756        // construction on the server boot path, `state.rs`'s
757        // `required_transcript_batch_policy` — which is the same seam
758        // `websocket.cluster_broadcast_capacity` is finally required at, and
759        // which fails startup loudly, naming the missing key.
760        Ok(())
761    }
762}
763
764impl Default for ObservabilityConfig {
765    /// The retention bounds fall back to their documented defaults; the flush
766    /// policy does NOT — `None` here is "the operator has not ruled". For an
767    /// embedder-built config the boot path turns that into a refusal to
768    /// start; for a config FILE the boot-side heal (`config::heal`) inserts
769    /// the declared defaults visibly instead.
770    fn default() -> Self {
771        Self {
772            max_event_bytes: DEFAULT_OBSERVABILITY_MAX_EVENT_BYTES,
773            max_stream_events: DEFAULT_OBSERVABILITY_MAX_STREAM_EVENTS,
774            max_batch_events: None,
775            max_batch_hold_ms: None,
776        }
777    }
778}
779
780/// Operator deploy API settings from `[deploy]`.
781///
782/// The deploy surface is dark by default: with `enabled = false` (or the
783/// section absent) neither the `/deploy/*` HTTP routes nor the gRPC
784/// `DeployService` are mounted, so a workflow server that is not a deploy
785/// target exposes no deploy attack surface at all.
786#[derive(Clone, Debug, Default, Deserialize)]
787#[serde(default, deny_unknown_fields)]
788pub struct DeployConfig {
789    /// Whether the deploy surface is mounted. Defaults to false.
790    pub enabled: bool,
791    /// Upload-size ceiling for `.aion` archives, in bytes. Defaults to
792    /// [`DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES`] when omitted and `enabled = true`,
793    /// so turning deploy on does not force sizing a security ceiling; the
794    /// operator overrides it for their packages.
795    ///
796    /// [`DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES`]: super::DEFAULT_DEPLOY_MAX_ARCHIVE_BYTES
797    pub max_archive_bytes: Option<u64>,
798    /// Inflate ceiling for uploaded archive contents, in bytes: the total
799    /// decompressed size of all archive entries an upload may extract to
800    /// (DEFLATE bombs inflate ~1000:1 past `max_archive_bytes`). Defaults to
801    /// [`DEFAULT_DEPLOY_MAX_INFLATED_BYTES`] when omitted and `enabled = true`;
802    /// must be at least `max_archive_bytes`.
803    ///
804    /// [`DEFAULT_DEPLOY_MAX_INFLATED_BYTES`]: super::DEFAULT_DEPLOY_MAX_INFLATED_BYTES
805    pub max_inflated_bytes: Option<u64>,
806}
807
808/// Local dev-server surface settings from `[dev]`.
809///
810/// The dev surface is dark by default, gated on `enabled`: with it false (the
811/// section absent or `enabled = false`) the `/dev/*` routes are not mounted,
812/// the engine installs the bare production activity dispatcher (no mocking
813/// decorator), and nothing dev-specific is ever reachable. Setting `enabled =
814/// true` mounts the dev endpoints and installs the per-run activity-mock
815/// decorator — a development affordance, never on in production. It adds no
816/// arbitrary defaults (ADR-001): the only knob is the on/off gate.
817#[derive(Clone, Debug, Default, Deserialize)]
818#[serde(default, deny_unknown_fields)]
819pub struct DevConfig {
820    /// Whether the local dev-server surface is mounted. Defaults to false.
821    pub enabled: bool,
822}
823
824/// Durable-outbox fan-out dispatcher settings from `[outbox]`.
825///
826/// The outbox dispatcher is dark by default, gated on `enabled`: with it false
827/// (the section absent or `enabled = false`) the non-replayed background task
828/// that claims pending outbox rows and dispatches them to connected workers is
829/// never spawned, so default server behaviour is unchanged and the live
830/// workflow dispatch path is the only dispatch path. Setting `enabled = true`
831/// commissions the dispatcher; its operational knobs below — poll interval,
832/// claim batch size, retry budget, and the backoff curve — are pure tuning, so
833/// each resolves to a sane default when omitted rather than forcing the
834/// operator to hand-author tuning values just to turn the feature on. An
835/// explicitly set value (including a misconfigured `0`) is still validated.
836///
837/// Scope: this Phase-2 dispatcher dispatches claimed rows and marks each row's
838/// terminal outbox state (done / retry / failed). Routing the worker completion
839/// back into workflow history through the Recorder is Phase 3 and is not wired
840/// here; with the flag off there is no behavioural difference at all.
841#[derive(Clone, Debug, Default, Deserialize)]
842#[serde(default, deny_unknown_fields)]
843pub struct OutboxConfig {
844    /// Whether the outbox dispatcher background task is spawned. Defaults to
845    /// false, leaving the dispatcher dark and server behaviour unchanged.
846    pub enabled: bool,
847    /// Interval between successive claim sweeps, in milliseconds. Defaults to
848    /// [`DEFAULT_OUTBOX_POLL_INTERVAL_MS`] when omitted and `enabled = true`;
849    /// override to size the poll cadence for fan-out volume and latency budget.
850    ///
851    /// [`DEFAULT_OUTBOX_POLL_INTERVAL_MS`]: super::DEFAULT_OUTBOX_POLL_INTERVAL_MS
852    pub poll_interval_ms: Option<u64>,
853    /// Maximum number of pending rows claimed per sweep. Defaults to
854    /// [`DEFAULT_OUTBOX_BATCH_SIZE`] when omitted and `enabled = true`.
855    ///
856    /// [`DEFAULT_OUTBOX_BATCH_SIZE`]: super::DEFAULT_OUTBOX_BATCH_SIZE
857    pub batch_size: Option<u32>,
858    /// Dispatch attempts before a row is dead-lettered to `failed`. Defaults to
859    /// [`DEFAULT_OUTBOX_MAX_ATTEMPTS`] when omitted and `enabled = true`. Must
860    /// be at least one.
861    ///
862    /// [`DEFAULT_OUTBOX_MAX_ATTEMPTS`]: super::DEFAULT_OUTBOX_MAX_ATTEMPTS
863    pub max_attempts: Option<u32>,
864    /// Base retry backoff applied to the first retry, in milliseconds. Defaults
865    /// to [`DEFAULT_OUTBOX_BACKOFF_BASE_MS`] when omitted and `enabled = true`.
866    /// Successive retries multiply this by `backoff_multiplier` raised to the
867    /// prior-attempt count, capped at `backoff_max_ms`.
868    ///
869    /// [`DEFAULT_OUTBOX_BACKOFF_BASE_MS`]: super::DEFAULT_OUTBOX_BACKOFF_BASE_MS
870    pub backoff_base_ms: Option<u64>,
871    /// Geometric growth factor applied to the backoff per prior attempt.
872    /// Defaults to [`DEFAULT_OUTBOX_BACKOFF_MULTIPLIER`] when omitted and
873    /// `enabled = true`. Must be at least one so backoff never shrinks.
874    ///
875    /// [`DEFAULT_OUTBOX_BACKOFF_MULTIPLIER`]: super::DEFAULT_OUTBOX_BACKOFF_MULTIPLIER
876    pub backoff_multiplier: Option<u32>,
877    /// Upper bound on a single retry's backoff, in milliseconds. Defaults to
878    /// [`DEFAULT_OUTBOX_BACKOFF_MAX_MS`] when omitted and `enabled = true`. Must
879    /// be at least `backoff_base_ms`.
880    ///
881    /// [`DEFAULT_OUTBOX_BACKOFF_MAX_MS`]: super::DEFAULT_OUTBOX_BACKOFF_MAX_MS
882    pub backoff_max_ms: Option<u64>,
883    /// Interval between live stale-claim reconciliation sweeps, in milliseconds. When both
884    /// reconciliation knobs are absent the live sweep remains dark; setting either knob opts into
885    /// reconciliation and requires both values to be positive.
886    pub reconcile_interval_ms: Option<u64>,
887    /// Age after which a durable `claimed` outbox row is considered stranded, in milliseconds. The
888    /// reconciler re-arms only rows with `claimed_at` older than this threshold, preserving their
889    /// attempt count.
890    pub reconcile_stale_after_ms: Option<u64>,
891    /// Wire transport the dispatcher uses to place a claimed row with a worker.
892    /// Defaults to [`OutboxTransport::Liminal`] whenever the `liminal-transport`
893    /// Cargo feature is compiled (the default ablative-stack build), so an
894    /// outbox-enabled server uses the liminal cross-node transport out of the box;
895    /// a slim build without that feature defaults to [`OutboxTransport::Grpc`].
896    /// Selecting `liminal` in a build without the feature is a configuration error
897    /// surfaced at spawn. The transport only matters when `outbox.enabled = true`.
898    pub transport: OutboxTransport,
899    /// Address (`host:port`) the aion-server LISTENS on for inbound liminal
900    /// worker connections, used only when `transport = liminal`. REQUIRED in that
901    /// mode; ignored otherwise.
902    ///
903    /// The aion-server HOSTS the liminal listener: a remote `LiminalActivityWorker`
904    /// connects IN to this address and self-registers in-band, so the server's
905    /// [`ConnectionSupervisor`](liminal_server::server::connection::ConnectionSupervisor)
906    /// owns the worker's connection and can push a dispatch out on it
907    /// (`push_to_connection`). This replaces the superseded 13-0 spike's
908    /// client-connect address: the dispatcher no longer *connects out* to publish
909    /// to a channel — it pushes to a connected worker the server already owns.
910    ///
911    /// The dispatch *channel* is not configured here: it is derived per-row from
912    /// each row's durable `(namespace, task_queue)` via `dispatch_channel_name`
913    /// (NSTQ-5), so one listener fans different worker pools out by selection.
914    pub liminal_listen_address: Option<String>,
915    /// Outbound buffer, in bytes, that liminal builds for EACH worker connection
916    /// the server hosts — the largest single dispatch frame the server can push
917    /// to a worker. Used only when `transport = liminal`.
918    ///
919    /// Absent means liminal's own default (4 MiB), read from the dependency
920    /// rather than restated here. Zero is refused. A dispatch whose encoded
921    /// frame is larger than this bound fails its activity BY NAME on the first
922    /// observation and is never retried (`ServerError::WorkerDispatchUnservable`),
923    /// because no amount of draining can make room for a frame bigger than the
924    /// whole buffer. Raising it costs that much memory per connection at worst;
925    /// raise it knowing every connection may hold that much at once.
926    pub liminal_max_connection_outbound_bytes: Option<u64>,
927}
928
929/// Wire transport selected for outbox dispatch.
930///
931/// `liminal` (the default whenever the `liminal-transport` feature is compiled —
932/// which it is in the default ablative-stack build) routes the dispatch over the
933/// liminal cross-node bus. `grpc` keeps the connected-worker registry path. A
934/// slim build compiled WITHOUT `liminal-transport` falls back to `grpc` as the
935/// default so the default is always constructible under the active feature set.
936#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize)]
937#[serde(rename_all = "lowercase")]
938pub enum OutboxTransport {
939    /// Dispatch over the in-process connected-worker gRPC registry. The default
940    /// in a slim build compiled WITHOUT `liminal-transport`, which cannot
941    /// construct the liminal path.
942    // The default variant is feature-selected: gRPC when the liminal transport is
943    // absent (it is the only constructible path), liminal otherwise.
944    #[cfg_attr(not(feature = "liminal-transport"), default)]
945    Grpc,
946    /// Dispatch over the liminal cross-node bus (requires `liminal-transport`).
947    /// The out-of-box default whenever `liminal-transport` is compiled (the
948    /// default ablative-stack feature set), so an outbox-enabled server uses the
949    /// ablative messaging transport without extra configuration.
950    #[cfg_attr(feature = "liminal-transport", default)]
951    Liminal,
952}
953
954/// Server-side authoring settings from `[authoring]`.
955///
956/// The AWL studio is on by default, rooted at `<AION_HOME>/authoring`, so a
957/// stock `aion server` provides its document, layout, check, deploy, revision,
958/// run, and scaffold surfaces without preliminary configuration. Operators can
959/// override it explicitly.
960///
961/// Only the separate Gleam authoring loop remains dark by default: without
962/// `gleam_path`, `/authoring/*` is not mounted and nothing invokes `gleam`
963/// (CN7). Setting `gleam_path` commissions that loop and makes `project_root`
964/// required.
965#[derive(Clone, Debug, Default, Deserialize)]
966#[serde(default, deny_unknown_fields)]
967pub struct AuthoringConfig {
968    /// Path to the external `gleam` binary the toolchain spawns. `None`
969    /// (the default) leaves only the Gleam authoring loop dark; setting it gates
970    /// the `/authoring/*` endpoints on. There is no default binary — the
971    /// operator names it explicitly.
972    pub gleam_path: Option<PathBuf>,
973    /// Built Gleam workflow project root submitted source is written into and
974    /// packaged from. REQUIRED when `gleam_path` is set; no default (house
975    /// rule) — a Gleam project needs `gleam.toml`, the `aion_flow` dependency,
976    /// `workflow.toml`, and `schemas/`, so the operator provisions and names
977    /// the project root.
978    pub project_root: Option<PathBuf>,
979    /// Root directory exposed by the full AWL studio surface. The merged server
980    /// loader defaults this to `<AION_HOME>/authoring` after config and
981    /// environment overlays. `authoring.workspace_dir` or
982    /// `AION_AUTHORING_WORKSPACE_DIR` therefore overrides it explicitly.
983    pub workspace_dir: Option<PathBuf>,
984}
985
986impl Default for ServerSection {
987    fn default() -> Self {
988        Self {
989            listen_address: DEFAULT_HTTP_ADDRESS,
990            grpc_address: DEFAULT_GRPC_ADDRESS,
991            cors_allowed_origins: Vec::new(),
992        }
993    }
994}
995
996impl Default for StoreConfig {
997    fn default() -> Self {
998        Self {
999            // The ablative stack is the out-of-box durable default: an empty
1000            // config selects the haematite backend. The merged loader fills
1001            // `<AION_HOME>/data`; `memory` remains an explicit dev choice.
1002            backend: StoreBackend::Haematite,
1003            retired_input: None,
1004            owned_shards: Vec::new(),
1005            data_dir: None,
1006            // NOT a default: `None` is "the operator has not ruled". An
1007            // embedder-built haematite config is refused at boot over it; a
1008            // config FILE gets the declared default minted visibly by the
1009            // boot-side heal (`config::heal`).
1010            node_cache_budget: None,
1011            lock_acquisition_patience_ms: None,
1012            lock_acquisition_retry_cadence_ms: None,
1013            // 64, NOT 4096 (#187): raising the default to 4096 bricked every
1014            // fresh server — engine boot's scan_prefix materializes every
1015            // shard, then each haematite 0.4.0 commit fans out one thread +
1016            // fsync PER MATERIALIZED SHARD (~8k fsyncs/commit), blowing the
1017            // 5s shard-actor timeout on deploy/start/timers/outbox. Re-raise
1018            // only after haematite makes commit O(dirty shards) and the
1019            // scaffold e2es pass at the new default (see #187 fix plan).
1020            shard_count: 64,
1021            cluster: None,
1022        }
1023    }
1024}
1025
1026impl Default for RuntimeSection {
1027    fn default() -> Self {
1028        Self {
1029            scheduler_threads: 1,
1030            // Absent on purpose, for a different reason than the deadline
1031            // below: this one is deferred to beamr's own default rather than
1032            // demanded from the operator. The server must not name a
1033            // compilation threshold it did not choose.
1034            jit_threshold: None,
1035            // Filled by `fill_operational_defaults`, like the query deadline
1036            // below: pure tuning an operator should not have to hand-author,
1037            // and an explicit zero is still refused.
1038            workloop_sweep_interval_ms: None,
1039            stop_drain_timeout_ms: None,
1040            // Deliberately absent: validation fails loudly until the operator
1041            // sets the workflow query reply deadline for the deployment.
1042            query_timeout_ms: None,
1043        }
1044    }
1045}
1046
1047impl Default for DrainConfig {
1048    fn default() -> Self {
1049        Self {
1050            timeout_seconds: 30,
1051        }
1052    }
1053}
1054
1055impl Default for AuthConfig {
1056    fn default() -> Self {
1057        Self {
1058            enabled: false,
1059            jwks_url: None,
1060            jwks_refresh_seconds: 300,
1061        }
1062    }
1063}
1064
1065impl Default for MetricsConfig {
1066    fn default() -> Self {
1067        Self { enabled: true }
1068    }
1069}
1070
1071impl Default for NamespacesConfig {
1072    fn default() -> Self {
1073        Self {
1074            default: "default".to_owned(),
1075            auto_create: AutoCreate::default(),
1076            max_in_flight_activities: DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
1077        }
1078    }
1079}
1080
1081impl Default for ListenConfig {
1082    fn default() -> Self {
1083        Self {
1084            grpc: DEFAULT_GRPC_ADDRESS,
1085            http: DEFAULT_HTTP_ADDRESS,
1086        }
1087    }
1088}
1089
1090impl Default for OpsConsoleConfig {
1091    fn default() -> Self {
1092        Self {
1093            source: OpsConsoleAssetSource::Embedded,
1094        }
1095    }
1096}
1097
1098impl Default for NamespaceConfig {
1099    fn default() -> Self {
1100        Self {
1101            mode: NamespaceMode::SharedEngine,
1102        }
1103    }
1104}
1105
1106impl Default for WorkerConfig {
1107    fn default() -> Self {
1108        Self {
1109            heartbeat_window: Duration::from_secs(30),
1110            queue_service: crate::worker::queue_service::QueueServiceConfig::default(),
1111        }
1112    }
1113}
1114
1115impl Default for WebSocketConfig {
1116    fn default() -> Self {
1117        Self {
1118            outbound_buffer_bound: 32,
1119            // Deliberately absent: validation fails loudly until the operator
1120            // sizes the engine-global broadcast channel for the deployment.
1121            event_broadcast_capacity: None,
1122            // Deliberately absent for the same reason: the cluster channel has
1123            // no defined lag buffer until sized.
1124            cluster_broadcast_capacity: None,
1125        }
1126    }
1127}
1128
1129mod duration_millis {
1130    use std::time::Duration;
1131
1132    use serde::{Deserialize, Deserializer};
1133
1134    pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
1135    where
1136        D: Deserializer<'de>,
1137    {
1138        let millis = u64::deserialize(deserializer)?;
1139        Ok(Duration::from_millis(millis))
1140    }
1141}