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