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 ///
23 /// A fresh boot surfaces `ConfigValidation` unless this path's PARENT
24 /// directory already exists: the haematite engine creates the store dir
25 /// exactly one level below a pre-existing parent it can fence (never
26 /// `create_dir_all` — deliberate upstream hardening). Create the parent;
27 /// the engine fences into it.
28 pub persistence_path: Option<PathBuf>,
29 /// Optional beamr distribution cluster membership configuration.
30 pub cluster: Option<ClusterConfig>,
31 /// Optional connection authentication configuration.
32 ///
33 /// When present, every client `Connect` handshake must carry a matching
34 /// `auth_token`; when absent the server is open (byte-identical to the
35 /// pre-auth behaviour). Not an ACL system — a single shared bearer token.
36 #[serde(default)]
37 pub auth: Option<AuthConfig>,
38 /// Service construction profile. Absent `[services]` (or an absent `profile`
39 /// key within it) defaults to `"full"`, so existing deployments build exactly
40 /// what they build today.
41 #[serde(default)]
42 pub services: ServicesConfig,
43 /// Operational bounds (§5). Absent `[limits]` (or any absent key within it)
44 /// defaults to the certifying-pair-signed numbers, so an operator who sets
45 /// nothing still runs bounded — "unlimited-by-silence is no longer a legal
46 /// state" (§5). Every value is a hard cap enforced by a typed refusal; a
47 /// zero (or otherwise invalid) value is a config validation error, never a
48 /// silent "unlimited".
49 #[serde(default)]
50 pub limits: LimitsConfig,
51 /// Optional WebSocket transport acceptor (LP-WS-TRANSPORT R1).
52 ///
53 /// When present the server binds a sibling WebSocket listener carrying the
54 /// canonical liminal wire protocol (one binary message per canonical frame)
55 /// alongside the main TCP listener. When absent NO HTTP/WebSocket listener
56 /// is started and the server behaves byte-identically to the pre-WebSocket
57 /// build. Every field inside is a deployment decision; the origin allow-list
58 /// FAILS CLOSED (an absent or empty list refuses every Origin-bearing
59 /// upgrade) and the keepalive ping interval is disabled unless explicitly
60 /// configured.
61 #[serde(default)]
62 pub websocket: Option<WebSocketConfig>,
63 /// Participant lifecycle activation (LP gap closure, Part B).
64 ///
65 /// When present the server installs the production participant semantic
66 /// handler and advertises the participant capability bit on every
67 /// connection. Every field inside is REQUIRED and carries NO default:
68 /// participant lifecycle values are deployment decisions, and an absent
69 /// field is a typed startup error rather than an assumed number. When the
70 /// section is absent the participant capability stays disabled and the
71 /// server behaves byte-identically to the pre-activation build.
72 #[serde(default)]
73 pub participant: Option<ParticipantConfig>,
74}
75
76impl ServerConfig {
77 /// Returns the configured graceful-shutdown drain timeout.
78 #[must_use]
79 pub const fn drain_timeout(&self) -> Duration {
80 Duration::from_millis(self.drain_timeout_ms)
81 }
82}
83
84/// Declarative channel definition loaded from server configuration.
85#[derive(Debug, Clone, serde::Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct ChannelDef {
88 /// Unique channel name used by routing rules and operators.
89 pub name: String,
90 /// Filesystem path to a JSON Schema document that validates every message
91 /// published to this channel.
92 ///
93 /// The path is resolved relative to the directory containing the config file
94 /// (absolute paths are used verbatim). Config validation reads and parses the
95 /// referenced document and stores the result in [`Self::loaded_schema`]; a
96 /// missing file, an unreadable file, or a file that is not valid JSON is an
97 /// accumulated validation error that stops startup.
98 ///
99 /// `None` means the channel has no schema: it keeps the permissive empty
100 /// schema (`{}`) that accepts any JSON payload.
101 #[serde(default)]
102 pub schema_ref: Option<PathBuf>,
103 /// Whether this channel requires durable persistence.
104 pub durable: bool,
105 /// Schema document loaded and parsed from [`Self::schema_ref`] during config
106 /// validation. Populated only by [`crate::config::validate`]; a directly
107 /// constructed [`ChannelDef`] that skips validation carries `None` here and is
108 /// therefore built with the permissive empty schema regardless of
109 /// [`Self::schema_ref`]. Never deserialized from the config file.
110 #[serde(skip)]
111 pub loaded_schema: Option<LoadedSchema>,
112}
113
114/// A channel's JSON Schema document as loaded from disk during config validation.
115///
116/// Carries both the parsed document (fed to the validation engine when the channel
117/// is built) and the raw file bytes (hashed into the protocol schema id advertised
118/// at subscribe time, so an SDK deriving ids from the same schema bytes converges).
119#[derive(Debug, Clone)]
120pub struct LoadedSchema {
121 /// Raw bytes of the schema file, hashed to derive the protocol schema id.
122 pub bytes: Vec<u8>,
123 /// Parsed JSON Schema document, fed to the channel's validation engine.
124 pub document: serde_json::Value,
125}
126
127/// Declarative routing rule definition loaded from server configuration.
128#[derive(Debug, Clone, serde::Deserialize)]
129#[serde(deny_unknown_fields)]
130pub struct RoutingRuleDef {
131 /// Source channel name from which messages are routed.
132 pub source_channel: String,
133 /// Target channel name to which matching messages are routed.
134 pub target_channel: String,
135 /// Optional predicate expression that filters routed messages.
136 pub predicate: Option<String>,
137}
138
139/// Default beamr distribution handshake cookie, used when the operator does not
140/// configure one. Mirrors beamr's own [`beamr::distribution::DEFAULT_COOKIE`].
141pub const DEFAULT_COOKIE: &str = "beamr-cookie";
142
143/// Beamr distribution cluster configuration for standalone deployment.
144#[derive(Debug, Clone, serde::Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct ClusterConfig {
147 /// Unique node name advertised to the beamr distribution cluster.
148 pub node_name: String,
149 /// Socket address this node binds for inbound distribution links from peers.
150 ///
151 /// This is distinct from [`ServerConfig::listen_address`] (the client wire
152 /// port): a clustered node listens on two ports — one for clients, one for
153 /// peer distribution traffic.
154 pub listen_address: SocketAddr,
155 /// Seed node socket addresses used to join an existing cluster.
156 pub seed_nodes: Vec<SocketAddr>,
157 /// Shared distribution handshake cookie. Every node in a cluster MUST use the
158 /// same cookie or the OTP handshake is rejected. Defaults to
159 /// [`DEFAULT_COOKIE`] when omitted.
160 #[serde(default = "default_cookie")]
161 pub cookie: String,
162}
163
164fn default_cookie() -> String {
165 DEFAULT_COOKIE.to_owned()
166}
167
168/// Connection authentication configuration.
169///
170/// A single shared bearer token compared (constant-time) against the `auth_token`
171/// carried on every client `Connect` handshake. This is the table-stakes access
172/// gate, not an ACL system: one token grants full access, its absence (no `[auth]`
173/// section) leaves the server open.
174#[derive(Debug, Clone, serde::Deserialize)]
175#[serde(deny_unknown_fields)]
176pub struct AuthConfig {
177 /// Shared secret token a client must present in its `Connect` handshake. Must
178 /// be non-empty when the `[auth]` section is present (an empty token is a
179 /// config validation error, since it would gate nothing).
180 pub token: String,
181}
182
183/// WebSocket transport acceptor configuration (`[websocket]`, LP-WS-TRANSPORT R1).
184///
185/// The sibling WebSocket route is an explicit opt-in: the section itself must be
186/// present for any HTTP/WebSocket listener to start, and inside it the listen
187/// address and the single exact upgrade path are required with no defaults.
188///
189/// The deployment TLS contract (tear ruling Q1) is raw `ws://` behind a named
190/// TLS-terminating proxy that owns public `wss://` and certificates; liminal
191/// grows no TLS stack. Origin validation nonetheless belongs to this acceptor:
192/// [`Self::allowed_origins`] is the explicit allow-list checked on every
193/// Origin-bearing upgrade, and there is NO default list — absent or empty
194/// configuration fails closed for browser-origin upgrades while a native client
195/// that sends no `Origin` header may still upgrade (F6).
196///
197/// OPERATOR NOTE — the same deployment contract covers the pre-upgrade window
198/// (domain-owner ruling, 2026-07-18): the fronting proxy must ALSO enforce
199/// pre-upgrade read timeouts, handshake concurrency limits, and connection
200/// rate limits. Between TCP accept and a completed WebSocket upgrade this
201/// listener does not count the socket against `[limits] max_connections` and
202/// applies no read deadline of its own (only the fixed request-head size
203/// bound), so a deployment that exposes this port without the named proxy is
204/// out of contract on untrusted networks. A named handshake read-deadline
205/// config plus an in-flight handshake cap derived from the configured
206/// `max_connections` value is the ledgered post-demo hardening.
207#[derive(Debug, Clone, serde::Deserialize)]
208#[serde(deny_unknown_fields)]
209pub struct WebSocketConfig {
210 /// Socket address the WebSocket acceptor binds. Required; distinct from the
211 /// main wire listener, the health listener, and any cluster listener.
212 pub listen_address: SocketAddr,
213 /// The single exact HTTP request path that accepts WebSocket upgrades.
214 /// Required; must start with `/`. Every other path — and every ordinary
215 /// HTTP request — receives a small fixed non-success response and closes.
216 pub path: String,
217 /// Explicit browser-origin allow-list checked on every Origin-bearing
218 /// upgrade (F6). Entries are compared byte-exact against the request's
219 /// serialized `Origin` header value (RFC 6454 ASCII serialization, e.g.
220 /// `https://app.example.com`). Absent or empty means NO browser origin is
221 /// accepted (fail closed); native clients sending no `Origin` header are
222 /// unaffected.
223 #[serde(default)]
224 pub allowed_origins: Vec<String>,
225 /// Q-A transport-liveness keepalive: the server-side WebSocket Ping
226 /// interval in milliseconds. This is a precise LAW-1 carve-out — liveness
227 /// pings never mint application events, never re-arm application state, and
228 /// never serve as a source of truth; failure detection remains the socket's
229 /// typed terminal events. The bound is one ping per interval per
230 /// connection, so the idle cost is `interval x connection-count`. Absent
231 /// means pings are DISABLED, accepting proxy-idle-disconnect churn as the
232 /// documented consequence. A configured zero is a validation error.
233 #[serde(default)]
234 pub ping_interval_ms: Option<u64>,
235}
236
237/// Service construction profile selection (D2).
238///
239/// The `profile` value is carried as a raw string here rather than a typed enum so
240/// an unrecognised value is a config *validation* error with a helpful message
241/// (via [`Self::profile`]) rather than an opaque deserialization failure — matching
242/// how every other semantic config check surfaces. Absent `profile` defaults to
243/// `"full"`.
244#[derive(Debug, Clone, serde::Deserialize)]
245#[serde(deny_unknown_fields)]
246pub struct ServicesConfig {
247 /// Construction profile: `"full"` (the default, unchanged behaviour) or
248 /// `"worker-front-door"` (capability-scoped worker deployments).
249 #[serde(default = "default_service_profile")]
250 pub profile: String,
251}
252
253impl Default for ServicesConfig {
254 fn default() -> Self {
255 Self {
256 profile: default_service_profile(),
257 }
258 }
259}
260
261impl ServicesConfig {
262 /// Resolves the raw `profile` string into a typed [`ServiceProfile`].
263 ///
264 /// # Errors
265 /// Returns [`ServerError::ConfigValidation`] when the value is not a recognised
266 /// profile.
267 pub fn profile(&self) -> Result<ServiceProfile, ServerError> {
268 ServiceProfile::parse(&self.profile)
269 }
270}
271
272fn default_service_profile() -> String {
273 ServiceProfile::FULL.to_owned()
274}
275
276/// Operational bounds (§5, scout Q4 — rule-2 items).
277///
278/// Each field is a hard per-scope cap with a typed refusal and a
279/// certifying-pair-signed default (the numbers below are §5's). The struct is
280/// the single wire surface for `[limits]`; [`LimitsConfig::validate`] rejects any
281/// zero value as a typed config error (a zero cap would gate nothing — the exact
282/// unlimited-by-silence state §5 outlaws). Defaults come from the `default_*`
283/// free functions so an absent key resolves to the signed number, not zero.
284#[derive(Debug, Clone, Copy, serde::Deserialize)]
285#[serde(deny_unknown_fields)]
286pub struct LimitsConfig {
287 /// Total live connections the listener admits before refusing (§5: 256 — a
288 /// worker-bus, an order of magnitude above any observed fleet).
289 #[serde(default = "default_max_connections")]
290 pub max_connections: usize,
291 /// Subscriptions one connection may hold (§5: 32).
292 #[serde(default = "default_max_subscriptions_per_connection")]
293 pub max_subscriptions_per_connection: usize,
294 /// Open conversations one connection may hold (§5: 32).
295 #[serde(default = "default_max_conversations_per_connection")]
296 pub max_conversations_per_connection: usize,
297 /// In-flight server→client correlated pushes per connection (§5: 32).
298 #[serde(default = "default_max_pending_pushes_per_connection")]
299 pub max_pending_pushes_per_connection: usize,
300 /// Entries in the per-connection pending-reply table (§1.2(3b)/§5: 32 —
301 /// distinct from server-push slots).
302 #[serde(default = "default_max_pending_conversation_replies_per_connection")]
303 pub max_pending_conversation_replies_per_connection: usize,
304 /// Per-conversation sub-cap that confines tombstone ambiguity to its own
305 /// conversation (§1.2(3b)/§5: 8). Pending entries count against BOTH this and
306 /// the connection table; tombstones against THIS alone.
307 #[serde(default = "default_max_pending_replies_per_conversation")]
308 pub max_pending_replies_per_conversation: usize,
309 /// One shared inbox-byte budget per connection, spent across ALL its
310 /// subscription inboxes (§5: 4 MiB — deliberately mirroring the outbound 4 MiB
311 /// bound). Accounting unit: serialized envelope bytes as admitted, charged at
312 /// enqueue and released at dequeue.
313 #[serde(default = "default_max_connection_inbox_bytes")]
314 pub max_connection_inbox_bytes: usize,
315 /// Per-inbox envelope-count secondary fairness trip — stops one subscription
316 /// starving its siblings inside the shared byte budget. See
317 /// [`LimitsConfig::DEFAULT_MAX_SUBSCRIPTION_INBOX_DEPTH`] for why the default
318 /// is what it is; the short version is that this cap is the CRUDE bound and
319 /// [`LimitsConfig::max_connection_inbox_bytes`] is the real one.
320 #[serde(default = "default_max_subscription_inbox_depth")]
321 pub max_subscription_inbox_depth: usize,
322 /// Per-slice cap on `Deliver` frames one connection may enqueue across all of
323 /// its subscriptions, before the scheduler moves on to its peers.
324 ///
325 /// Operator-visible because P0 #55 proved it decides whether a subscriber
326 /// behind a burst survives — but see
327 /// [`LimitsConfig::DEFAULT_DELIVERY_SLICE_BUDGET`] for why the DEFAULT is not
328 /// raised. Raising it trades one starvation for another, and the measurement
329 /// that showed it moves the outcome could not see the starvation it causes.
330 #[serde(default = "default_delivery_slice_budget")]
331 pub delivery_slice_budget: usize,
332 /// Runtime-registered channels this deployment admits.
333 ///
334 /// # Why this cap departs the uniform pattern
335 ///
336 /// Every other field here carries `#[serde(default = "…")]` naming a §5
337 /// constant, because each of those numbers is a signed §5 bound. **There is
338 /// no signed §5 bound for channel count**, and inventing one is barred: a
339 /// number nobody certified, presented in the same shape as eight numbers
340 /// somebody did, is a forged citation. So the type is `Option<usize>` and
341 /// the serde default is the ABSENCE itself (`None`), never a value —
342 /// `#[serde(default)]` here resolves a missing key to "no bound declared",
343 /// which is a different statement from any number.
344 ///
345 /// Nor may this be a `usize` with a large default: unbounded-by-default is
346 /// not a bound, it is the gap wearing a number.
347 ///
348 /// `None` refuses every runtime channel registration with a typed error
349 /// naming this key, so a deployment that wants runtime registration declares
350 /// its own bound and a deployment that never registers needs no config
351 /// change at all. The cap bounds RUNTIME-registered channels only:
352 /// `[[channels]]` entries are the bound the operator already wrote.
353 ///
354 /// `Some(0)` is a validation error like every other zero cap here — see
355 /// [`LimitsConfig::collect_errors`].
356 #[serde(default)]
357 pub max_channels: Option<usize>,
358}
359
360impl LimitsConfig {
361 /// §5 default: total live connections before the listener refuses.
362 pub const DEFAULT_MAX_CONNECTIONS: usize = 256;
363 /// §5 default: subscriptions per connection.
364 pub const DEFAULT_MAX_SUBSCRIPTIONS_PER_CONNECTION: usize = 32;
365 /// §5 default: open conversations per connection.
366 pub const DEFAULT_MAX_CONVERSATIONS_PER_CONNECTION: usize = 32;
367 /// §5 default: in-flight server pushes per connection.
368 pub const DEFAULT_MAX_PENDING_PUSHES_PER_CONNECTION: usize = 32;
369 /// §5 default: pending-reply table entries per connection.
370 pub const DEFAULT_MAX_PENDING_CONVERSATION_REPLIES_PER_CONNECTION: usize = 32;
371 /// §5 default: per-conversation pending-reply sub-cap.
372 pub const DEFAULT_MAX_PENDING_REPLIES_PER_CONVERSATION: usize = 8;
373 /// §5 default: shared per-connection inbox byte budget (4 MiB).
374 pub const DEFAULT_MAX_CONNECTION_INBOX_BYTES: usize = 4 * 1024 * 1024;
375 /// Default per-inbox envelope-count fairness trip.
376 ///
377 /// # Why 4096 and not the §5-era 256 (P0 #55)
378 ///
379 /// A subscription inbox is bounded TWICE: by
380 /// [`Self::DEFAULT_MAX_CONNECTION_INBOX_BYTES`] (4 MiB, shared across all of
381 /// one connection's inboxes) and by this envelope COUNT. Memory is the real
382 /// resource, so the byte budget is the bound that should bind and this one is
383 /// meant to be a fairness backstop behind it.
384 ///
385 /// Which one binds first is decided by record size. For a connection holding a
386 /// single subscription, the crossover — the record size at which the two caps
387 /// trip together — is `4 MiB / depth`:
388 ///
389 /// | depth | crossover record size | binds first for a 1 KiB record |
390 /// |-------|------------------------|--------------------------------|
391 /// | 256 | 4194304/256 = 16 KiB | the COUNT (at 256 KiB, 6% of 4 MiB) |
392 /// | 4096 | 4194304/4096 = 1 KiB | the BYTES (as intended) |
393 ///
394 /// At 256 the count cap therefore binds for every record smaller than 16 KiB —
395 /// which is nearly all real traffic — and it binds at a small fraction of the
396 /// memory the connection is actually permitted: at a 164-byte record, 256
397 /// envelopes is 41 KiB, ~1% of the 4 MiB budget. A replay burst is thousands of
398 /// SMALL records, which is precisely the shape that hits the crude bound while
399 /// the real bound sits untouched. That is the P0: a live subscriber shed for
400 /// exceeding a fairness trip it reached at 1% of its memory allowance.
401 ///
402 /// At 4096 the crossover falls to 1 KiB, so bytes bind for realistic records
403 /// and this cap returns to backstop duty.
404 ///
405 /// Honest bound on that arithmetic: the byte budget is per CONNECTION and this
406 /// cap is per SUBSCRIPTION, so with `S` subscriptions sharing one connection
407 /// the effective crossover is `4 MiB / S / depth`. The table above is the `S=1`
408 /// case. A connection holding many subscriptions of small records can still
409 /// reach the count cap first — which is exactly the sibling-starvation case
410 /// this cap exists to catch.
411 pub const DEFAULT_MAX_SUBSCRIPTION_INBOX_DEPTH: usize = 4096;
412 /// Default per-slice `Deliver` budget for one connection — the source of truth
413 /// for [`crate::config::LimitsConfig::delivery_slice_budget`] and for the
414 /// delivery pump's own `DELIVERY_SLICE_BUDGET`.
415 ///
416 /// # Why this is NOT raised (P0 #55)
417 ///
418 /// This is the cross-connection FAIRNESS bound: it caps how long one connection
419 /// may hold a shared scheduler thread before its peers get a turn. Raising it
420 /// makes a burst subscriber's own drain finish in fewer scheduling round trips,
421 /// and the P0 #55 2x2 measured exactly that — 0/192 boots lost a subscriber at
422 /// 256 against roughly half of them at 32.
423 ///
424 /// That experiment does not license raising the default. It ran two or three
425 /// connections, so the peer starvation a raise would CAUSE was unobservable BY
426 /// CONSTRUCTION: with no queue of waiting peers there is nothing for a longer
427 /// slice to delay. What was measured is that this knob moves the outcome, not
428 /// that moving it is safe.
429 ///
430 /// So the number stays 32 and becomes an operator DECISION instead: a
431 /// deployment that knows its connection count and its burst shape can raise it
432 /// deliberately. The default does not make that trade on anyone's behalf.
433 /// The P0's actual fix is [`Self::DEFAULT_MAX_SUBSCRIPTION_INBOX_DEPTH`].
434 pub const DEFAULT_DELIVERY_SLICE_BUDGET: usize = 32;
435
436 /// Validates the caps: every value must be non-zero (a zero cap gates nothing
437 /// — the unlimited-by-silence state §5 outlaws). Errors are accumulated into
438 /// `errors` (one per offending field) so an operator sees every bad cap at
439 /// once, matching the rest of config validation.
440 pub(crate) fn collect_errors(&self, errors: &mut Vec<String>) {
441 let checks: [(&str, usize); 9] = [
442 ("max_connections", self.max_connections),
443 (
444 "max_subscriptions_per_connection",
445 self.max_subscriptions_per_connection,
446 ),
447 (
448 "max_conversations_per_connection",
449 self.max_conversations_per_connection,
450 ),
451 (
452 "max_pending_pushes_per_connection",
453 self.max_pending_pushes_per_connection,
454 ),
455 (
456 "max_pending_conversation_replies_per_connection",
457 self.max_pending_conversation_replies_per_connection,
458 ),
459 (
460 "max_pending_replies_per_conversation",
461 self.max_pending_replies_per_conversation,
462 ),
463 (
464 "max_connection_inbox_bytes",
465 self.max_connection_inbox_bytes,
466 ),
467 (
468 "max_subscription_inbox_depth",
469 self.max_subscription_inbox_depth,
470 ),
471 // A zero here would make the delivery pump enqueue nothing on any
472 // slice — every subscription silently stalled forever, which is the
473 // unlimited-by-silence failure wearing the opposite sign.
474 ("delivery_slice_budget", self.delivery_slice_budget),
475 ];
476 for (field, value) in checks {
477 if value == 0 {
478 errors.push(zero_cap_error(field));
479 }
480 }
481 // `max_channels` is checked BESIDE the array rather than inside it,
482 // because it is the one optional cap and the array's element type has no
483 // way to say "absent". Forcing it in would need a stand-in value for
484 // `None` — a number standing where a declaration is not — and any
485 // stand-in would either invent a bound or make an undeclared cap look
486 // declared. So the shape stays honest and the RULE stays uniform: a
487 // declared zero is refused with the same message as the other eight.
488 //
489 // The parenthetical about unlimited-by-silence is the other eight caps'
490 // ground; for this one a zero would refuse every registration rather
491 // than admit every registration. The message is kept identical anyway:
492 // an operator reading nine cap errors should learn one rule — zero is
493 // not a legal cap value anywhere in this section — not two.
494 if self.max_channels == Some(0) {
495 errors.push(zero_cap_error("max_channels"));
496 }
497 }
498}
499
500/// The typed refusal for a zero-valued cap, shared by every field in `[limits]`
501/// so the wording cannot drift between them.
502fn zero_cap_error(field: &str) -> String {
503 format!(
504 "limits.{field}: must be greater than zero (a zero cap would be \
505 unlimited-by-silence, which §5 forbids)"
506 )
507}
508
509impl Default for LimitsConfig {
510 fn default() -> Self {
511 Self {
512 max_connections: default_max_connections(),
513 max_subscriptions_per_connection: default_max_subscriptions_per_connection(),
514 max_conversations_per_connection: default_max_conversations_per_connection(),
515 max_pending_pushes_per_connection: default_max_pending_pushes_per_connection(),
516 max_pending_conversation_replies_per_connection:
517 default_max_pending_conversation_replies_per_connection(),
518 max_pending_replies_per_conversation: default_max_pending_replies_per_conversation(),
519 max_connection_inbox_bytes: default_max_connection_inbox_bytes(),
520 max_subscription_inbox_depth: default_max_subscription_inbox_depth(),
521 delivery_slice_budget: default_delivery_slice_budget(),
522 // No signed bound exists for this cap, so the default is the
523 // absence of a declaration — never a number.
524 max_channels: None,
525 }
526 }
527}
528
529const fn default_max_connections() -> usize {
530 LimitsConfig::DEFAULT_MAX_CONNECTIONS
531}
532const fn default_max_subscriptions_per_connection() -> usize {
533 LimitsConfig::DEFAULT_MAX_SUBSCRIPTIONS_PER_CONNECTION
534}
535const fn default_max_conversations_per_connection() -> usize {
536 LimitsConfig::DEFAULT_MAX_CONVERSATIONS_PER_CONNECTION
537}
538const fn default_max_pending_pushes_per_connection() -> usize {
539 LimitsConfig::DEFAULT_MAX_PENDING_PUSHES_PER_CONNECTION
540}
541const fn default_max_pending_conversation_replies_per_connection() -> usize {
542 LimitsConfig::DEFAULT_MAX_PENDING_CONVERSATION_REPLIES_PER_CONNECTION
543}
544const fn default_max_pending_replies_per_conversation() -> usize {
545 LimitsConfig::DEFAULT_MAX_PENDING_REPLIES_PER_CONVERSATION
546}
547const fn default_max_connection_inbox_bytes() -> usize {
548 LimitsConfig::DEFAULT_MAX_CONNECTION_INBOX_BYTES
549}
550const fn default_max_subscription_inbox_depth() -> usize {
551 LimitsConfig::DEFAULT_MAX_SUBSCRIPTION_INBOX_DEPTH
552}
553const fn default_delivery_slice_budget() -> usize {
554 LimitsConfig::DEFAULT_DELIVERY_SLICE_BUDGET
555}
556
557/// Participant lifecycle configuration (`[participant]`).
558///
559/// Present iff the deployment activates the participant protocol. Every field
560/// is required — serde carries no defaults here, so a missing field fails
561/// config loading with a typed error naming the field, and
562/// [`ParticipantConfig::collect_errors`] rejects semantically impossible
563/// values during the same accumulated validation pass as the rest of the
564/// config. All values are deployment-owner decisions (no assumed defaults).
565///
566/// Every field here is consumed by the live production handler. Frontier and
567/// retention limits are required inputs; there are no deployment defaults.
568#[derive(Debug, Clone, Copy, serde::Deserialize)]
569#[serde(deny_unknown_fields)]
570pub struct ParticipantConfig {
571 /// Complete participant wire-frame limit (`WF`) negotiated with every
572 /// participant-capable connection. Must be at least the protocol's
573 /// minimum complete frame; enforced by the shared codec at service
574 /// construction and pre-checked during config validation.
575 pub wire_frame_limit: u64,
576 /// Secret-bearing attach/enrollment receipt lifetime in milliseconds.
577 pub attach_receipt_ttl_ms: u64,
578 /// Non-secret receipt-provenance lifetime in milliseconds. Must be at
579 /// least `attach_receipt_ttl_ms` (provenance explains the receipt and
580 /// cannot expire first).
581 pub receipt_provenance_ttl_ms: u64,
582 /// Reporting threshold for server-wide live secret-bearing receipts
583 /// (stage-8 shared scope `LiveReceiptServer`).
584 ///
585 /// Lane p0-39: this scope is NOT an admission gate. It is where an honest
586 /// third party would meet a number someone else's churn consumed, and no
587 /// configured refusal is tolerable there — retention is bounded by
588 /// `attach_receipt_ttl_ms` alone. This number is a TRIPWIRE: when
589 /// in-window occupancy reaches it, the server counts the observation and
590 /// warns on the rising edge. Nothing is ever refused because of it.
591 /// Setting it higher than the deployment's plausible steady state is the
592 /// point; a churn storm is what it is meant to disclose.
593 pub live_receipt_server_report_threshold: u64,
594 /// Per-participant WINDOW SIZE for live secret-bearing receipts (stage-8
595 /// scope `LiveReceiptParticipant`).
596 ///
597 /// Lane p0-39: this is a bound on retention, not a refusal threshold. At
598 /// a full window the participant's own oldest live receipt is displaced
599 /// and the new one lands, so a rotation can never be refused by the very
600 /// receipt it is about to end. Occupancy never exceeds this value.
601 pub max_live_attach_receipts_per_participant: u64,
602 /// Reporting threshold for server-wide retained non-secret provenance
603 /// fingerprints (stage-8 shared scope `ProvenanceServer`).
604 ///
605 /// Lane p0-39: a tripwire, never a gate — see
606 /// [`Self::live_receipt_server_report_threshold`]. Retention here is
607 /// bounded by `receipt_provenance_ttl_ms` alone.
608 ///
609 /// Board #37 (ruling 2026-08-12): a fingerprint occupies from the moment
610 /// the client proves it possesses the secret its receipt minted through
611 /// that receipt's own provenance deadline — not from the minting commit.
612 /// A receipt whose delivery was never observed classifies through its
613 /// window but is never counted here, so an enrol-and-crash cycle leaves
614 /// no provenance residue behind it.
615 pub receipt_provenance_server_report_threshold: u64,
616 /// Reporting threshold for per-conversation provenance fingerprints
617 /// (stage-8 shared scope `ProvenanceConversation`). A tripwire, never a
618 /// gate: one participant's churn must not refuse the next participant of
619 /// the same conversation.
620 pub receipt_provenance_per_conversation_report_threshold: u64,
621 /// Per-participant WINDOW SIZE for provenance fingerprints (stage-8 scope
622 /// `ProvenanceParticipant`).
623 ///
624 /// Lane p0-39: the participant's retained fingerprints — one per
625 /// committed rotation, plus its proven enrollment fingerprint — are held
626 /// to this many, oldest displaced first. Per-participant pressure is
627 /// self-inflicted, so the number bounds memory without refusing: the
628 /// (N+1)th honest fingerprint always lands, including on cold replay from
629 /// durable state. Occupancy never exceeds this value.
630 pub max_receipt_provenance_per_participant: u64,
631 /// Server-wide identity-slot limit (the contract's
632 /// `max_retired_identity_slots` server scope): the total number of
633 /// participant identities — live or retired — mintable across ALL
634 /// conversations. Enrollment refuses with server-scope
635 /// `IdentityCapacityExceeded` (tested BEFORE the conversation scope)
636 /// when every slot is reserved.
637 pub max_retired_identity_slots_server: u64,
638 /// Per-CONVERSATION identity limit `I` (the contract's half-open
639 /// `0..=I` bound on permanent participant ordinals — the conversation
640 /// scope of `max_retired_identity_slots`, NOT a per-participant
641 /// reservation). Enrollment assigns monotone participant indices in
642 /// `0..I` within one conversation and refuses with conversation-scope
643 /// `IdentityCapacityExceeded` when occupancy reaches this value; slots
644 /// and ids are never reused. The server-wide companion is
645 /// [`Self::max_retired_identity_slots_server`].
646 pub identity_slots: u64,
647 /// Maximum entries one observer-recovery handshake batch may name.
648 pub observer_recovery_max_entries: u64,
649 /// Semantic conversations one connection may track — the protocol's
650 /// signed connection-conversation limit. Consumed on BOTH of its contract
651 /// paths: the stage-6 capacity gate every conversation-scoped semantic
652 /// operation runs (register row 5641) and the observer-recovery batch
653 /// preflight (register row 5642), over one shared per-connection
654 /// dispatch map.
655 pub max_semantic_conversations_per_connection: u64,
656 /// Maximum canonical entries in one ordinary retained-record row.
657 pub max_ordinary_record_entries: u64,
658 /// Maximum canonical bytes in one ordinary retained-record row.
659 pub max_ordinary_record_bytes: u64,
660 /// Maximum canonical entries in one generated marker row.
661 pub max_generated_marker_entries: u64,
662 /// Maximum canonical bytes in one generated marker row.
663 pub max_generated_marker_bytes: u64,
664 /// Entry component of the mandatory transaction envelope `Q`.
665 pub mandatory_transaction_bound_entries: u64,
666 /// Byte component of the mandatory transaction envelope `Q`.
667 pub mandatory_transaction_bound_bytes: u64,
668 /// Entry component of the full recovery claim `K`.
669 pub full_recovery_claim_entries: u64,
670 /// Byte component of the full recovery claim `K`.
671 pub full_recovery_claim_bytes: u64,
672 /// Total retained durable entry capacity per conversation.
673 pub retained_capacity_entries: u64,
674 /// Total retained canonical-byte capacity per conversation.
675 pub retained_capacity_bytes: u64,
676 /// Maximum retained causal-record rows restored for one conversation.
677 pub max_retained_record_rows: u64,
678 /// Maximum closure churn cycles in one episode.
679 pub closure_episode_churn_limit: u64,
680}
681
682impl ParticipantConfig {
683 /// Accumulates semantic validation errors for the participant section.
684 ///
685 /// Zero is rejected wherever it would be unlimited-by-silence, gate
686 /// nothing, or violate a protocol precondition; the TTL ordering mirrors
687 /// the protocol's own frozen configuration precedence.
688 pub(crate) fn collect_errors(&self, errors: &mut Vec<String>) {
689 // The receipt/identity block follows the contract's frozen nine-field
690 // validation order: both TTLs, the five receipt/provenance caps, the
691 // server identity limit, then the conversation identity limit.
692 let nonzero: [(&str, u64); 23] = [
693 ("wire_frame_limit", self.wire_frame_limit),
694 ("attach_receipt_ttl_ms", self.attach_receipt_ttl_ms),
695 ("receipt_provenance_ttl_ms", self.receipt_provenance_ttl_ms),
696 (
697 "live_receipt_server_report_threshold",
698 self.live_receipt_server_report_threshold,
699 ),
700 (
701 "max_live_attach_receipts_per_participant",
702 self.max_live_attach_receipts_per_participant,
703 ),
704 (
705 "receipt_provenance_server_report_threshold",
706 self.receipt_provenance_server_report_threshold,
707 ),
708 (
709 "receipt_provenance_per_conversation_report_threshold",
710 self.receipt_provenance_per_conversation_report_threshold,
711 ),
712 (
713 "max_receipt_provenance_per_participant",
714 self.max_receipt_provenance_per_participant,
715 ),
716 (
717 "max_retired_identity_slots_server",
718 self.max_retired_identity_slots_server,
719 ),
720 ("identity_slots", self.identity_slots),
721 (
722 "observer_recovery_max_entries",
723 self.observer_recovery_max_entries,
724 ),
725 (
726 "max_semantic_conversations_per_connection",
727 self.max_semantic_conversations_per_connection,
728 ),
729 (
730 "max_ordinary_record_entries",
731 self.max_ordinary_record_entries,
732 ),
733 ("max_ordinary_record_bytes", self.max_ordinary_record_bytes),
734 (
735 "max_generated_marker_entries",
736 self.max_generated_marker_entries,
737 ),
738 (
739 "max_generated_marker_bytes",
740 self.max_generated_marker_bytes,
741 ),
742 (
743 "mandatory_transaction_bound_entries",
744 self.mandatory_transaction_bound_entries,
745 ),
746 (
747 "mandatory_transaction_bound_bytes",
748 self.mandatory_transaction_bound_bytes,
749 ),
750 (
751 "full_recovery_claim_entries",
752 self.full_recovery_claim_entries,
753 ),
754 ("full_recovery_claim_bytes", self.full_recovery_claim_bytes),
755 ("retained_capacity_entries", self.retained_capacity_entries),
756 ("retained_capacity_bytes", self.retained_capacity_bytes),
757 ("max_retained_record_rows", self.max_retained_record_rows),
758 ];
759 for (field, value) in nonzero {
760 if value == 0 {
761 errors.push(format!("participant.{field}: must be greater than zero"));
762 }
763 }
764 if self.receipt_provenance_ttl_ms < self.attach_receipt_ttl_ms {
765 errors.push(
766 "participant.receipt_provenance_ttl_ms: must be at least \
767 attach_receipt_ttl_ms (provenance cannot expire before the receipt it explains)"
768 .to_owned(),
769 );
770 }
771 if self.full_recovery_claim_entries != self.mandatory_transaction_bound_entries {
772 errors.push(
773 "participant.full_recovery_claim_entries: must equal \
774 mandatory_transaction_bound_entries"
775 .to_owned(),
776 );
777 }
778 if self.full_recovery_claim_bytes != self.mandatory_transaction_bound_bytes {
779 errors.push(
780 "participant.full_recovery_claim_bytes: must equal \
781 mandatory_transaction_bound_bytes"
782 .to_owned(),
783 );
784 }
785 if !(2..=u64::from(u32::MAX)).contains(&self.closure_episode_churn_limit) {
786 errors.push(
787 "participant.closure_episode_churn_limit: must be in 2..=u32::MAX".to_owned(),
788 );
789 }
790 self.collect_unit2_derived_errors(errors);
791 }
792
793 fn collect_unit2_derived_errors(&self, errors: &mut Vec<String>) {
794 if self
795 .max_retained_record_rows
796 .checked_mul(self.identity_slots)
797 .is_none()
798 {
799 errors.push("participant.UNIT2_MAX_LIVE_RECIPIENT_OBLIGATIONS: max_retained_record_rows * identity_slots overflows u64".to_owned());
800 }
801 }
802}
803
804/// Which connection-services adapter the server constructs (D2).
805#[derive(Debug, Clone, Copy, PartialEq, Eq)]
806pub enum ServiceProfile {
807 /// Full channel/conversation/durability services — the default. Constructs the
808 /// haematite store, channel supervisor, conversation supervisor, and dedup
809 /// cache exactly as before.
810 Full,
811 /// Capability-scoped worker front door: the connection supervisor only, with no
812 /// channel/conversation/haematite machinery. Backs worker registration,
813 /// correlated push/reply, and notifier-consumed reserved publishes; ordinary
814 /// channel and conversation frames are rejected with a typed error frame.
815 WorkerFrontDoor,
816}
817
818impl ServiceProfile {
819 /// Config value selecting the full-service profile.
820 pub const FULL: &'static str = "full";
821 /// Config value selecting the worker-front-door profile.
822 pub const WORKER_FRONT_DOOR: &'static str = "worker-front-door";
823
824 /// Parses a `[services] profile` value into a typed profile.
825 ///
826 /// # Errors
827 /// Returns [`ServerError::ConfigValidation`] for any value other than
828 /// [`Self::FULL`] or [`Self::WORKER_FRONT_DOOR`].
829 pub fn parse(value: &str) -> Result<Self, ServerError> {
830 match value {
831 Self::FULL => Ok(Self::Full),
832 Self::WORKER_FRONT_DOOR => Ok(Self::WorkerFrontDoor),
833 other => Err(ServerError::ConfigValidation {
834 message: format!(
835 "services.profile: unknown profile '{other}'; expected \"{}\" or \"{}\"",
836 Self::FULL,
837 Self::WORKER_FRONT_DOOR
838 ),
839 }),
840 }
841 }
842}