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}