Skip to main content

liminal_server/config/
types.rs

1use std::net::SocketAddr;
2use std::path::PathBuf;
3use std::time::Duration;
4
5use crate::ServerError;
6
7/// Declarative configuration for the standalone liminal server wrapper.
8#[derive(Debug, Clone, serde::Deserialize)]
9#[serde(deny_unknown_fields)]
10pub struct ServerConfig {
11    /// Socket address where the standalone server will listen for client traffic.
12    pub listen_address: SocketAddr,
13    /// Socket address where the health endpoint server will listen for probes.
14    pub health_listen_address: SocketAddr,
15    /// 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(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    /// Refuse every worker registration from a connection that proved no
206    /// holder (ACCESS-004 R3): a bearer connection, or one before any pass.
207    /// Off by default, so a worker that dials with the bearer, as aion's does,
208    /// keeps registering; turning it on is the operator's act, and it needs
209    /// `[auth.pass]`, without which no connection could prove a holder.
210    #[serde(default)]
211    pub require_verified_worker: bool,
212}
213
214/// The token is a credential: its `Debug` names its length only
215/// (ACCESS-004 R5).
216impl std::fmt::Debug for AuthConfig {
217    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218        formatter
219            .debug_struct("AuthConfig")
220            .field("token", &crate::auth_pass::ByteLength(self.token.len()))
221            .field("pass", &self.pass)
222            .field("require_verified_worker", &self.require_verified_worker)
223            .finish()
224    }
225}
226
227/// Registry pass verification configuration. Every field is required when present.
228#[derive(Debug, Clone, serde::Deserialize)]
229#[serde(deny_unknown_fields)]
230pub struct PassConfig {
231    /// Registry Ed25519 verifying key as exactly 64 hexadecimal characters.
232    pub registry_verifying_key: String,
233    /// Maximum admitted server-clock skew in seconds.
234    pub maximum_clock_skew_seconds: u64,
235}
236
237/// WebSocket transport acceptor configuration (`[websocket]`, LP-WS-TRANSPORT R1).
238///
239/// The sibling WebSocket route is an explicit opt-in: the section itself must be
240/// present for any HTTP/WebSocket listener to start, and inside it the listen
241/// address and the single exact upgrade path are required with no defaults.
242///
243/// The deployment TLS contract (tear ruling Q1) is raw `ws://` behind a named
244/// TLS-terminating proxy that owns public `wss://` and certificates; liminal
245/// grows no TLS stack. Origin validation nonetheless belongs to this acceptor:
246/// [`Self::allowed_origins`] is the explicit allow-list checked on every
247/// Origin-bearing upgrade, and there is NO default list — absent or empty
248/// configuration fails closed for browser-origin upgrades while a native client
249/// that sends no `Origin` header may still upgrade (F6).
250///
251/// OPERATOR NOTE — the same deployment contract covers the pre-upgrade window
252/// (domain-owner ruling, 2026-07-18): the fronting proxy must ALSO enforce
253/// pre-upgrade read timeouts, handshake concurrency limits, and connection
254/// rate limits. Between TCP accept and a completed WebSocket upgrade this
255/// listener does not count the socket against `[limits] max_connections` and
256/// applies no read deadline of its own (only the fixed request-head size
257/// bound), so a deployment that exposes this port without the named proxy is
258/// out of contract on untrusted networks. A named handshake read-deadline
259/// config plus an in-flight handshake cap derived from the configured
260/// `max_connections` value is the ledgered post-demo hardening.
261#[derive(Debug, Clone, serde::Deserialize)]
262#[serde(deny_unknown_fields)]
263pub struct WebSocketConfig {
264    /// Socket address the WebSocket acceptor binds. Required; distinct from the
265    /// main wire listener, the health listener, and any cluster listener.
266    pub listen_address: SocketAddr,
267    /// The single exact HTTP request path that accepts WebSocket upgrades.
268    /// Required; must start with `/`. Every other path — and every ordinary
269    /// HTTP request — receives a small fixed non-success response and closes.
270    pub path: String,
271    /// Explicit browser-origin allow-list checked on every Origin-bearing
272    /// upgrade (F6). Entries are compared byte-exact against the request's
273    /// serialized `Origin` header value (RFC 6454 ASCII serialization, e.g.
274    /// `https://app.example.com`). Absent or empty means NO browser origin is
275    /// accepted (fail closed); native clients sending no `Origin` header are
276    /// unaffected.
277    #[serde(default)]
278    pub allowed_origins: Vec<String>,
279    /// Q-A transport-liveness keepalive: the server-side WebSocket Ping
280    /// interval in milliseconds. This is a precise LAW-1 carve-out — liveness
281    /// pings never mint application events, never re-arm application state, and
282    /// never serve as a source of truth; failure detection remains the socket's
283    /// typed terminal events. The bound is one ping per interval per
284    /// connection, so the idle cost is `interval x connection-count`. Absent
285    /// means pings are DISABLED, accepting proxy-idle-disconnect churn as the
286    /// documented consequence. A configured zero is a validation error.
287    #[serde(default)]
288    pub ping_interval_ms: Option<u64>,
289}
290
291/// Service construction profile selection (D2).
292///
293/// The `profile` value is carried as a raw string here rather than a typed enum so
294/// an unrecognised value is a config *validation* error with a helpful message
295/// (via [`Self::profile`]) rather than an opaque deserialization failure — matching
296/// how every other semantic config check surfaces. Absent `profile` defaults to
297/// `"full"`.
298#[derive(Debug, Clone, serde::Deserialize)]
299#[serde(deny_unknown_fields)]
300pub struct ServicesConfig {
301    /// Construction profile: `"full"` (the default, unchanged behaviour) or
302    /// `"worker-front-door"` (capability-scoped worker deployments).
303    #[serde(default = "default_service_profile")]
304    pub profile: String,
305    /// Row bounds for the server-owned durable streams.
306    #[serde(default)]
307    pub self_streams: SelfStreamsConfig,
308}
309
310/// Retained row bounds for each server-owned durable stream.
311#[derive(Debug, Clone, Copy, serde::Deserialize)]
312#[serde(deny_unknown_fields)]
313pub struct SelfStreamsConfig {
314    /// Maximum retained cost records.
315    #[serde(default = "default_cost_rows")]
316    pub cost_rows: u64,
317    /// Maximum retained lifecycle and connection events.
318    #[serde(default = "default_events_rows")]
319    pub events_rows: u64,
320}
321
322impl Default for SelfStreamsConfig {
323    fn default() -> Self {
324        Self {
325            cost_rows: default_cost_rows(),
326            events_rows: default_events_rows(),
327        }
328    }
329}
330
331const fn default_cost_rows() -> u64 {
332    10_000
333}
334
335const fn default_events_rows() -> u64 {
336    100_000
337}
338
339impl Default for ServicesConfig {
340    fn default() -> Self {
341        Self {
342            profile: default_service_profile(),
343            self_streams: SelfStreamsConfig::default(),
344        }
345    }
346}
347
348impl ServicesConfig {
349    /// Resolves the raw `profile` string into a typed [`ServiceProfile`].
350    ///
351    /// # Errors
352    /// Returns [`ServerError::ConfigValidation`] when the value is not a recognised
353    /// profile.
354    pub fn profile(&self) -> Result<ServiceProfile, ServerError> {
355        ServiceProfile::parse(&self.profile)
356    }
357}
358
359fn default_service_profile() -> String {
360    ServiceProfile::FULL.to_owned()
361}
362
363/// Operational bounds (§5, scout Q4 — rule-2 items).
364///
365/// Each field is a hard per-scope cap with a typed refusal and a
366/// certifying-pair-signed default (the numbers below are §5's). The struct is
367/// the single wire surface for `[limits]`; [`LimitsConfig::validate`] rejects any
368/// zero value as a typed config error (a zero cap would gate nothing — the exact
369/// unlimited-by-silence state §5 outlaws). Defaults come from the `default_*`
370/// free functions so an absent key resolves to the signed number, not zero.
371#[derive(Debug, Clone, Copy, serde::Deserialize)]
372#[serde(deny_unknown_fields)]
373pub struct LimitsConfig {
374    /// Total live connections the listener admits before refusing (§5: 256 — a
375    /// worker-bus, an order of magnitude above any observed fleet).
376    #[serde(default = "default_max_connections")]
377    pub max_connections: usize,
378    /// Subscriptions one connection may hold (§5: 32).
379    #[serde(default = "default_max_subscriptions_per_connection")]
380    pub max_subscriptions_per_connection: usize,
381    /// Open conversations one connection may hold (§5: 32).
382    #[serde(default = "default_max_conversations_per_connection")]
383    pub max_conversations_per_connection: usize,
384    /// In-flight server→client correlated pushes per connection (§5: 32).
385    #[serde(default = "default_max_pending_pushes_per_connection")]
386    pub max_pending_pushes_per_connection: usize,
387    /// Entries in the per-connection pending-reply table (§1.2(3b)/§5: 32 —
388    /// distinct from server-push slots).
389    #[serde(default = "default_max_pending_conversation_replies_per_connection")]
390    pub max_pending_conversation_replies_per_connection: usize,
391    /// Per-conversation sub-cap that confines tombstone ambiguity to its own
392    /// conversation (§1.2(3b)/§5: 8). Pending entries count against BOTH this and
393    /// the connection table; tombstones against THIS alone.
394    #[serde(default = "default_max_pending_replies_per_conversation")]
395    pub max_pending_replies_per_conversation: usize,
396    /// One shared inbox-byte budget per connection, spent across ALL its
397    /// subscription inboxes (§5: 4 MiB — deliberately mirroring the outbound 4 MiB
398    /// bound). Accounting unit: serialized envelope bytes as admitted, charged at
399    /// enqueue and released at dequeue.
400    #[serde(default = "default_max_connection_inbox_bytes")]
401    pub max_connection_inbox_bytes: usize,
402    /// One outbound byte buffer per connection, holding every server-originated
403    /// frame (acks, errors, `Push`, `Deliver`, `Disconnect`, `Pong`) until the
404    /// connection's scheduler slice drains it to the socket.
405    ///
406    /// This is a DELIVERY bound, not a fairness one: a single frame larger than
407    /// the whole buffer can never be queued, so this value is the largest frame
408    /// the server can send on one connection. Raising it raises that ceiling at
409    /// the cost of per-connection memory; lowering it lowers the ceiling for
410    /// every connection at once.
411    #[serde(default = "default_max_connection_outbound_bytes")]
412    pub max_connection_outbound_bytes: usize,
413    /// Per-inbox envelope-count secondary fairness trip — stops one subscription
414    /// starving its siblings inside the shared byte budget. See
415    /// [`LimitsConfig::DEFAULT_MAX_SUBSCRIPTION_INBOX_DEPTH`] for why the default
416    /// is what it is; the short version is that this cap is the CRUDE bound and
417    /// [`LimitsConfig::max_connection_inbox_bytes`] is the real one.
418    #[serde(default = "default_max_subscription_inbox_depth")]
419    pub max_subscription_inbox_depth: usize,
420    /// Per-slice cap on `Deliver` frames one connection may enqueue across all of
421    /// its subscriptions, before the scheduler moves on to its peers.
422    ///
423    /// Operator-visible because P0 #55 proved it decides whether a subscriber
424    /// behind a burst survives — but see
425    /// [`LimitsConfig::DEFAULT_DELIVERY_SLICE_BUDGET`] for why the DEFAULT is not
426    /// raised. Raising it trades one starvation for another, and the measurement
427    /// that showed it moves the outcome could not see the starvation it causes.
428    #[serde(default = "default_delivery_slice_budget")]
429    pub delivery_slice_budget: usize,
430    /// Runtime-registered channels this deployment admits.
431    ///
432    /// # Why this cap departs the uniform pattern
433    ///
434    /// Every other field here carries `#[serde(default = "…")]` naming a §5
435    /// constant, because each of those numbers is a signed §5 bound. **There is
436    /// no signed §5 bound for channel count**, and inventing one is barred: a
437    /// number nobody certified, presented in the same shape as eight numbers
438    /// somebody did, is a forged citation. So the type is `Option<usize>` and
439    /// the serde default is the ABSENCE itself (`None`), never a value —
440    /// `#[serde(default)]` here resolves a missing key to "no bound declared",
441    /// which is a different statement from any number.
442    ///
443    /// Nor may this be a `usize` with a large default: unbounded-by-default is
444    /// not a bound, it is the gap wearing a number.
445    ///
446    /// `None` refuses every runtime channel registration with a typed error
447    /// naming this key, so a deployment that wants runtime registration declares
448    /// its own bound and a deployment that never registers needs no config
449    /// change at all. The cap bounds RUNTIME-registered channels only:
450    /// `[[channels]]` entries are the bound the operator already wrote.
451    ///
452    /// `Some(0)` is a validation error like every other zero cap here — see
453    /// [`LimitsConfig::collect_errors`].
454    #[serde(default)]
455    pub max_channels: Option<usize>,
456    /// How long a client's correlated conversation request waits for its reply
457    /// before the server answers it timed out, in milliseconds (default 5,000).
458    /// A zero would time every request out on its first slice.
459    #[serde(default = "default_reply_timeout_ms")]
460    pub reply_timeout_ms: u64,
461}
462
463impl LimitsConfig {
464    /// §5 default: total live connections before the listener refuses.
465    pub const DEFAULT_MAX_CONNECTIONS: usize = 256;
466    /// §5 default: subscriptions per connection.
467    pub const DEFAULT_MAX_SUBSCRIPTIONS_PER_CONNECTION: usize = 32;
468    /// §5 default: open conversations per connection.
469    pub const DEFAULT_MAX_CONVERSATIONS_PER_CONNECTION: usize = 32;
470    /// §5 default: in-flight server pushes per connection.
471    pub const DEFAULT_MAX_PENDING_PUSHES_PER_CONNECTION: usize = 32;
472    /// §5 default: pending-reply table entries per connection.
473    pub const DEFAULT_MAX_PENDING_CONVERSATION_REPLIES_PER_CONNECTION: usize = 32;
474    /// §5 default: per-conversation pending-reply sub-cap.
475    pub const DEFAULT_MAX_PENDING_REPLIES_PER_CONVERSATION: usize = 8;
476    /// §5 default: shared per-connection inbox byte budget (4 MiB).
477    pub const DEFAULT_MAX_CONNECTION_INBOX_BYTES: usize = 4 * 1024 * 1024;
478    /// Default per-connection OUTBOUND byte budget (4 MiB) — the twin the inbound
479    /// budget was always described as mirroring, now declared in its own right.
480    ///
481    /// It is the honest single-frame delivery bound: a frame up to this size is
482    /// delivered intact across as many scheduler slices as the peer's read rate
483    /// needs, and a frame LARGER than the whole buffer can never be queued at all.
484    /// The number is unchanged from the buffer's former private constant; what
485    /// changes is that an operator can now see it and set it, instead of meeting
486    /// it only as a connection that stopped carrying something.
487    pub const DEFAULT_MAX_CONNECTION_OUTBOUND_BYTES: usize = 4 * 1024 * 1024;
488    /// Default per-inbox envelope-count fairness trip.
489    ///
490    /// # Why 4096 and not the §5-era 256 (P0 #55)
491    ///
492    /// A subscription inbox is bounded TWICE: by
493    /// [`Self::DEFAULT_MAX_CONNECTION_INBOX_BYTES`] (4 MiB, shared across all of
494    /// one connection's inboxes) and by this envelope COUNT. Memory is the real
495    /// resource, so the byte budget is the bound that should bind and this one is
496    /// meant to be a fairness backstop behind it.
497    ///
498    /// Which one binds first is decided by record size. For a connection holding a
499    /// single subscription, the crossover — the record size at which the two caps
500    /// trip together — is `4 MiB / depth`:
501    ///
502    /// | depth | crossover record size  | binds first for a 1 KiB record |
503    /// |-------|------------------------|--------------------------------|
504    /// | 256   | 4194304/256 = 16 KiB   | the COUNT (at 256 KiB, 6% of 4 MiB) |
505    /// | 4096  | 4194304/4096 = 1 KiB   | the BYTES (as intended)        |
506    ///
507    /// At 256 the count cap therefore binds for every record smaller than 16 KiB —
508    /// which is nearly all real traffic — and it binds at a small fraction of the
509    /// memory the connection is actually permitted: at a 164-byte record, 256
510    /// envelopes is 41 KiB, ~1% of the 4 MiB budget. A replay burst is thousands of
511    /// SMALL records, which is precisely the shape that hits the crude bound while
512    /// the real bound sits untouched. That is the P0: a live subscriber shed for
513    /// exceeding a fairness trip it reached at 1% of its memory allowance.
514    ///
515    /// At 4096 the crossover falls to 1 KiB, so bytes bind for realistic records
516    /// and this cap returns to backstop duty.
517    ///
518    /// Honest bound on that arithmetic: the byte budget is per CONNECTION and this
519    /// cap is per SUBSCRIPTION, so with `S` subscriptions sharing one connection
520    /// the effective crossover is `4 MiB / S / depth`. The table above is the `S=1`
521    /// case. A connection holding many subscriptions of small records can still
522    /// reach the count cap first — which is exactly the sibling-starvation case
523    /// this cap exists to catch.
524    pub const DEFAULT_MAX_SUBSCRIPTION_INBOX_DEPTH: usize = 4096;
525    /// Default per-slice `Deliver` budget for one connection — the source of truth
526    /// for [`crate::config::LimitsConfig::delivery_slice_budget`] and for the
527    /// delivery pump's own `DELIVERY_SLICE_BUDGET`.
528    ///
529    /// # Why this is NOT raised (P0 #55)
530    ///
531    /// This is the cross-connection FAIRNESS bound: it caps how long one connection
532    /// may hold a shared scheduler thread before its peers get a turn. Raising it
533    /// makes a burst subscriber's own drain finish in fewer scheduling round trips,
534    /// and the P0 #55 2x2 measured exactly that — 0/192 boots lost a subscriber at
535    /// 256 against roughly half of them at 32.
536    ///
537    /// That experiment does not license raising the default. It ran two or three
538    /// connections, so the peer starvation a raise would CAUSE was unobservable BY
539    /// CONSTRUCTION: with no queue of waiting peers there is nothing for a longer
540    /// slice to delay. What was measured is that this knob moves the outcome, not
541    /// that moving it is safe.
542    ///
543    /// So the number stays 32 and becomes an operator DECISION instead: a
544    /// deployment that knows its connection count and its burst shape can raise it
545    /// deliberately. The default does not make that trade on anyone's behalf.
546    /// The P0's actual fix is [`Self::DEFAULT_MAX_SUBSCRIPTION_INBOX_DEPTH`].
547    pub const DEFAULT_DELIVERY_SLICE_BUDGET: usize = 32;
548    /// Default wait for a correlated conversation reply: 5 s, the bound a
549    /// client has always been answered within.
550    pub const DEFAULT_REPLY_TIMEOUT_MS: u64 = 5_000;
551
552    /// Validates the caps: every value must be non-zero (a zero cap gates nothing
553    /// — the unlimited-by-silence state §5 outlaws). Errors are accumulated into
554    /// `errors` (one per offending field) so an operator sees every bad cap at
555    /// once, matching the rest of config validation.
556    pub(crate) fn collect_errors(&self, errors: &mut Vec<String>) {
557        let checks: [(&str, usize); 10] = [
558            ("max_connections", self.max_connections),
559            (
560                "max_subscriptions_per_connection",
561                self.max_subscriptions_per_connection,
562            ),
563            (
564                "max_conversations_per_connection",
565                self.max_conversations_per_connection,
566            ),
567            (
568                "max_pending_pushes_per_connection",
569                self.max_pending_pushes_per_connection,
570            ),
571            (
572                "max_pending_conversation_replies_per_connection",
573                self.max_pending_conversation_replies_per_connection,
574            ),
575            (
576                "max_pending_replies_per_conversation",
577                self.max_pending_replies_per_conversation,
578            ),
579            (
580                "max_connection_inbox_bytes",
581                self.max_connection_inbox_bytes,
582            ),
583            // A zero here would leave a connection unable to queue ANY frame at
584            // all — every push and every delivery refused on its first byte.
585            (
586                "max_connection_outbound_bytes",
587                self.max_connection_outbound_bytes,
588            ),
589            (
590                "max_subscription_inbox_depth",
591                self.max_subscription_inbox_depth,
592            ),
593            // A zero here would make the delivery pump enqueue nothing on any
594            // slice — every subscription silently stalled forever, which is the
595            // unlimited-by-silence failure wearing the opposite sign.
596            ("delivery_slice_budget", self.delivery_slice_budget),
597        ];
598        for (field, value) in checks {
599            if value == 0 {
600                errors.push(zero_cap_error(field));
601            }
602        }
603        // `max_channels` is checked BESIDE the array rather than inside it,
604        // because it is the one optional cap and the array's element type has no
605        // way to say "absent". Forcing it in would need a stand-in value for
606        // `None` — a number standing where a declaration is not — and any
607        // stand-in would either invent a bound or make an undeclared cap look
608        // declared. So the shape stays honest and the RULE stays uniform: a
609        // declared zero is refused with the same message as the other eight.
610        //
611        // The parenthetical about unlimited-by-silence is the other eight caps'
612        // ground; for this one a zero would refuse every registration rather
613        // than admit every registration. The message is kept identical anyway:
614        // an operator reading nine cap errors should learn one rule — zero is
615        // not a legal cap value anywhere in this section — not two.
616        if self.max_channels == Some(0) {
617            errors.push(zero_cap_error("max_channels"));
618        }
619        // A duration, not a count, so it sits beside the array too; zero is
620        // refused by the same rule.
621        if self.reply_timeout_ms == 0 {
622            errors.push(zero_cap_error("reply_timeout_ms"));
623        }
624    }
625}
626
627/// The typed refusal for a zero-valued cap, shared by every field in `[limits]`
628/// so the wording cannot drift between them.
629fn zero_cap_error(field: &str) -> String {
630    format!(
631        "limits.{field}: must be greater than zero (a zero cap would be \
632         unlimited-by-silence, which §5 forbids)"
633    )
634}
635
636impl Default for LimitsConfig {
637    fn default() -> Self {
638        Self {
639            max_connections: default_max_connections(),
640            max_subscriptions_per_connection: default_max_subscriptions_per_connection(),
641            max_conversations_per_connection: default_max_conversations_per_connection(),
642            max_pending_pushes_per_connection: default_max_pending_pushes_per_connection(),
643            max_pending_conversation_replies_per_connection:
644                default_max_pending_conversation_replies_per_connection(),
645            max_pending_replies_per_conversation: default_max_pending_replies_per_conversation(),
646            max_connection_inbox_bytes: default_max_connection_inbox_bytes(),
647            max_connection_outbound_bytes: default_max_connection_outbound_bytes(),
648            max_subscription_inbox_depth: default_max_subscription_inbox_depth(),
649            delivery_slice_budget: default_delivery_slice_budget(),
650            // No signed bound exists for this cap, so the default is the
651            // absence of a declaration — never a number.
652            max_channels: None,
653            reply_timeout_ms: default_reply_timeout_ms(),
654        }
655    }
656}
657
658const fn default_max_connections() -> usize {
659    LimitsConfig::DEFAULT_MAX_CONNECTIONS
660}
661const fn default_max_subscriptions_per_connection() -> usize {
662    LimitsConfig::DEFAULT_MAX_SUBSCRIPTIONS_PER_CONNECTION
663}
664const fn default_max_conversations_per_connection() -> usize {
665    LimitsConfig::DEFAULT_MAX_CONVERSATIONS_PER_CONNECTION
666}
667const fn default_max_pending_pushes_per_connection() -> usize {
668    LimitsConfig::DEFAULT_MAX_PENDING_PUSHES_PER_CONNECTION
669}
670const fn default_max_pending_conversation_replies_per_connection() -> usize {
671    LimitsConfig::DEFAULT_MAX_PENDING_CONVERSATION_REPLIES_PER_CONNECTION
672}
673const fn default_max_pending_replies_per_conversation() -> usize {
674    LimitsConfig::DEFAULT_MAX_PENDING_REPLIES_PER_CONVERSATION
675}
676const fn default_max_connection_inbox_bytes() -> usize {
677    LimitsConfig::DEFAULT_MAX_CONNECTION_INBOX_BYTES
678}
679const fn default_max_connection_outbound_bytes() -> usize {
680    LimitsConfig::DEFAULT_MAX_CONNECTION_OUTBOUND_BYTES
681}
682const fn default_max_subscription_inbox_depth() -> usize {
683    LimitsConfig::DEFAULT_MAX_SUBSCRIPTION_INBOX_DEPTH
684}
685const fn default_delivery_slice_budget() -> usize {
686    LimitsConfig::DEFAULT_DELIVERY_SLICE_BUDGET
687}
688
689const fn default_reply_timeout_ms() -> u64 {
690    LimitsConfig::DEFAULT_REPLY_TIMEOUT_MS
691}
692
693/// Participant lifecycle configuration (`[participant]`).
694///
695/// Present iff the deployment activates the participant protocol. Every field
696/// is required — serde carries no defaults here, so a missing field fails
697/// config loading with a typed error naming the field, and
698/// [`ParticipantConfig::collect_errors`] rejects semantically impossible
699/// values during the same accumulated validation pass as the rest of the
700/// config. All values are deployment-owner decisions (no assumed defaults).
701///
702/// Every field here is consumed by the live production handler. Frontier and
703/// retention limits are required inputs; there are no deployment defaults.
704#[derive(Debug, Clone, Copy, serde::Deserialize)]
705#[serde(deny_unknown_fields)]
706pub struct ParticipantConfig {
707    /// Complete participant wire-frame limit (`WF`) negotiated with every
708    /// participant-capable connection. Must be at least the protocol's
709    /// minimum complete frame; enforced by the shared codec at service
710    /// construction and pre-checked during config validation.
711    pub wire_frame_limit: u64,
712    /// Secret-bearing attach/enrollment receipt lifetime in milliseconds.
713    pub attach_receipt_ttl_ms: u64,
714    /// Non-secret receipt-provenance lifetime in milliseconds. Must be at
715    /// least `attach_receipt_ttl_ms` (provenance explains the receipt and
716    /// cannot expire first).
717    pub receipt_provenance_ttl_ms: u64,
718    /// Reporting threshold for server-wide live secret-bearing receipts
719    /// (stage-8 shared scope `LiveReceiptServer`).
720    ///
721    /// Lane p0-39: this scope is NOT an admission gate. It is where an honest
722    /// third party would meet a number someone else's churn consumed, and no
723    /// configured refusal is tolerable there — retention is bounded by
724    /// `attach_receipt_ttl_ms` alone. This number is a TRIPWIRE: when
725    /// in-window occupancy reaches it, the server counts the observation and
726    /// warns on the rising edge. Nothing is ever refused because of it.
727    /// Setting it higher than the deployment's plausible steady state is the
728    /// point; a churn storm is what it is meant to disclose.
729    pub live_receipt_server_report_threshold: u64,
730    /// Per-participant WINDOW SIZE for live secret-bearing receipts (stage-8
731    /// scope `LiveReceiptParticipant`).
732    ///
733    /// Lane p0-39: this is a bound on retention, not a refusal threshold. At
734    /// a full window the participant's own oldest live receipt is displaced
735    /// and the new one lands, so a rotation can never be refused by the very
736    /// receipt it is about to end. Occupancy never exceeds this value.
737    pub max_live_attach_receipts_per_participant: u64,
738    /// Reporting threshold for server-wide retained non-secret provenance
739    /// fingerprints (stage-8 shared scope `ProvenanceServer`).
740    ///
741    /// Lane p0-39: a tripwire, never a gate — see
742    /// [`Self::live_receipt_server_report_threshold`]. Retention here is
743    /// bounded by `receipt_provenance_ttl_ms` alone.
744    ///
745    /// Board #37 (ruling 2026-08-12): a fingerprint occupies from the moment
746    /// the client proves it possesses the secret its receipt minted through
747    /// that receipt's own provenance deadline — not from the minting commit.
748    /// A receipt whose delivery was never observed classifies through its
749    /// window but is never counted here, so an enrol-and-crash cycle leaves
750    /// no provenance residue behind it.
751    pub receipt_provenance_server_report_threshold: u64,
752    /// Reporting threshold for per-conversation provenance fingerprints
753    /// (stage-8 shared scope `ProvenanceConversation`). A tripwire, never a
754    /// gate: one participant's churn must not refuse the next participant of
755    /// the same conversation.
756    pub receipt_provenance_per_conversation_report_threshold: u64,
757    /// Per-participant WINDOW SIZE for provenance fingerprints (stage-8 scope
758    /// `ProvenanceParticipant`).
759    ///
760    /// Lane p0-39: the participant's retained fingerprints — one per
761    /// committed rotation, plus its proven enrollment fingerprint — are held
762    /// to this many, oldest displaced first. Per-participant pressure is
763    /// self-inflicted, so the number bounds memory without refusing: the
764    /// (N+1)th honest fingerprint always lands, including on cold replay from
765    /// durable state. Occupancy never exceeds this value.
766    pub max_receipt_provenance_per_participant: u64,
767    /// Server-wide identity-slot limit (the contract's
768    /// `max_retired_identity_slots` server scope): the total number of
769    /// participant identities — live or retired — mintable across ALL
770    /// conversations. Enrollment refuses with server-scope
771    /// `IdentityCapacityExceeded` (tested BEFORE the conversation scope)
772    /// when every slot is reserved.
773    pub max_retired_identity_slots_server: u64,
774    /// Per-CONVERSATION identity limit `I` (the contract's half-open
775    /// `0..=I` bound on permanent participant ordinals — the conversation
776    /// scope of `max_retired_identity_slots`, NOT a per-participant
777    /// reservation). Enrollment assigns monotone participant indices in
778    /// `0..I` within one conversation and refuses with conversation-scope
779    /// `IdentityCapacityExceeded` when occupancy reaches this value; slots
780    /// and ids are never reused. The server-wide companion is
781    /// [`Self::max_retired_identity_slots_server`].
782    pub identity_slots: u64,
783    /// Maximum entries one observer-recovery handshake batch may name.
784    pub observer_recovery_max_entries: u64,
785    /// Semantic conversations one connection may track — the protocol's
786    /// signed connection-conversation limit. Consumed on BOTH of its contract
787    /// paths: the stage-6 capacity gate every conversation-scoped semantic
788    /// operation runs (register row 5641) and the observer-recovery batch
789    /// preflight (register row 5642), over one shared per-connection
790    /// dispatch map.
791    pub max_semantic_conversations_per_connection: u64,
792    /// Maximum canonical entries in one ordinary retained-record row.
793    pub max_ordinary_record_entries: u64,
794    /// Maximum canonical bytes in one ordinary retained-record row.
795    pub max_ordinary_record_bytes: u64,
796    /// Maximum canonical entries in one generated marker row.
797    pub max_generated_marker_entries: u64,
798    /// Maximum canonical bytes in one generated marker row.
799    pub max_generated_marker_bytes: u64,
800    /// Entry component of the mandatory transaction envelope `Q`.
801    pub mandatory_transaction_bound_entries: u64,
802    /// Byte component of the mandatory transaction envelope `Q`.
803    pub mandatory_transaction_bound_bytes: u64,
804    /// Entry component of the full recovery claim `K`.
805    pub full_recovery_claim_entries: u64,
806    /// Byte component of the full recovery claim `K`.
807    pub full_recovery_claim_bytes: u64,
808    /// Total retained durable entry capacity per conversation.
809    pub retained_capacity_entries: u64,
810    /// Total retained canonical-byte capacity per conversation.
811    pub retained_capacity_bytes: u64,
812    /// Maximum retained causal-record rows restored for one conversation.
813    pub max_retained_record_rows: u64,
814    /// Maximum closure churn cycles in one episode.
815    pub closure_episode_churn_limit: u64,
816}
817
818impl ParticipantConfig {
819    /// Accumulates semantic validation errors for the participant section.
820    ///
821    /// Zero is rejected wherever it would be unlimited-by-silence, gate
822    /// nothing, or violate a protocol precondition; the TTL ordering mirrors
823    /// the protocol's own frozen configuration precedence.
824    pub(crate) fn collect_errors(&self, errors: &mut Vec<String>) {
825        // The receipt/identity block follows the contract's frozen nine-field
826        // validation order: both TTLs, the five receipt/provenance caps, the
827        // server identity limit, then the conversation identity limit.
828        let nonzero: [(&str, u64); 23] = [
829            ("wire_frame_limit", self.wire_frame_limit),
830            ("attach_receipt_ttl_ms", self.attach_receipt_ttl_ms),
831            ("receipt_provenance_ttl_ms", self.receipt_provenance_ttl_ms),
832            (
833                "live_receipt_server_report_threshold",
834                self.live_receipt_server_report_threshold,
835            ),
836            (
837                "max_live_attach_receipts_per_participant",
838                self.max_live_attach_receipts_per_participant,
839            ),
840            (
841                "receipt_provenance_server_report_threshold",
842                self.receipt_provenance_server_report_threshold,
843            ),
844            (
845                "receipt_provenance_per_conversation_report_threshold",
846                self.receipt_provenance_per_conversation_report_threshold,
847            ),
848            (
849                "max_receipt_provenance_per_participant",
850                self.max_receipt_provenance_per_participant,
851            ),
852            (
853                "max_retired_identity_slots_server",
854                self.max_retired_identity_slots_server,
855            ),
856            ("identity_slots", self.identity_slots),
857            (
858                "observer_recovery_max_entries",
859                self.observer_recovery_max_entries,
860            ),
861            (
862                "max_semantic_conversations_per_connection",
863                self.max_semantic_conversations_per_connection,
864            ),
865            (
866                "max_ordinary_record_entries",
867                self.max_ordinary_record_entries,
868            ),
869            ("max_ordinary_record_bytes", self.max_ordinary_record_bytes),
870            (
871                "max_generated_marker_entries",
872                self.max_generated_marker_entries,
873            ),
874            (
875                "max_generated_marker_bytes",
876                self.max_generated_marker_bytes,
877            ),
878            (
879                "mandatory_transaction_bound_entries",
880                self.mandatory_transaction_bound_entries,
881            ),
882            (
883                "mandatory_transaction_bound_bytes",
884                self.mandatory_transaction_bound_bytes,
885            ),
886            (
887                "full_recovery_claim_entries",
888                self.full_recovery_claim_entries,
889            ),
890            ("full_recovery_claim_bytes", self.full_recovery_claim_bytes),
891            ("retained_capacity_entries", self.retained_capacity_entries),
892            ("retained_capacity_bytes", self.retained_capacity_bytes),
893            ("max_retained_record_rows", self.max_retained_record_rows),
894        ];
895        for (field, value) in nonzero {
896            if value == 0 {
897                errors.push(format!("participant.{field}: must be greater than zero"));
898            }
899        }
900        if self.receipt_provenance_ttl_ms < self.attach_receipt_ttl_ms {
901            errors.push(
902                "participant.receipt_provenance_ttl_ms: must be at least \
903                 attach_receipt_ttl_ms (provenance cannot expire before the receipt it explains)"
904                    .to_owned(),
905            );
906        }
907        if self.full_recovery_claim_entries != self.mandatory_transaction_bound_entries {
908            errors.push(
909                "participant.full_recovery_claim_entries: must equal \
910                 mandatory_transaction_bound_entries"
911                    .to_owned(),
912            );
913        }
914        if self.full_recovery_claim_bytes != self.mandatory_transaction_bound_bytes {
915            errors.push(
916                "participant.full_recovery_claim_bytes: must equal \
917                 mandatory_transaction_bound_bytes"
918                    .to_owned(),
919            );
920        }
921        if !(2..=u64::from(u32::MAX)).contains(&self.closure_episode_churn_limit) {
922            errors.push(
923                "participant.closure_episode_churn_limit: must be in 2..=u32::MAX".to_owned(),
924            );
925        }
926        self.collect_unit2_derived_errors(errors);
927    }
928
929    fn collect_unit2_derived_errors(&self, errors: &mut Vec<String>) {
930        if self
931            .max_retained_record_rows
932            .checked_mul(self.identity_slots)
933            .is_none()
934        {
935            errors.push("participant.UNIT2_MAX_LIVE_RECIPIENT_OBLIGATIONS: max_retained_record_rows * identity_slots overflows u64".to_owned());
936        }
937    }
938}
939
940/// Which connection-services adapter the server constructs (D2).
941#[derive(Debug, Clone, Copy, PartialEq, Eq)]
942pub enum ServiceProfile {
943    /// Full channel/conversation/durability services — the default. Constructs the
944    /// haematite store, channel supervisor, conversation supervisor, and dedup
945    /// cache exactly as before.
946    Full,
947    /// Capability-scoped worker front door: the connection supervisor only, with no
948    /// channel/conversation/haematite machinery. Backs worker registration,
949    /// correlated push/reply, and notifier-consumed reserved publishes; ordinary
950    /// channel and conversation frames are rejected with a typed error frame.
951    WorkerFrontDoor,
952}
953
954impl ServiceProfile {
955    /// Config value selecting the full-service profile.
956    pub const FULL: &'static str = "full";
957    /// Config value selecting the worker-front-door profile.
958    pub const WORKER_FRONT_DOOR: &'static str = "worker-front-door";
959
960    /// Parses a `[services] profile` value into a typed profile.
961    ///
962    /// # Errors
963    /// Returns [`ServerError::ConfigValidation`] for any value other than
964    /// [`Self::FULL`] or [`Self::WORKER_FRONT_DOOR`].
965    pub fn parse(value: &str) -> Result<Self, ServerError> {
966        match value {
967            Self::FULL => Ok(Self::Full),
968            Self::WORKER_FRONT_DOOR => Ok(Self::WorkerFrontDoor),
969            other => Err(ServerError::ConfigValidation {
970                message: format!(
971                    "services.profile: unknown profile '{other}'; expected \"{}\" or \"{}\"",
972                    Self::FULL,
973                    Self::WORKER_FRONT_DOOR
974                ),
975            }),
976        }
977    }
978}