Skip to main content

liminal_server/config/
types.rs

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