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