Skip to main content

liminal_server/config/
types.rs

1use std::net::SocketAddr;
2use std::path::PathBuf;
3use std::time::Duration;
4
5use crate::ServerError;
6
7/// Declarative configuration for the standalone liminal server wrapper.
8#[derive(Debug, Clone, serde::Deserialize)]
9#[serde(deny_unknown_fields)]
10pub struct ServerConfig {
11    /// Socket address where the standalone server will listen for client traffic.
12    pub listen_address: SocketAddr,
13    /// Socket address where the health endpoint server will listen for probes.
14    pub health_listen_address: SocketAddr,
15    /// Maximum time to allow existing connections to drain during graceful shutdown.
16    pub drain_timeout_ms: u64,
17    /// Channel topology definitions declared by the operator.
18    pub channels: Vec<ChannelDef>,
19    /// Declarative routing rules that connect configured channels.
20    pub routing_rules: Vec<RoutingRuleDef>,
21    /// Optional filesystem location for durable server state.
22    pub persistence_path: Option<PathBuf>,
23    /// Optional beamr distribution cluster membership configuration.
24    pub cluster: Option<ClusterConfig>,
25    /// Optional connection authentication configuration.
26    ///
27    /// When present, every client `Connect` handshake must carry a matching
28    /// `auth_token`; when absent the server is open (byte-identical to the
29    /// pre-auth behaviour). Not an ACL system — a single shared bearer token.
30    #[serde(default)]
31    pub auth: Option<AuthConfig>,
32    /// Service construction profile. Absent `[services]` (or an absent `profile`
33    /// key within it) defaults to `"full"`, so existing deployments build exactly
34    /// what they build today.
35    #[serde(default)]
36    pub services: ServicesConfig,
37    /// Operational bounds (§5). Absent `[limits]` (or any absent key within it)
38    /// defaults to the certifying-pair-signed numbers, so an operator who sets
39    /// nothing still runs bounded — "unlimited-by-silence is no longer a legal
40    /// state" (§5). Every value is a hard cap enforced by a typed refusal; a
41    /// zero (or otherwise invalid) value is a config validation error, never a
42    /// silent "unlimited".
43    #[serde(default)]
44    pub limits: LimitsConfig,
45}
46
47impl ServerConfig {
48    /// Returns the configured graceful-shutdown drain timeout.
49    #[must_use]
50    pub const fn drain_timeout(&self) -> Duration {
51        Duration::from_millis(self.drain_timeout_ms)
52    }
53}
54
55/// Declarative channel definition loaded from server configuration.
56#[derive(Debug, Clone, serde::Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct ChannelDef {
59    /// Unique channel name used by routing rules and operators.
60    pub name: String,
61    /// Filesystem path to a JSON Schema document that validates every message
62    /// published to this channel.
63    ///
64    /// The path is resolved relative to the directory containing the config file
65    /// (absolute paths are used verbatim). Config validation reads and parses the
66    /// referenced document and stores the result in [`Self::loaded_schema`]; a
67    /// missing file, an unreadable file, or a file that is not valid JSON is an
68    /// accumulated validation error that stops startup.
69    ///
70    /// `None` means the channel has no schema: it keeps the permissive empty
71    /// schema (`{}`) that accepts any JSON payload.
72    #[serde(default)]
73    pub schema_ref: Option<PathBuf>,
74    /// Whether this channel requires durable persistence.
75    pub durable: bool,
76    /// Schema document loaded and parsed from [`Self::schema_ref`] during config
77    /// validation. Populated only by [`crate::config::validate`]; a directly
78    /// constructed [`ChannelDef`] that skips validation carries `None` here and is
79    /// therefore built with the permissive empty schema regardless of
80    /// [`Self::schema_ref`]. Never deserialized from the config file.
81    #[serde(skip)]
82    pub loaded_schema: Option<LoadedSchema>,
83}
84
85/// A channel's JSON Schema document as loaded from disk during config validation.
86///
87/// Carries both the parsed document (fed to the validation engine when the channel
88/// is built) and the raw file bytes (hashed into the protocol schema id advertised
89/// at subscribe time, so an SDK deriving ids from the same schema bytes converges).
90#[derive(Debug, Clone)]
91pub struct LoadedSchema {
92    /// Raw bytes of the schema file, hashed to derive the protocol schema id.
93    pub bytes: Vec<u8>,
94    /// Parsed JSON Schema document, fed to the channel's validation engine.
95    pub document: serde_json::Value,
96}
97
98/// Declarative routing rule definition loaded from server configuration.
99#[derive(Debug, Clone, serde::Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct RoutingRuleDef {
102    /// Source channel name from which messages are routed.
103    pub source_channel: String,
104    /// Target channel name to which matching messages are routed.
105    pub target_channel: String,
106    /// Optional predicate expression that filters routed messages.
107    pub predicate: Option<String>,
108}
109
110/// Default beamr distribution handshake cookie, used when the operator does not
111/// configure one. Mirrors beamr's own [`beamr::distribution::DEFAULT_COOKIE`].
112pub const DEFAULT_COOKIE: &str = "beamr-cookie";
113
114/// Beamr distribution cluster configuration for standalone deployment.
115#[derive(Debug, Clone, serde::Deserialize)]
116#[serde(deny_unknown_fields)]
117pub struct ClusterConfig {
118    /// Unique node name advertised to the beamr distribution cluster.
119    pub node_name: String,
120    /// Socket address this node binds for inbound distribution links from peers.
121    ///
122    /// This is distinct from [`ServerConfig::listen_address`] (the client wire
123    /// port): a clustered node listens on two ports — one for clients, one for
124    /// peer distribution traffic.
125    pub listen_address: SocketAddr,
126    /// Seed node socket addresses used to join an existing cluster.
127    pub seed_nodes: Vec<SocketAddr>,
128    /// Shared distribution handshake cookie. Every node in a cluster MUST use the
129    /// same cookie or the OTP handshake is rejected. Defaults to
130    /// [`DEFAULT_COOKIE`] when omitted.
131    #[serde(default = "default_cookie")]
132    pub cookie: String,
133}
134
135fn default_cookie() -> String {
136    DEFAULT_COOKIE.to_owned()
137}
138
139/// Connection authentication configuration.
140///
141/// A single shared bearer token compared (constant-time) against the `auth_token`
142/// carried on every client `Connect` handshake. This is the table-stakes access
143/// gate, not an ACL system: one token grants full access, its absence (no `[auth]`
144/// section) leaves the server open.
145#[derive(Debug, Clone, serde::Deserialize)]
146#[serde(deny_unknown_fields)]
147pub struct AuthConfig {
148    /// Shared secret token a client must present in its `Connect` handshake. Must
149    /// be non-empty when the `[auth]` section is present (an empty token is a
150    /// config validation error, since it would gate nothing).
151    pub token: String,
152}
153
154/// Service construction profile selection (D2).
155///
156/// The `profile` value is carried as a raw string here rather than a typed enum so
157/// an unrecognised value is a config *validation* error with a helpful message
158/// (via [`Self::profile`]) rather than an opaque deserialization failure — matching
159/// how every other semantic config check surfaces. Absent `profile` defaults to
160/// `"full"`.
161#[derive(Debug, Clone, serde::Deserialize)]
162#[serde(deny_unknown_fields)]
163pub struct ServicesConfig {
164    /// Construction profile: `"full"` (the default, unchanged behaviour) or
165    /// `"worker-front-door"` (capability-scoped worker deployments).
166    #[serde(default = "default_service_profile")]
167    pub profile: String,
168}
169
170impl Default for ServicesConfig {
171    fn default() -> Self {
172        Self {
173            profile: default_service_profile(),
174        }
175    }
176}
177
178impl ServicesConfig {
179    /// Resolves the raw `profile` string into a typed [`ServiceProfile`].
180    ///
181    /// # Errors
182    /// Returns [`ServerError::ConfigValidation`] when the value is not a recognised
183    /// profile.
184    pub fn profile(&self) -> Result<ServiceProfile, ServerError> {
185        ServiceProfile::parse(&self.profile)
186    }
187}
188
189fn default_service_profile() -> String {
190    ServiceProfile::FULL.to_owned()
191}
192
193/// Operational bounds (§5, scout Q4 — rule-2 items).
194///
195/// Each field is a hard per-scope cap with a typed refusal and a
196/// certifying-pair-signed default (the numbers below are §5's). The struct is
197/// the single wire surface for `[limits]`; [`LimitsConfig::validate`] rejects any
198/// zero value as a typed config error (a zero cap would gate nothing — the exact
199/// unlimited-by-silence state §5 outlaws). Defaults come from the `default_*`
200/// free functions so an absent key resolves to the signed number, not zero.
201#[derive(Debug, Clone, Copy, serde::Deserialize)]
202#[serde(deny_unknown_fields)]
203pub struct LimitsConfig {
204    /// Total live connections the listener admits before refusing (§5: 256 — a
205    /// worker-bus, an order of magnitude above any observed fleet).
206    #[serde(default = "default_max_connections")]
207    pub max_connections: usize,
208    /// Subscriptions one connection may hold (§5: 32).
209    #[serde(default = "default_max_subscriptions_per_connection")]
210    pub max_subscriptions_per_connection: usize,
211    /// Open conversations one connection may hold (§5: 32).
212    #[serde(default = "default_max_conversations_per_connection")]
213    pub max_conversations_per_connection: usize,
214    /// In-flight server→client correlated pushes per connection (§5: 32).
215    #[serde(default = "default_max_pending_pushes_per_connection")]
216    pub max_pending_pushes_per_connection: usize,
217    /// Entries in the per-connection pending-reply table (§1.2(3b)/§5: 32 —
218    /// distinct from server-push slots).
219    #[serde(default = "default_max_pending_conversation_replies_per_connection")]
220    pub max_pending_conversation_replies_per_connection: usize,
221    /// Per-conversation sub-cap that confines tombstone ambiguity to its own
222    /// conversation (§1.2(3b)/§5: 8). Pending entries count against BOTH this and
223    /// the connection table; tombstones against THIS alone.
224    #[serde(default = "default_max_pending_replies_per_conversation")]
225    pub max_pending_replies_per_conversation: usize,
226    /// One shared inbox-byte budget per connection, spent across ALL its
227    /// subscription inboxes (§5: 4 MiB — deliberately mirroring the outbound 4 MiB
228    /// bound). Accounting unit: serialized envelope bytes as admitted, charged at
229    /// enqueue and released at dequeue.
230    #[serde(default = "default_max_connection_inbox_bytes")]
231    pub max_connection_inbox_bytes: usize,
232    /// Per-inbox envelope-count secondary fairness trip (§5: 256) — stops one
233    /// subscription starving its siblings inside the shared byte budget; no longer
234    /// load-bearing for the signed bound.
235    #[serde(default = "default_max_subscription_inbox_depth")]
236    pub max_subscription_inbox_depth: usize,
237}
238
239impl LimitsConfig {
240    /// §5 default: total live connections before the listener refuses.
241    pub const DEFAULT_MAX_CONNECTIONS: usize = 256;
242    /// §5 default: subscriptions per connection.
243    pub const DEFAULT_MAX_SUBSCRIPTIONS_PER_CONNECTION: usize = 32;
244    /// §5 default: open conversations per connection.
245    pub const DEFAULT_MAX_CONVERSATIONS_PER_CONNECTION: usize = 32;
246    /// §5 default: in-flight server pushes per connection.
247    pub const DEFAULT_MAX_PENDING_PUSHES_PER_CONNECTION: usize = 32;
248    /// §5 default: pending-reply table entries per connection.
249    pub const DEFAULT_MAX_PENDING_CONVERSATION_REPLIES_PER_CONNECTION: usize = 32;
250    /// §5 default: per-conversation pending-reply sub-cap.
251    pub const DEFAULT_MAX_PENDING_REPLIES_PER_CONVERSATION: usize = 8;
252    /// §5 default: shared per-connection inbox byte budget (4 MiB).
253    pub const DEFAULT_MAX_CONNECTION_INBOX_BYTES: usize = 4 * 1024 * 1024;
254    /// §5 default: per-inbox envelope-count fairness trip.
255    pub const DEFAULT_MAX_SUBSCRIPTION_INBOX_DEPTH: usize = 256;
256
257    /// Validates the caps: every value must be non-zero (a zero cap gates nothing
258    /// — the unlimited-by-silence state §5 outlaws). Errors are accumulated into
259    /// `errors` (one per offending field) so an operator sees every bad cap at
260    /// once, matching the rest of config validation.
261    pub(crate) fn collect_errors(&self, errors: &mut Vec<String>) {
262        let checks: [(&str, usize); 8] = [
263            ("max_connections", self.max_connections),
264            (
265                "max_subscriptions_per_connection",
266                self.max_subscriptions_per_connection,
267            ),
268            (
269                "max_conversations_per_connection",
270                self.max_conversations_per_connection,
271            ),
272            (
273                "max_pending_pushes_per_connection",
274                self.max_pending_pushes_per_connection,
275            ),
276            (
277                "max_pending_conversation_replies_per_connection",
278                self.max_pending_conversation_replies_per_connection,
279            ),
280            (
281                "max_pending_replies_per_conversation",
282                self.max_pending_replies_per_conversation,
283            ),
284            (
285                "max_connection_inbox_bytes",
286                self.max_connection_inbox_bytes,
287            ),
288            (
289                "max_subscription_inbox_depth",
290                self.max_subscription_inbox_depth,
291            ),
292        ];
293        for (field, value) in checks {
294            if value == 0 {
295                errors.push(format!(
296                    "limits.{field}: must be greater than zero (a zero cap would be \
297                     unlimited-by-silence, which §5 forbids)"
298                ));
299            }
300        }
301    }
302}
303
304impl Default for LimitsConfig {
305    fn default() -> Self {
306        Self {
307            max_connections: default_max_connections(),
308            max_subscriptions_per_connection: default_max_subscriptions_per_connection(),
309            max_conversations_per_connection: default_max_conversations_per_connection(),
310            max_pending_pushes_per_connection: default_max_pending_pushes_per_connection(),
311            max_pending_conversation_replies_per_connection:
312                default_max_pending_conversation_replies_per_connection(),
313            max_pending_replies_per_conversation: default_max_pending_replies_per_conversation(),
314            max_connection_inbox_bytes: default_max_connection_inbox_bytes(),
315            max_subscription_inbox_depth: default_max_subscription_inbox_depth(),
316        }
317    }
318}
319
320const fn default_max_connections() -> usize {
321    LimitsConfig::DEFAULT_MAX_CONNECTIONS
322}
323const fn default_max_subscriptions_per_connection() -> usize {
324    LimitsConfig::DEFAULT_MAX_SUBSCRIPTIONS_PER_CONNECTION
325}
326const fn default_max_conversations_per_connection() -> usize {
327    LimitsConfig::DEFAULT_MAX_CONVERSATIONS_PER_CONNECTION
328}
329const fn default_max_pending_pushes_per_connection() -> usize {
330    LimitsConfig::DEFAULT_MAX_PENDING_PUSHES_PER_CONNECTION
331}
332const fn default_max_pending_conversation_replies_per_connection() -> usize {
333    LimitsConfig::DEFAULT_MAX_PENDING_CONVERSATION_REPLIES_PER_CONNECTION
334}
335const fn default_max_pending_replies_per_conversation() -> usize {
336    LimitsConfig::DEFAULT_MAX_PENDING_REPLIES_PER_CONVERSATION
337}
338const fn default_max_connection_inbox_bytes() -> usize {
339    LimitsConfig::DEFAULT_MAX_CONNECTION_INBOX_BYTES
340}
341const fn default_max_subscription_inbox_depth() -> usize {
342    LimitsConfig::DEFAULT_MAX_SUBSCRIPTION_INBOX_DEPTH
343}
344
345/// Which connection-services adapter the server constructs (D2).
346#[derive(Debug, Clone, Copy, PartialEq, Eq)]
347pub enum ServiceProfile {
348    /// Full channel/conversation/durability services — the default. Constructs the
349    /// haematite store, channel supervisor, conversation supervisor, and dedup
350    /// cache exactly as before.
351    Full,
352    /// Capability-scoped worker front door: the connection supervisor only, with no
353    /// channel/conversation/haematite machinery. Backs worker registration,
354    /// correlated push/reply, and notifier-consumed reserved publishes; ordinary
355    /// channel and conversation frames are rejected with a typed error frame.
356    WorkerFrontDoor,
357}
358
359impl ServiceProfile {
360    /// Config value selecting the full-service profile.
361    pub const FULL: &'static str = "full";
362    /// Config value selecting the worker-front-door profile.
363    pub const WORKER_FRONT_DOOR: &'static str = "worker-front-door";
364
365    /// Parses a `[services] profile` value into a typed profile.
366    ///
367    /// # Errors
368    /// Returns [`ServerError::ConfigValidation`] for any value other than
369    /// [`Self::FULL`] or [`Self::WORKER_FRONT_DOOR`].
370    pub fn parse(value: &str) -> Result<Self, ServerError> {
371        match value {
372            Self::FULL => Ok(Self::Full),
373            Self::WORKER_FRONT_DOOR => Ok(Self::WorkerFrontDoor),
374            other => Err(ServerError::ConfigValidation {
375                message: format!(
376                    "services.profile: unknown profile '{other}'; expected \"{}\" or \"{}\"",
377                    Self::FULL,
378                    Self::WORKER_FRONT_DOOR
379                ),
380            }),
381        }
382    }
383}