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