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    pub persistence_path: Option<PathBuf>,
23    /// Optional beamr distribution cluster membership configuration.
24    pub cluster: Option<ClusterConfig>,
25    /// Optional connection authentication configuration.
26    ///
27    /// When present, every client `Connect` handshake must carry a matching
28    /// `auth_token`; when absent the server is open (byte-identical to the
29    /// pre-auth behaviour). Not an ACL system — a single shared bearer token.
30    #[serde(default)]
31    pub auth: Option<AuthConfig>,
32    /// Service construction profile. Absent `[services]` (or an absent `profile`
33    /// key within it) defaults to `"full"`, so existing deployments build exactly
34    /// what they build today.
35    #[serde(default)]
36    pub services: ServicesConfig,
37    /// Operational bounds (§5). Absent `[limits]` (or any absent key within it)
38    /// defaults to the certifying-pair-signed numbers, so an operator who sets
39    /// nothing still runs bounded — "unlimited-by-silence is no longer a legal
40    /// state" (§5). Every value is a hard cap enforced by a typed refusal; a
41    /// zero (or otherwise invalid) value is a config validation error, never a
42    /// silent "unlimited".
43    #[serde(default)]
44    pub limits: LimitsConfig,
45    /// Optional WebSocket transport acceptor (LP-WS-TRANSPORT R1).
46    ///
47    /// When present the server binds a sibling WebSocket listener carrying the
48    /// canonical liminal wire protocol (one binary message per canonical frame)
49    /// alongside the main TCP listener. When absent NO HTTP/WebSocket listener
50    /// is started and the server behaves byte-identically to the pre-WebSocket
51    /// build. Every field inside is a deployment decision; the origin allow-list
52    /// FAILS CLOSED (an absent or empty list refuses every Origin-bearing
53    /// upgrade) and the keepalive ping interval is disabled unless explicitly
54    /// configured.
55    #[serde(default)]
56    pub websocket: Option<WebSocketConfig>,
57    /// Participant lifecycle activation (LP gap closure, Part B).
58    ///
59    /// When present the server installs the production participant semantic
60    /// handler and advertises the participant capability bit on every
61    /// connection. Every field inside is REQUIRED and carries NO default:
62    /// participant lifecycle values are deployment decisions, and an absent
63    /// field is a typed startup error rather than an assumed number. When the
64    /// section is absent the participant capability stays disabled and the
65    /// server behaves byte-identically to the pre-activation build.
66    #[serde(default)]
67    pub participant: Option<ParticipantConfig>,
68}
69
70impl ServerConfig {
71    /// Returns the configured graceful-shutdown drain timeout.
72    #[must_use]
73    pub const fn drain_timeout(&self) -> Duration {
74        Duration::from_millis(self.drain_timeout_ms)
75    }
76}
77
78/// Declarative channel definition loaded from server configuration.
79#[derive(Debug, Clone, serde::Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct ChannelDef {
82    /// Unique channel name used by routing rules and operators.
83    pub name: String,
84    /// Filesystem path to a JSON Schema document that validates every message
85    /// published to this channel.
86    ///
87    /// The path is resolved relative to the directory containing the config file
88    /// (absolute paths are used verbatim). Config validation reads and parses the
89    /// referenced document and stores the result in [`Self::loaded_schema`]; a
90    /// missing file, an unreadable file, or a file that is not valid JSON is an
91    /// accumulated validation error that stops startup.
92    ///
93    /// `None` means the channel has no schema: it keeps the permissive empty
94    /// schema (`{}`) that accepts any JSON payload.
95    #[serde(default)]
96    pub schema_ref: Option<PathBuf>,
97    /// Whether this channel requires durable persistence.
98    pub durable: bool,
99    /// Schema document loaded and parsed from [`Self::schema_ref`] during config
100    /// validation. Populated only by [`crate::config::validate`]; a directly
101    /// constructed [`ChannelDef`] that skips validation carries `None` here and is
102    /// therefore built with the permissive empty schema regardless of
103    /// [`Self::schema_ref`]. Never deserialized from the config file.
104    #[serde(skip)]
105    pub loaded_schema: Option<LoadedSchema>,
106}
107
108/// A channel's JSON Schema document as loaded from disk during config validation.
109///
110/// Carries both the parsed document (fed to the validation engine when the channel
111/// is built) and the raw file bytes (hashed into the protocol schema id advertised
112/// at subscribe time, so an SDK deriving ids from the same schema bytes converges).
113#[derive(Debug, Clone)]
114pub struct LoadedSchema {
115    /// Raw bytes of the schema file, hashed to derive the protocol schema id.
116    pub bytes: Vec<u8>,
117    /// Parsed JSON Schema document, fed to the channel's validation engine.
118    pub document: serde_json::Value,
119}
120
121/// Declarative routing rule definition loaded from server configuration.
122#[derive(Debug, Clone, serde::Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct RoutingRuleDef {
125    /// Source channel name from which messages are routed.
126    pub source_channel: String,
127    /// Target channel name to which matching messages are routed.
128    pub target_channel: String,
129    /// Optional predicate expression that filters routed messages.
130    pub predicate: Option<String>,
131}
132
133/// Default beamr distribution handshake cookie, used when the operator does not
134/// configure one. Mirrors beamr's own [`beamr::distribution::DEFAULT_COOKIE`].
135pub const DEFAULT_COOKIE: &str = "beamr-cookie";
136
137/// Beamr distribution cluster configuration for standalone deployment.
138#[derive(Debug, Clone, serde::Deserialize)]
139#[serde(deny_unknown_fields)]
140pub struct ClusterConfig {
141    /// Unique node name advertised to the beamr distribution cluster.
142    pub node_name: String,
143    /// Socket address this node binds for inbound distribution links from peers.
144    ///
145    /// This is distinct from [`ServerConfig::listen_address`] (the client wire
146    /// port): a clustered node listens on two ports — one for clients, one for
147    /// peer distribution traffic.
148    pub listen_address: SocketAddr,
149    /// Seed node socket addresses used to join an existing cluster.
150    pub seed_nodes: Vec<SocketAddr>,
151    /// Shared distribution handshake cookie. Every node in a cluster MUST use the
152    /// same cookie or the OTP handshake is rejected. Defaults to
153    /// [`DEFAULT_COOKIE`] when omitted.
154    #[serde(default = "default_cookie")]
155    pub cookie: String,
156}
157
158fn default_cookie() -> String {
159    DEFAULT_COOKIE.to_owned()
160}
161
162/// Connection authentication configuration.
163///
164/// A single shared bearer token compared (constant-time) against the `auth_token`
165/// carried on every client `Connect` handshake. This is the table-stakes access
166/// gate, not an ACL system: one token grants full access, its absence (no `[auth]`
167/// section) leaves the server open.
168#[derive(Debug, Clone, serde::Deserialize)]
169#[serde(deny_unknown_fields)]
170pub struct AuthConfig {
171    /// Shared secret token a client must present in its `Connect` handshake. Must
172    /// be non-empty when the `[auth]` section is present (an empty token is a
173    /// config validation error, since it would gate nothing).
174    pub token: String,
175}
176
177/// WebSocket transport acceptor configuration (`[websocket]`, LP-WS-TRANSPORT R1).
178///
179/// The sibling WebSocket route is an explicit opt-in: the section itself must be
180/// present for any HTTP/WebSocket listener to start, and inside it the listen
181/// address and the single exact upgrade path are required with no defaults.
182///
183/// The deployment TLS contract (tear ruling Q1) is raw `ws://` behind a named
184/// TLS-terminating proxy that owns public `wss://` and certificates; liminal
185/// grows no TLS stack. Origin validation nonetheless belongs to this acceptor:
186/// [`Self::allowed_origins`] is the explicit allow-list checked on every
187/// Origin-bearing upgrade, and there is NO default list — absent or empty
188/// configuration fails closed for browser-origin upgrades while a native client
189/// that sends no `Origin` header may still upgrade (F6).
190///
191/// OPERATOR NOTE — the same deployment contract covers the pre-upgrade window
192/// (domain-owner ruling, 2026-07-18): the fronting proxy must ALSO enforce
193/// pre-upgrade read timeouts, handshake concurrency limits, and connection
194/// rate limits. Between TCP accept and a completed WebSocket upgrade this
195/// listener does not count the socket against `[limits] max_connections` and
196/// applies no read deadline of its own (only the fixed request-head size
197/// bound), so a deployment that exposes this port without the named proxy is
198/// out of contract on untrusted networks. A named handshake read-deadline
199/// config plus an in-flight handshake cap derived from the configured
200/// `max_connections` value is the ledgered post-demo hardening.
201#[derive(Debug, Clone, serde::Deserialize)]
202#[serde(deny_unknown_fields)]
203pub struct WebSocketConfig {
204    /// Socket address the WebSocket acceptor binds. Required; distinct from the
205    /// main wire listener, the health listener, and any cluster listener.
206    pub listen_address: SocketAddr,
207    /// The single exact HTTP request path that accepts WebSocket upgrades.
208    /// Required; must start with `/`. Every other path — and every ordinary
209    /// HTTP request — receives a small fixed non-success response and closes.
210    pub path: String,
211    /// Explicit browser-origin allow-list checked on every Origin-bearing
212    /// upgrade (F6). Entries are compared byte-exact against the request's
213    /// serialized `Origin` header value (RFC 6454 ASCII serialization, e.g.
214    /// `https://app.example.com`). Absent or empty means NO browser origin is
215    /// accepted (fail closed); native clients sending no `Origin` header are
216    /// unaffected.
217    #[serde(default)]
218    pub allowed_origins: Vec<String>,
219    /// Q-A transport-liveness keepalive: the server-side WebSocket Ping
220    /// interval in milliseconds. This is a precise LAW-1 carve-out — liveness
221    /// pings never mint application events, never re-arm application state, and
222    /// never serve as a source of truth; failure detection remains the socket's
223    /// typed terminal events. The bound is one ping per interval per
224    /// connection, so the idle cost is `interval x connection-count`. Absent
225    /// means pings are DISABLED, accepting proxy-idle-disconnect churn as the
226    /// documented consequence. A configured zero is a validation error.
227    #[serde(default)]
228    pub ping_interval_ms: Option<u64>,
229}
230
231/// Service construction profile selection (D2).
232///
233/// The `profile` value is carried as a raw string here rather than a typed enum so
234/// an unrecognised value is a config *validation* error with a helpful message
235/// (via [`Self::profile`]) rather than an opaque deserialization failure — matching
236/// how every other semantic config check surfaces. Absent `profile` defaults to
237/// `"full"`.
238#[derive(Debug, Clone, serde::Deserialize)]
239#[serde(deny_unknown_fields)]
240pub struct ServicesConfig {
241    /// Construction profile: `"full"` (the default, unchanged behaviour) or
242    /// `"worker-front-door"` (capability-scoped worker deployments).
243    #[serde(default = "default_service_profile")]
244    pub profile: String,
245}
246
247impl Default for ServicesConfig {
248    fn default() -> Self {
249        Self {
250            profile: default_service_profile(),
251        }
252    }
253}
254
255impl ServicesConfig {
256    /// Resolves the raw `profile` string into a typed [`ServiceProfile`].
257    ///
258    /// # Errors
259    /// Returns [`ServerError::ConfigValidation`] when the value is not a recognised
260    /// profile.
261    pub fn profile(&self) -> Result<ServiceProfile, ServerError> {
262        ServiceProfile::parse(&self.profile)
263    }
264}
265
266fn default_service_profile() -> String {
267    ServiceProfile::FULL.to_owned()
268}
269
270/// Operational bounds (§5, scout Q4 — rule-2 items).
271///
272/// Each field is a hard per-scope cap with a typed refusal and a
273/// certifying-pair-signed default (the numbers below are §5's). The struct is
274/// the single wire surface for `[limits]`; [`LimitsConfig::validate`] rejects any
275/// zero value as a typed config error (a zero cap would gate nothing — the exact
276/// unlimited-by-silence state §5 outlaws). Defaults come from the `default_*`
277/// free functions so an absent key resolves to the signed number, not zero.
278#[derive(Debug, Clone, Copy, serde::Deserialize)]
279#[serde(deny_unknown_fields)]
280pub struct LimitsConfig {
281    /// Total live connections the listener admits before refusing (§5: 256 — a
282    /// worker-bus, an order of magnitude above any observed fleet).
283    #[serde(default = "default_max_connections")]
284    pub max_connections: usize,
285    /// Subscriptions one connection may hold (§5: 32).
286    #[serde(default = "default_max_subscriptions_per_connection")]
287    pub max_subscriptions_per_connection: usize,
288    /// Open conversations one connection may hold (§5: 32).
289    #[serde(default = "default_max_conversations_per_connection")]
290    pub max_conversations_per_connection: usize,
291    /// In-flight server→client correlated pushes per connection (§5: 32).
292    #[serde(default = "default_max_pending_pushes_per_connection")]
293    pub max_pending_pushes_per_connection: usize,
294    /// Entries in the per-connection pending-reply table (§1.2(3b)/§5: 32 —
295    /// distinct from server-push slots).
296    #[serde(default = "default_max_pending_conversation_replies_per_connection")]
297    pub max_pending_conversation_replies_per_connection: usize,
298    /// Per-conversation sub-cap that confines tombstone ambiguity to its own
299    /// conversation (§1.2(3b)/§5: 8). Pending entries count against BOTH this and
300    /// the connection table; tombstones against THIS alone.
301    #[serde(default = "default_max_pending_replies_per_conversation")]
302    pub max_pending_replies_per_conversation: usize,
303    /// One shared inbox-byte budget per connection, spent across ALL its
304    /// subscription inboxes (§5: 4 MiB — deliberately mirroring the outbound 4 MiB
305    /// bound). Accounting unit: serialized envelope bytes as admitted, charged at
306    /// enqueue and released at dequeue.
307    #[serde(default = "default_max_connection_inbox_bytes")]
308    pub max_connection_inbox_bytes: usize,
309    /// Per-inbox envelope-count secondary fairness trip (§5: 256) — stops one
310    /// subscription starving its siblings inside the shared byte budget; no longer
311    /// load-bearing for the signed bound.
312    #[serde(default = "default_max_subscription_inbox_depth")]
313    pub max_subscription_inbox_depth: usize,
314}
315
316impl LimitsConfig {
317    /// §5 default: total live connections before the listener refuses.
318    pub const DEFAULT_MAX_CONNECTIONS: usize = 256;
319    /// §5 default: subscriptions per connection.
320    pub const DEFAULT_MAX_SUBSCRIPTIONS_PER_CONNECTION: usize = 32;
321    /// §5 default: open conversations per connection.
322    pub const DEFAULT_MAX_CONVERSATIONS_PER_CONNECTION: usize = 32;
323    /// §5 default: in-flight server pushes per connection.
324    pub const DEFAULT_MAX_PENDING_PUSHES_PER_CONNECTION: usize = 32;
325    /// §5 default: pending-reply table entries per connection.
326    pub const DEFAULT_MAX_PENDING_CONVERSATION_REPLIES_PER_CONNECTION: usize = 32;
327    /// §5 default: per-conversation pending-reply sub-cap.
328    pub const DEFAULT_MAX_PENDING_REPLIES_PER_CONVERSATION: usize = 8;
329    /// §5 default: shared per-connection inbox byte budget (4 MiB).
330    pub const DEFAULT_MAX_CONNECTION_INBOX_BYTES: usize = 4 * 1024 * 1024;
331    /// §5 default: per-inbox envelope-count fairness trip.
332    pub const DEFAULT_MAX_SUBSCRIPTION_INBOX_DEPTH: usize = 256;
333
334    /// Validates the caps: every value must be non-zero (a zero cap gates nothing
335    /// — the unlimited-by-silence state §5 outlaws). Errors are accumulated into
336    /// `errors` (one per offending field) so an operator sees every bad cap at
337    /// once, matching the rest of config validation.
338    pub(crate) fn collect_errors(&self, errors: &mut Vec<String>) {
339        let checks: [(&str, usize); 8] = [
340            ("max_connections", self.max_connections),
341            (
342                "max_subscriptions_per_connection",
343                self.max_subscriptions_per_connection,
344            ),
345            (
346                "max_conversations_per_connection",
347                self.max_conversations_per_connection,
348            ),
349            (
350                "max_pending_pushes_per_connection",
351                self.max_pending_pushes_per_connection,
352            ),
353            (
354                "max_pending_conversation_replies_per_connection",
355                self.max_pending_conversation_replies_per_connection,
356            ),
357            (
358                "max_pending_replies_per_conversation",
359                self.max_pending_replies_per_conversation,
360            ),
361            (
362                "max_connection_inbox_bytes",
363                self.max_connection_inbox_bytes,
364            ),
365            (
366                "max_subscription_inbox_depth",
367                self.max_subscription_inbox_depth,
368            ),
369        ];
370        for (field, value) in checks {
371            if value == 0 {
372                errors.push(format!(
373                    "limits.{field}: must be greater than zero (a zero cap would be \
374                     unlimited-by-silence, which §5 forbids)"
375                ));
376            }
377        }
378    }
379}
380
381impl Default for LimitsConfig {
382    fn default() -> Self {
383        Self {
384            max_connections: default_max_connections(),
385            max_subscriptions_per_connection: default_max_subscriptions_per_connection(),
386            max_conversations_per_connection: default_max_conversations_per_connection(),
387            max_pending_pushes_per_connection: default_max_pending_pushes_per_connection(),
388            max_pending_conversation_replies_per_connection:
389                default_max_pending_conversation_replies_per_connection(),
390            max_pending_replies_per_conversation: default_max_pending_replies_per_conversation(),
391            max_connection_inbox_bytes: default_max_connection_inbox_bytes(),
392            max_subscription_inbox_depth: default_max_subscription_inbox_depth(),
393        }
394    }
395}
396
397const fn default_max_connections() -> usize {
398    LimitsConfig::DEFAULT_MAX_CONNECTIONS
399}
400const fn default_max_subscriptions_per_connection() -> usize {
401    LimitsConfig::DEFAULT_MAX_SUBSCRIPTIONS_PER_CONNECTION
402}
403const fn default_max_conversations_per_connection() -> usize {
404    LimitsConfig::DEFAULT_MAX_CONVERSATIONS_PER_CONNECTION
405}
406const fn default_max_pending_pushes_per_connection() -> usize {
407    LimitsConfig::DEFAULT_MAX_PENDING_PUSHES_PER_CONNECTION
408}
409const fn default_max_pending_conversation_replies_per_connection() -> usize {
410    LimitsConfig::DEFAULT_MAX_PENDING_CONVERSATION_REPLIES_PER_CONNECTION
411}
412const fn default_max_pending_replies_per_conversation() -> usize {
413    LimitsConfig::DEFAULT_MAX_PENDING_REPLIES_PER_CONVERSATION
414}
415const fn default_max_connection_inbox_bytes() -> usize {
416    LimitsConfig::DEFAULT_MAX_CONNECTION_INBOX_BYTES
417}
418const fn default_max_subscription_inbox_depth() -> usize {
419    LimitsConfig::DEFAULT_MAX_SUBSCRIPTION_INBOX_DEPTH
420}
421
422/// Participant lifecycle configuration (`[participant]`).
423///
424/// Present iff the deployment activates the participant protocol. Every field
425/// is required — serde carries no defaults here, so a missing field fails
426/// config loading with a typed error naming the field, and
427/// [`ParticipantConfig::collect_errors`] rejects semantically impossible
428/// values during the same accumulated validation pass as the rest of the
429/// config. All values are deployment-owner decisions (no assumed defaults).
430///
431/// Every field here is consumed by the live production handler. Frontier and
432/// retention limits are required inputs; there are no deployment defaults.
433#[derive(Debug, Clone, Copy, serde::Deserialize)]
434#[serde(deny_unknown_fields)]
435pub struct ParticipantConfig {
436    /// Complete participant wire-frame limit (`WF`) negotiated with every
437    /// participant-capable connection. Must be at least the protocol's
438    /// minimum complete frame; enforced by the shared codec at service
439    /// construction and pre-checked during config validation.
440    pub wire_frame_limit: u64,
441    /// Secret-bearing attach/enrollment receipt lifetime in milliseconds.
442    pub attach_receipt_ttl_ms: u64,
443    /// Non-secret receipt-provenance lifetime in milliseconds. Must be at
444    /// least `attach_receipt_ttl_ms` (provenance explains the receipt and
445    /// cannot expire first).
446    pub receipt_provenance_ttl_ms: u64,
447    /// Server-wide cap on live secret-bearing receipts (enrollment and
448    /// credential-attach receipt bodies inside their own receipt windows,
449    /// across every conversation). R-D1 stage-8 scope `LiveReceiptServer`:
450    /// enrollment and credential attach refuse with the typed
451    /// `ReceiptCapacityExceeded` when reserving one more would exceed it.
452    pub max_live_attach_receipts_server: u64,
453    /// Per-participant cap on live secret-bearing receipts (stage-8 scope
454    /// `LiveReceiptParticipant`). A participant holds at most its enrollment
455    /// receipt plus its current attach receipt live at once, so values below
456    /// 3 refuse rotation while the enrollment receipt is still live.
457    pub max_live_attach_receipts_per_participant: u64,
458    /// Server-wide cap on retained non-secret provenance fingerprints
459    /// (stage-8 scope `ProvenanceServer`). A fingerprint exists from its
460    /// operation's commit through its own provenance deadline.
461    pub max_receipt_provenance_server: u64,
462    /// Per-conversation provenance-fingerprint cap (stage-8 scope
463    /// `ProvenanceConversation`).
464    pub max_receipt_provenance_per_conversation: u64,
465    /// Per-participant provenance-fingerprint cap (stage-8 scope
466    /// `ProvenanceParticipant`).
467    pub max_receipt_provenance_per_participant: u64,
468    /// Server-wide identity-slot limit (the contract's
469    /// `max_retired_identity_slots` server scope): the total number of
470    /// participant identities — live or retired — mintable across ALL
471    /// conversations. Enrollment refuses with server-scope
472    /// `IdentityCapacityExceeded` (tested BEFORE the conversation scope)
473    /// when every slot is reserved.
474    pub max_retired_identity_slots_server: u64,
475    /// Per-CONVERSATION identity limit `I` (the contract's half-open
476    /// `0..=I` bound on permanent participant ordinals — the conversation
477    /// scope of `max_retired_identity_slots`, NOT a per-participant
478    /// reservation). Enrollment assigns monotone participant indices in
479    /// `0..I` within one conversation and refuses with conversation-scope
480    /// `IdentityCapacityExceeded` when occupancy reaches this value; slots
481    /// and ids are never reused. The server-wide companion is
482    /// [`Self::max_retired_identity_slots_server`].
483    pub identity_slots: u64,
484    /// Maximum entries one observer-recovery handshake batch may name.
485    pub observer_recovery_max_entries: u64,
486    /// Semantic conversations one connection may track — the protocol's
487    /// signed connection-conversation limit. Consumed on BOTH of its contract
488    /// paths: the stage-6 capacity gate every conversation-scoped semantic
489    /// operation runs (register row 5641) and the observer-recovery batch
490    /// preflight (register row 5642), over one shared per-connection
491    /// dispatch map.
492    pub max_semantic_conversations_per_connection: u64,
493    /// Maximum canonical entries in one ordinary retained-record row.
494    pub max_ordinary_record_entries: u64,
495    /// Maximum canonical bytes in one ordinary retained-record row.
496    pub max_ordinary_record_bytes: u64,
497    /// Maximum canonical entries in one generated marker row.
498    pub max_generated_marker_entries: u64,
499    /// Maximum canonical bytes in one generated marker row.
500    pub max_generated_marker_bytes: u64,
501    /// Entry component of the mandatory transaction envelope `Q`.
502    pub mandatory_transaction_bound_entries: u64,
503    /// Byte component of the mandatory transaction envelope `Q`.
504    pub mandatory_transaction_bound_bytes: u64,
505    /// Entry component of the full recovery claim `K`.
506    pub full_recovery_claim_entries: u64,
507    /// Byte component of the full recovery claim `K`.
508    pub full_recovery_claim_bytes: u64,
509    /// Total retained durable entry capacity per conversation.
510    pub retained_capacity_entries: u64,
511    /// Total retained canonical-byte capacity per conversation.
512    pub retained_capacity_bytes: u64,
513    /// Maximum retained causal-record rows restored for one conversation.
514    pub max_retained_record_rows: u64,
515    /// Maximum closure churn cycles in one episode.
516    pub closure_episode_churn_limit: u64,
517}
518
519impl ParticipantConfig {
520    /// Accumulates semantic validation errors for the participant section.
521    ///
522    /// Zero is rejected wherever it would be unlimited-by-silence, gate
523    /// nothing, or violate a protocol precondition; the TTL ordering mirrors
524    /// the protocol's own frozen configuration precedence.
525    pub(crate) fn collect_errors(&self, errors: &mut Vec<String>) {
526        // The receipt/identity block follows the contract's frozen nine-field
527        // validation order: both TTLs, the five receipt/provenance caps, the
528        // server identity limit, then the conversation identity limit.
529        let nonzero: [(&str, u64); 23] = [
530            ("wire_frame_limit", self.wire_frame_limit),
531            ("attach_receipt_ttl_ms", self.attach_receipt_ttl_ms),
532            ("receipt_provenance_ttl_ms", self.receipt_provenance_ttl_ms),
533            (
534                "max_live_attach_receipts_server",
535                self.max_live_attach_receipts_server,
536            ),
537            (
538                "max_live_attach_receipts_per_participant",
539                self.max_live_attach_receipts_per_participant,
540            ),
541            (
542                "max_receipt_provenance_server",
543                self.max_receipt_provenance_server,
544            ),
545            (
546                "max_receipt_provenance_per_conversation",
547                self.max_receipt_provenance_per_conversation,
548            ),
549            (
550                "max_receipt_provenance_per_participant",
551                self.max_receipt_provenance_per_participant,
552            ),
553            (
554                "max_retired_identity_slots_server",
555                self.max_retired_identity_slots_server,
556            ),
557            ("identity_slots", self.identity_slots),
558            (
559                "observer_recovery_max_entries",
560                self.observer_recovery_max_entries,
561            ),
562            (
563                "max_semantic_conversations_per_connection",
564                self.max_semantic_conversations_per_connection,
565            ),
566            (
567                "max_ordinary_record_entries",
568                self.max_ordinary_record_entries,
569            ),
570            ("max_ordinary_record_bytes", self.max_ordinary_record_bytes),
571            (
572                "max_generated_marker_entries",
573                self.max_generated_marker_entries,
574            ),
575            (
576                "max_generated_marker_bytes",
577                self.max_generated_marker_bytes,
578            ),
579            (
580                "mandatory_transaction_bound_entries",
581                self.mandatory_transaction_bound_entries,
582            ),
583            (
584                "mandatory_transaction_bound_bytes",
585                self.mandatory_transaction_bound_bytes,
586            ),
587            (
588                "full_recovery_claim_entries",
589                self.full_recovery_claim_entries,
590            ),
591            ("full_recovery_claim_bytes", self.full_recovery_claim_bytes),
592            ("retained_capacity_entries", self.retained_capacity_entries),
593            ("retained_capacity_bytes", self.retained_capacity_bytes),
594            ("max_retained_record_rows", self.max_retained_record_rows),
595        ];
596        for (field, value) in nonzero {
597            if value == 0 {
598                errors.push(format!("participant.{field}: must be greater than zero"));
599            }
600        }
601        if self.receipt_provenance_ttl_ms < self.attach_receipt_ttl_ms {
602            errors.push(
603                "participant.receipt_provenance_ttl_ms: must be at least \
604                 attach_receipt_ttl_ms (provenance cannot expire before the receipt it explains)"
605                    .to_owned(),
606            );
607        }
608        if self.full_recovery_claim_entries != self.mandatory_transaction_bound_entries {
609            errors.push(
610                "participant.full_recovery_claim_entries: must equal \
611                 mandatory_transaction_bound_entries"
612                    .to_owned(),
613            );
614        }
615        if self.full_recovery_claim_bytes != self.mandatory_transaction_bound_bytes {
616            errors.push(
617                "participant.full_recovery_claim_bytes: must equal \
618                 mandatory_transaction_bound_bytes"
619                    .to_owned(),
620            );
621        }
622        if !(2..=u64::from(u32::MAX)).contains(&self.closure_episode_churn_limit) {
623            errors.push(
624                "participant.closure_episode_churn_limit: must be in 2..=u32::MAX".to_owned(),
625            );
626        }
627        self.collect_unit2_derived_errors(errors);
628    }
629
630    fn collect_unit2_derived_errors(&self, errors: &mut Vec<String>) {
631        if self
632            .max_retained_record_rows
633            .checked_mul(self.identity_slots)
634            .is_none()
635        {
636            errors.push("participant.UNIT2_MAX_LIVE_RECIPIENT_OBLIGATIONS: max_retained_record_rows * identity_slots overflows u64".to_owned());
637        }
638    }
639}
640
641/// Which connection-services adapter the server constructs (D2).
642#[derive(Debug, Clone, Copy, PartialEq, Eq)]
643pub enum ServiceProfile {
644    /// Full channel/conversation/durability services — the default. Constructs the
645    /// haematite store, channel supervisor, conversation supervisor, and dedup
646    /// cache exactly as before.
647    Full,
648    /// Capability-scoped worker front door: the connection supervisor only, with no
649    /// channel/conversation/haematite machinery. Backs worker registration,
650    /// correlated push/reply, and notifier-consumed reserved publishes; ordinary
651    /// channel and conversation frames are rejected with a typed error frame.
652    WorkerFrontDoor,
653}
654
655impl ServiceProfile {
656    /// Config value selecting the full-service profile.
657    pub const FULL: &'static str = "full";
658    /// Config value selecting the worker-front-door profile.
659    pub const WORKER_FRONT_DOOR: &'static str = "worker-front-door";
660
661    /// Parses a `[services] profile` value into a typed profile.
662    ///
663    /// # Errors
664    /// Returns [`ServerError::ConfigValidation`] for any value other than
665    /// [`Self::FULL`] or [`Self::WORKER_FRONT_DOOR`].
666    pub fn parse(value: &str) -> Result<Self, ServerError> {
667        match value {
668            Self::FULL => Ok(Self::Full),
669            Self::WORKER_FRONT_DOOR => Ok(Self::WorkerFrontDoor),
670            other => Err(ServerError::ConfigValidation {
671                message: format!(
672                    "services.profile: unknown profile '{other}'; expected \"{}\" or \"{}\"",
673                    Self::FULL,
674                    Self::WORKER_FRONT_DOOR
675                ),
676            }),
677        }
678    }
679}