Skip to main content

aion_server/config/
sections.rs

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