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