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    /// Additional named wire addresses on an encrypted network, sharing admission.
14    #[serde(default)]
15    pub extra_listen_addresses: Vec<SocketAddr>,
16    /// Socket address where the health endpoint server will listen for probes.
17    pub health_listen_address: SocketAddr,
18    /// IGNORED SINCE 0.14.3. Kept only so an existing config file still loads.
19    ///
20    /// This was the maximum time shutdown would wait for existing connections
21    /// to hang up by themselves before closing them. Nothing reads it now.
22    ///
23    /// A drain exists to let in-flight requests finish. In this server every
24    /// write is durable and flushed BEFORE it is acknowledged, so at the
25    /// instant shutdown begins there is no in-flight request to finish and
26    /// nothing for the wait to buy. What it did in practice, on a deployment
27    /// whose connections are long-lived idle clients that never hang up on
28    /// their own, was spend its whole budget on every restart and then close
29    /// the connections anyway — 5 s per restart, measured on Tom Whiting's
30    /// estate on 2026-09-14. Shutdown now stops accepting, closes every
31    /// connection itself (an orderly close each peer reads as a shutdown), and
32    /// flushes durable state, with no timer in that path.
33    ///
34    /// Any value parses, including zero. Setting it changes nothing.
35    pub drain_timeout_ms: u64,
36    /// Channel topology definitions declared by the operator.
37    pub channels: Vec<ChannelDef>,
38    /// Declarative routing rules that connect configured channels.
39    pub routing_rules: Vec<RoutingRuleDef>,
40    /// Optional filesystem location for durable server state.
41    ///
42    /// A fresh boot surfaces `ConfigValidation` unless this path's PARENT
43    /// directory already exists: the haematite engine creates the store dir
44    /// exactly one level below a pre-existing parent it can fence (never
45    /// `create_dir_all` — deliberate upstream hardening). Create the parent;
46    /// the engine fences into it.
47    pub persistence_path: Option<PathBuf>,
48    /// Optional beamr distribution cluster membership configuration.
49    pub cluster: Option<ClusterConfig>,
50    /// Optional connection authentication configuration.
51    ///
52    /// When present, every client `Connect` handshake must carry a matching
53    /// `auth_token`; when absent the server is open (byte-identical to the
54    /// pre-auth behaviour). Not an ACL system — a single shared bearer token.
55    #[serde(default)]
56    pub auth: Option<AuthConfig>,
57    /// Service construction profile. Absent `[services]` (or an absent `profile`
58    /// key within it) defaults to `"full"`, so existing deployments build exactly
59    /// what they build today.
60    #[serde(default)]
61    pub services: ServicesConfig,
62    /// Operational bounds (§5). Absent `[limits]` (or any absent key within it)
63    /// defaults to the certifying-pair-signed numbers, so an operator who sets
64    /// nothing still runs bounded — "unlimited-by-silence is no longer a legal
65    /// state" (§5). Every value is a hard cap enforced by a typed refusal; a
66    /// zero (or otherwise invalid) value is a config validation error, never a
67    /// silent "unlimited".
68    #[serde(default)]
69    pub limits: LimitsConfig,
70    /// Optional WebSocket transport acceptor (LP-WS-TRANSPORT R1).
71    ///
72    /// When present the server binds a sibling WebSocket listener carrying the
73    /// canonical liminal wire protocol (one binary message per canonical frame)
74    /// alongside the main TCP listener. When absent NO HTTP/WebSocket listener
75    /// is started and the server behaves byte-identically to the pre-WebSocket
76    /// build. Every field inside is a deployment decision; the origin allow-list
77    /// FAILS CLOSED (an absent or empty list refuses every Origin-bearing
78    /// upgrade) and the keepalive ping interval is disabled unless explicitly
79    /// configured.
80    #[serde(default)]
81    pub websocket: Option<WebSocketConfig>,
82    /// Participant lifecycle activation (LP gap closure, Part B).
83    ///
84    /// When present the server installs the production participant semantic
85    /// handler and advertises the participant capability bit on every
86    /// connection. Every field inside is REQUIRED and carries NO default:
87    /// participant lifecycle values are deployment decisions, and an absent
88    /// field is a typed startup error rather than an assumed number. When the
89    /// section is absent the participant capability stays disabled and the
90    /// server behaves byte-identically to the pre-activation build.
91    #[serde(default)]
92    pub participant: Option<ParticipantConfig>,
93}
94
95impl ServerConfig {
96    /// Returns the configured drain timeout, which shutdown IGNORES since
97    /// 0.14.3.
98    ///
99    /// Retained so an embedder that reads it keeps compiling, and so the value
100    /// an operator wrote can still be named in the shutdown log line that
101    /// reports it ignored. See [`ServerConfig::drain_timeout_ms`].
102    #[must_use]
103    pub const fn drain_timeout(&self) -> Duration {
104        Duration::from_millis(self.drain_timeout_ms)
105    }
106}
107
108/// Declarative channel definition loaded from server configuration.
109#[derive(Debug, Clone, serde::Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct ChannelDef {
112    /// Unique channel name used by routing rules and operators.
113    pub name: String,
114    /// Filesystem path to a JSON Schema document that validates every message
115    /// published to this channel.
116    ///
117    /// The path is resolved relative to the directory containing the config file
118    /// (absolute paths are used verbatim). Config validation reads and parses the
119    /// referenced document and stores the result in [`Self::loaded_schema`]; a
120    /// missing file, an unreadable file, or a file that is not valid JSON is an
121    /// accumulated validation error that stops startup.
122    ///
123    /// `None` means the channel has no schema: it keeps the permissive empty
124    /// schema (`{}`) that accepts any JSON payload.
125    #[serde(default)]
126    pub schema_ref: Option<PathBuf>,
127    /// Whether this channel requires durable persistence.
128    pub durable: bool,
129    /// Schema document loaded and parsed from [`Self::schema_ref`] during config
130    /// validation. Populated only by [`crate::config::validate`]; a directly
131    /// constructed [`ChannelDef`] that skips validation carries `None` here and is
132    /// therefore built with the permissive empty schema regardless of
133    /// [`Self::schema_ref`]. Never deserialized from the config file.
134    #[serde(skip)]
135    pub loaded_schema: Option<LoadedSchema>,
136}
137
138/// A channel's JSON Schema document as loaded from disk during config validation.
139///
140/// Carries both the parsed document (fed to the validation engine when the channel
141/// is built) and the raw file bytes (hashed into the protocol schema id advertised
142/// at subscribe time, so an SDK deriving ids from the same schema bytes converges).
143#[derive(Debug, Clone)]
144pub struct LoadedSchema {
145    /// Raw bytes of the schema file, hashed to derive the protocol schema id.
146    pub bytes: Vec<u8>,
147    /// Parsed JSON Schema document, fed to the channel's validation engine.
148    pub document: serde_json::Value,
149}
150
151/// Declarative routing rule definition loaded from server configuration.
152#[derive(Debug, Clone, serde::Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct RoutingRuleDef {
155    /// Source channel name from which messages are routed.
156    pub source_channel: String,
157    /// Target channel name to which matching messages are routed.
158    pub target_channel: String,
159    /// Optional predicate expression that filters routed messages.
160    pub predicate: Option<String>,
161}
162
163/// Public beamr distribution cookie, refused during configuration validation.
164/// Mirrors beamr's own [`beamr::distribution::DEFAULT_COOKIE`].
165pub const DEFAULT_COOKIE: &str = "beamr-cookie";
166
167/// Beamr distribution for copies of one server on a trusted network.
168///
169/// Federation provides the network between servers; a cluster joins replicas
170/// of the same server using an explicitly configured shared secret cookie.
171#[derive(Debug, Clone, serde::Deserialize)]
172#[serde(deny_unknown_fields)]
173pub struct ClusterConfig {
174    /// Unique node name advertised to the beamr distribution cluster.
175    pub node_name: String,
176    /// Socket address this node binds for inbound distribution links from peers.
177    ///
178    /// This is distinct from [`ServerConfig::listen_address`] (the client wire
179    /// port): a clustered node listens on two ports — one for clients, one for
180    /// peer distribution traffic.
181    pub listen_address: SocketAddr,
182    /// Seed node socket addresses used to join an existing cluster.
183    pub seed_nodes: Vec<SocketAddr>,
184    /// Required secret distribution handshake cookie. Every node in a cluster
185    /// must explicitly set the same cookie or the OTP handshake is rejected.
186    /// An omitted cookie fails deserialization; an empty cookie or the public
187    /// [`DEFAULT_COOKIE`] fails configuration validation.
188    pub cookie: String,
189}
190
191/// Connection authentication configuration.
192///
193/// A single shared bearer token compared (constant-time) against the `auth_token`
194/// carried on every client `Connect` handshake. This is the table-stakes access
195/// gate, not an ACL system: one token grants full access, its absence (no `[auth]`
196/// section) leaves the server open.
197#[derive(Clone, serde::Deserialize)]
198#[serde(deny_unknown_fields)]
199pub struct AuthConfig {
200    /// Shared secret token a client must present in its `Connect` handshake. Must
201    /// be non-empty when the `[auth]` section is present (an empty token is a
202    /// config validation error, since it would gate nothing).
203    pub token: String,
204    /// Optional registry-minted pass verifier, additive beside the bearer.
205    #[serde(default)]
206    pub pass: Option<PassConfig>,
207    /// The Lys pass as a second credential beside the wire pass (ACCESS-001
208    /// R2): when set, a `Connect` carrying a Lys pass record is verified
209    /// offline against the issuer's published keys and decided per message.
210    #[serde(default)]
211    pub lys: Option<LysConfig>,
212    /// Refuse every worker registration from a connection that proved no
213    /// holder (ACCESS-004 R3): a bearer connection, or one before any pass.
214    /// Off by default, so a worker that dials with the bearer, as aion's does,
215    /// keeps registering; turning it on is the operator's act, and it needs
216    /// `[auth.pass]`, without which no connection could prove a holder.
217    #[serde(default)]
218    pub require_verified_worker: bool,
219}
220
221/// The token is a credential: its `Debug` names its length only
222/// (ACCESS-004 R5).
223impl std::fmt::Debug for AuthConfig {
224    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        formatter
226            .debug_struct("AuthConfig")
227            .field("token", &crate::auth_pass::ByteLength(self.token.len()))
228            .field("pass", &self.pass)
229            .field("lys", &self.lys)
230            .field("require_verified_worker", &self.require_verified_worker)
231            .finish()
232    }
233}
234
235/// Registry pass verification configuration. Every field is required when present.
236#[derive(Debug, Clone, serde::Deserialize)]
237#[serde(deny_unknown_fields)]
238pub struct PassConfig {
239    /// Registry Ed25519 verifying key as exactly 64 hexadecimal characters.
240    pub registry_verifying_key: String,
241    /// Maximum admitted server-clock skew in seconds.
242    pub maximum_clock_skew_seconds: u64,
243}
244
245/// WebSocket transport acceptor configuration (`[websocket]`, LP-WS-TRANSPORT R1).
246///
247/// The sibling WebSocket route is an explicit opt-in: the section itself must be
248/// present for any HTTP/WebSocket listener to start, and inside it the listen
249/// address and the single exact upgrade path are required with no defaults.
250///
251/// The deployment TLS contract (tear ruling Q1) is raw `ws://` behind a named
252/// TLS-terminating proxy that owns public `wss://` and certificates; liminal
253/// grows no TLS stack. Origin validation nonetheless belongs to this acceptor:
254/// [`Self::allowed_origins`] is the explicit allow-list checked on every
255/// Origin-bearing upgrade, and there is NO default list — absent or empty
256/// configuration fails closed for browser-origin upgrades while a native client
257/// that sends no `Origin` header may still upgrade (F6).
258///
259/// OPERATOR NOTE — the same deployment contract covers the pre-upgrade window
260/// (domain-owner ruling, 2026-07-18): the fronting proxy must ALSO enforce
261/// pre-upgrade read timeouts, handshake concurrency limits, and connection
262/// rate limits. Between TCP accept and a completed WebSocket upgrade this
263/// listener does not count the socket against `[limits] max_connections` and
264/// applies no read deadline of its own (only the fixed request-head size
265/// bound), so a deployment that exposes this port without the named proxy is
266/// out of contract on untrusted networks. A named handshake read-deadline
267/// config plus an in-flight handshake cap derived from the configured
268/// `max_connections` value is the ledgered post-demo hardening.
269#[derive(Debug, Clone, serde::Deserialize)]
270#[serde(deny_unknown_fields)]
271pub struct WebSocketConfig {
272    /// Socket address the WebSocket acceptor binds. Required; distinct from the
273    /// main wire listener, the health listener, and any cluster listener.
274    pub listen_address: SocketAddr,
275    /// The single exact HTTP request path that accepts WebSocket upgrades.
276    /// Required; must start with `/`. Every other path — and every ordinary
277    /// HTTP request — receives a small fixed non-success response and closes.
278    pub path: String,
279    /// Explicit browser-origin allow-list checked on every Origin-bearing
280    /// upgrade (F6). Entries are compared byte-exact against the request's
281    /// serialized `Origin` header value (RFC 6454 ASCII serialization, e.g.
282    /// `https://app.example.com`). Absent or empty means NO browser origin is
283    /// accepted (fail closed); native clients sending no `Origin` header are
284    /// unaffected.
285    #[serde(default)]
286    pub allowed_origins: Vec<String>,
287    /// Q-A transport-liveness keepalive: the server-side WebSocket Ping
288    /// interval in milliseconds. This is a precise LAW-1 carve-out — liveness
289    /// pings never mint application events, never re-arm application state, and
290    /// never serve as a source of truth; failure detection remains the socket's
291    /// typed terminal events. The bound is one ping per interval per
292    /// connection, so the idle cost is `interval x connection-count`. Absent
293    /// means pings are DISABLED, accepting proxy-idle-disconnect churn as the
294    /// documented consequence. A configured zero is a validation error.
295    #[serde(default)]
296    pub ping_interval_ms: Option<u64>,
297}
298
299/// Service construction profile selection (D2).
300///
301/// The `profile` value is carried as a raw string here rather than a typed enum so
302/// an unrecognised value is a config *validation* error with a helpful message
303/// (via [`Self::profile`]) rather than an opaque deserialization failure — matching
304/// how every other semantic config check surfaces. Absent `profile` defaults to
305/// `"full"`.
306#[derive(Debug, Clone, serde::Deserialize)]
307#[serde(deny_unknown_fields)]
308pub struct ServicesConfig {
309    /// Construction profile: `"full"` (the default, unchanged behaviour) or
310    /// `"worker-front-door"` (capability-scoped worker deployments).
311    #[serde(default = "default_service_profile")]
312    pub profile: String,
313    /// Row bounds for the server-owned durable streams.
314    #[serde(default)]
315    pub self_streams: SelfStreamsConfig,
316}
317
318/// Retained row bounds for each server-owned durable stream.
319#[derive(Debug, Clone, Copy, serde::Deserialize)]
320#[serde(deny_unknown_fields)]
321pub struct SelfStreamsConfig {
322    /// Maximum retained cost records.
323    #[serde(default = "default_cost_rows")]
324    pub cost_rows: u64,
325    /// Maximum retained lifecycle and connection events.
326    #[serde(default = "default_events_rows")]
327    pub events_rows: u64,
328}
329
330impl Default for SelfStreamsConfig {
331    fn default() -> Self {
332        Self {
333            cost_rows: default_cost_rows(),
334            events_rows: default_events_rows(),
335        }
336    }
337}
338
339const fn default_cost_rows() -> u64 {
340    10_000
341}
342
343const fn default_events_rows() -> u64 {
344    100_000
345}
346
347impl Default for ServicesConfig {
348    fn default() -> Self {
349        Self {
350            profile: default_service_profile(),
351            self_streams: SelfStreamsConfig::default(),
352        }
353    }
354}
355
356impl ServicesConfig {
357    /// Resolves the raw `profile` string into a typed [`ServiceProfile`].
358    ///
359    /// # Errors
360    /// Returns [`ServerError::ConfigValidation`] when the value is not a recognised
361    /// profile.
362    pub fn profile(&self) -> Result<ServiceProfile, ServerError> {
363        ServiceProfile::parse(&self.profile)
364    }
365}
366
367fn default_service_profile() -> String {
368    ServiceProfile::FULL.to_owned()
369}
370
371/// Operational bounds (§5, scout Q4 — rule-2 items).
372///
373/// Each field is a hard per-scope cap with a typed refusal and a
374/// certifying-pair-signed default (the numbers below are §5's). The struct is
375/// the single wire surface for `[limits]`; [`LimitsConfig::validate`] rejects any
376/// zero value as a typed config error (a zero cap would gate nothing — the exact
377/// unlimited-by-silence state §5 outlaws). Defaults come from the `default_*`
378/// free functions so an absent key resolves to the signed number, not zero.
379#[derive(Debug, Clone, Copy, serde::Deserialize)]
380#[serde(deny_unknown_fields)]
381pub struct LimitsConfig {
382    /// Total live connections the listener admits before refusing (§5: 256 — a
383    /// worker-bus, an order of magnitude above any observed fleet).
384    #[serde(default = "default_max_connections")]
385    pub max_connections: usize,
386    /// Subscriptions one connection may hold (§5: 32).
387    #[serde(default = "default_max_subscriptions_per_connection")]
388    pub max_subscriptions_per_connection: usize,
389    /// Open conversations one connection may hold (§5: 32).
390    #[serde(default = "default_max_conversations_per_connection")]
391    pub max_conversations_per_connection: usize,
392    /// In-flight server→client correlated pushes per connection (§5: 32).
393    #[serde(default = "default_max_pending_pushes_per_connection")]
394    pub max_pending_pushes_per_connection: usize,
395    /// Entries in the per-connection pending-reply table (§1.2(3b)/§5: 32 —
396    /// distinct from server-push slots).
397    #[serde(default = "default_max_pending_conversation_replies_per_connection")]
398    pub max_pending_conversation_replies_per_connection: usize,
399    /// Per-conversation sub-cap that confines tombstone ambiguity to its own
400    /// conversation (§1.2(3b)/§5: 8). Pending entries count against BOTH this and
401    /// the connection table; tombstones against THIS alone.
402    #[serde(default = "default_max_pending_replies_per_conversation")]
403    pub max_pending_replies_per_conversation: usize,
404    /// One shared inbox-byte budget per connection, spent across ALL its
405    /// subscription inboxes (§5: 4 MiB — deliberately mirroring the outbound 4 MiB
406    /// bound). Accounting unit: serialized envelope bytes as admitted, charged at
407    /// enqueue and released at dequeue.
408    #[serde(default = "default_max_connection_inbox_bytes")]
409    pub max_connection_inbox_bytes: usize,
410    /// One outbound byte buffer per connection, holding every server-originated
411    /// frame (acks, errors, `Push`, `Deliver`, `Disconnect`, `Pong`) until the
412    /// connection's scheduler slice drains it to the socket.
413    ///
414    /// This is a DELIVERY bound, not a fairness one: a single frame larger than
415    /// the whole buffer can never be queued, so this value is the largest frame
416    /// the server can send on one connection. Raising it raises that ceiling at
417    /// the cost of per-connection memory; lowering it lowers the ceiling for
418    /// every connection at once.
419    #[serde(default = "default_max_connection_outbound_bytes")]
420    pub max_connection_outbound_bytes: usize,
421    /// Per-inbox envelope-count secondary fairness trip — stops one subscription
422    /// starving its siblings inside the shared byte budget. See
423    /// [`LimitsConfig::DEFAULT_MAX_SUBSCRIPTION_INBOX_DEPTH`] for why the default
424    /// is what it is; the short version is that this cap is the CRUDE bound and
425    /// [`LimitsConfig::max_connection_inbox_bytes`] is the real one.
426    #[serde(default = "default_max_subscription_inbox_depth")]
427    pub max_subscription_inbox_depth: usize,
428    /// Per-slice cap on `Deliver` frames one connection may enqueue across all of
429    /// its subscriptions, before the scheduler moves on to its peers.
430    ///
431    /// Operator-visible because P0 #55 proved it decides whether a subscriber
432    /// behind a burst survives — but see
433    /// [`LimitsConfig::DEFAULT_DELIVERY_SLICE_BUDGET`] for why the DEFAULT is not
434    /// raised. Raising it trades one starvation for another, and the measurement
435    /// that showed it moves the outcome could not see the starvation it causes.
436    #[serde(default = "default_delivery_slice_budget")]
437    pub delivery_slice_budget: usize,
438    /// Runtime-registered channels this deployment admits.
439    ///
440    /// # Why this cap departs the uniform pattern
441    ///
442    /// Every other field here carries `#[serde(default = "…")]` naming a §5
443    /// constant, because each of those numbers is a signed §5 bound. **There is
444    /// no signed §5 bound for channel count**, and inventing one is barred: a
445    /// number nobody certified, presented in the same shape as eight numbers
446    /// somebody did, is a forged citation. So the type is `Option<usize>` and
447    /// the serde default is the ABSENCE itself (`None`), never a value —
448    /// `#[serde(default)]` here resolves a missing key to "no bound declared",
449    /// which is a different statement from any number.
450    ///
451    /// Nor may this be a `usize` with a large default: unbounded-by-default is
452    /// not a bound, it is the gap wearing a number.
453    ///
454    /// `None` refuses every runtime channel registration with a typed error
455    /// naming this key, so a deployment that wants runtime registration declares
456    /// its own bound and a deployment that never registers needs no config
457    /// change at all. The cap bounds RUNTIME-registered channels only:
458    /// `[[channels]]` entries are the bound the operator already wrote.
459    ///
460    /// `Some(0)` is a validation error like every other zero cap here — see
461    /// [`LimitsConfig::collect_errors`].
462    #[serde(default)]
463    pub max_channels: Option<usize>,
464    /// How long a client's correlated conversation request waits for its reply
465    /// before the server answers it timed out, in milliseconds (default 5,000).
466    /// A zero would time every request out on its first slice.
467    #[serde(default = "default_reply_timeout_ms")]
468    pub reply_timeout_ms: u64,
469}
470
471impl LimitsConfig {
472    /// §5 default: total live connections before the listener refuses.
473    pub const DEFAULT_MAX_CONNECTIONS: usize = 256;
474    /// §5 default: subscriptions per connection.
475    pub const DEFAULT_MAX_SUBSCRIPTIONS_PER_CONNECTION: usize = 32;
476    /// §5 default: open conversations per connection.
477    pub const DEFAULT_MAX_CONVERSATIONS_PER_CONNECTION: usize = 32;
478    /// §5 default: in-flight server pushes per connection.
479    pub const DEFAULT_MAX_PENDING_PUSHES_PER_CONNECTION: usize = 32;
480    /// §5 default: pending-reply table entries per connection.
481    pub const DEFAULT_MAX_PENDING_CONVERSATION_REPLIES_PER_CONNECTION: usize = 32;
482    /// §5 default: per-conversation pending-reply sub-cap.
483    pub const DEFAULT_MAX_PENDING_REPLIES_PER_CONVERSATION: usize = 8;
484    /// §5 default: shared per-connection inbox byte budget (4 MiB).
485    pub const DEFAULT_MAX_CONNECTION_INBOX_BYTES: usize = 4 * 1024 * 1024;
486    /// Default per-connection OUTBOUND byte budget (4 MiB) — the twin the inbound
487    /// budget was always described as mirroring, now declared in its own right.
488    ///
489    /// It is the honest single-frame delivery bound: a frame up to this size is
490    /// delivered intact across as many scheduler slices as the peer's read rate
491    /// needs, and a frame LARGER than the whole buffer can never be queued at all.
492    /// The number is unchanged from the buffer's former private constant; what
493    /// changes is that an operator can now see it and set it, instead of meeting
494    /// it only as a connection that stopped carrying something.
495    pub const DEFAULT_MAX_CONNECTION_OUTBOUND_BYTES: usize = 4 * 1024 * 1024;
496    /// Default per-inbox envelope-count fairness trip.
497    ///
498    /// # Why 4096 and not the §5-era 256 (P0 #55)
499    ///
500    /// A subscription inbox is bounded TWICE: by
501    /// [`Self::DEFAULT_MAX_CONNECTION_INBOX_BYTES`] (4 MiB, shared across all of
502    /// one connection's inboxes) and by this envelope COUNT. Memory is the real
503    /// resource, so the byte budget is the bound that should bind and this one is
504    /// meant to be a fairness backstop behind it.
505    ///
506    /// Which one binds first is decided by record size. For a connection holding a
507    /// single subscription, the crossover — the record size at which the two caps
508    /// trip together — is `4 MiB / depth`:
509    ///
510    /// | depth | crossover record size  | binds first for a 1 KiB record |
511    /// |-------|------------------------|--------------------------------|
512    /// | 256   | 4194304/256 = 16 KiB   | the COUNT (at 256 KiB, 6% of 4 MiB) |
513    /// | 4096  | 4194304/4096 = 1 KiB   | the BYTES (as intended)        |
514    ///
515    /// At 256 the count cap therefore binds for every record smaller than 16 KiB —
516    /// which is nearly all real traffic — and it binds at a small fraction of the
517    /// memory the connection is actually permitted: at a 164-byte record, 256
518    /// envelopes is 41 KiB, ~1% of the 4 MiB budget. A replay burst is thousands of
519    /// SMALL records, which is precisely the shape that hits the crude bound while
520    /// the real bound sits untouched. That is the P0: a live subscriber shed for
521    /// exceeding a fairness trip it reached at 1% of its memory allowance.
522    ///
523    /// At 4096 the crossover falls to 1 KiB, so bytes bind for realistic records
524    /// and this cap returns to backstop duty.
525    ///
526    /// Honest bound on that arithmetic: the byte budget is per CONNECTION and this
527    /// cap is per SUBSCRIPTION, so with `S` subscriptions sharing one connection
528    /// the effective crossover is `4 MiB / S / depth`. The table above is the `S=1`
529    /// case. A connection holding many subscriptions of small records can still
530    /// reach the count cap first — which is exactly the sibling-starvation case
531    /// this cap exists to catch.
532    pub const DEFAULT_MAX_SUBSCRIPTION_INBOX_DEPTH: usize = 4096;
533    /// Default per-slice `Deliver` budget for one connection — the source of truth
534    /// for [`crate::config::LimitsConfig::delivery_slice_budget`] and for the
535    /// delivery pump's own `DELIVERY_SLICE_BUDGET`.
536    ///
537    /// # Why this is NOT raised (P0 #55)
538    ///
539    /// This is the cross-connection FAIRNESS bound: it caps how long one connection
540    /// may hold a shared scheduler thread before its peers get a turn. Raising it
541    /// makes a burst subscriber's own drain finish in fewer scheduling round trips,
542    /// and the P0 #55 2x2 measured exactly that — 0/192 boots lost a subscriber at
543    /// 256 against roughly half of them at 32.
544    ///
545    /// That experiment does not license raising the default. It ran two or three
546    /// connections, so the peer starvation a raise would CAUSE was unobservable BY
547    /// CONSTRUCTION: with no queue of waiting peers there is nothing for a longer
548    /// slice to delay. What was measured is that this knob moves the outcome, not
549    /// that moving it is safe.
550    ///
551    /// So the number stays 32 and becomes an operator DECISION instead: a
552    /// deployment that knows its connection count and its burst shape can raise it
553    /// deliberately. The default does not make that trade on anyone's behalf.
554    /// The P0's actual fix is [`Self::DEFAULT_MAX_SUBSCRIPTION_INBOX_DEPTH`].
555    pub const DEFAULT_DELIVERY_SLICE_BUDGET: usize = 32;
556    /// Default wait for a correlated conversation reply: 5 s, the bound a
557    /// client has always been answered within.
558    pub const DEFAULT_REPLY_TIMEOUT_MS: u64 = 5_000;
559
560    /// Validates the caps: every value must be non-zero (a zero cap gates nothing
561    /// — the unlimited-by-silence state §5 outlaws). Errors are accumulated into
562    /// `errors` (one per offending field) so an operator sees every bad cap at
563    /// once, matching the rest of config validation.
564    pub(crate) fn collect_errors(&self, errors: &mut Vec<String>) {
565        let checks: [(&str, usize); 10] = [
566            ("max_connections", self.max_connections),
567            (
568                "max_subscriptions_per_connection",
569                self.max_subscriptions_per_connection,
570            ),
571            (
572                "max_conversations_per_connection",
573                self.max_conversations_per_connection,
574            ),
575            (
576                "max_pending_pushes_per_connection",
577                self.max_pending_pushes_per_connection,
578            ),
579            (
580                "max_pending_conversation_replies_per_connection",
581                self.max_pending_conversation_replies_per_connection,
582            ),
583            (
584                "max_pending_replies_per_conversation",
585                self.max_pending_replies_per_conversation,
586            ),
587            (
588                "max_connection_inbox_bytes",
589                self.max_connection_inbox_bytes,
590            ),
591            // A zero here would leave a connection unable to queue ANY frame at
592            // all — every push and every delivery refused on its first byte.
593            (
594                "max_connection_outbound_bytes",
595                self.max_connection_outbound_bytes,
596            ),
597            (
598                "max_subscription_inbox_depth",
599                self.max_subscription_inbox_depth,
600            ),
601            // A zero here would make the delivery pump enqueue nothing on any
602            // slice — every subscription silently stalled forever, which is the
603            // unlimited-by-silence failure wearing the opposite sign.
604            ("delivery_slice_budget", self.delivery_slice_budget),
605        ];
606        for (field, value) in checks {
607            if value == 0 {
608                errors.push(zero_cap_error(field));
609            }
610        }
611        // `max_channels` is checked BESIDE the array rather than inside it,
612        // because it is the one optional cap and the array's element type has no
613        // way to say "absent". Forcing it in would need a stand-in value for
614        // `None` — a number standing where a declaration is not — and any
615        // stand-in would either invent a bound or make an undeclared cap look
616        // declared. So the shape stays honest and the RULE stays uniform: a
617        // declared zero is refused with the same message as the other eight.
618        //
619        // The parenthetical about unlimited-by-silence is the other eight caps'
620        // ground; for this one a zero would refuse every registration rather
621        // than admit every registration. The message is kept identical anyway:
622        // an operator reading nine cap errors should learn one rule — zero is
623        // not a legal cap value anywhere in this section — not two.
624        if self.max_channels == Some(0) {
625            errors.push(zero_cap_error("max_channels"));
626        }
627        // A duration, not a count, so it sits beside the array too; zero is
628        // refused by the same rule.
629        if self.reply_timeout_ms == 0 {
630            errors.push(zero_cap_error("reply_timeout_ms"));
631        }
632    }
633}
634
635/// The typed refusal for a zero-valued cap, shared by every field in `[limits]`
636/// so the wording cannot drift between them.
637fn zero_cap_error(field: &str) -> String {
638    format!(
639        "limits.{field}: must be greater than zero (a zero cap would be \
640         unlimited-by-silence, which §5 forbids)"
641    )
642}
643
644impl Default for LimitsConfig {
645    fn default() -> Self {
646        Self {
647            max_connections: default_max_connections(),
648            max_subscriptions_per_connection: default_max_subscriptions_per_connection(),
649            max_conversations_per_connection: default_max_conversations_per_connection(),
650            max_pending_pushes_per_connection: default_max_pending_pushes_per_connection(),
651            max_pending_conversation_replies_per_connection:
652                default_max_pending_conversation_replies_per_connection(),
653            max_pending_replies_per_conversation: default_max_pending_replies_per_conversation(),
654            max_connection_inbox_bytes: default_max_connection_inbox_bytes(),
655            max_connection_outbound_bytes: default_max_connection_outbound_bytes(),
656            max_subscription_inbox_depth: default_max_subscription_inbox_depth(),
657            delivery_slice_budget: default_delivery_slice_budget(),
658            // No signed bound exists for this cap, so the default is the
659            // absence of a declaration — never a number.
660            max_channels: None,
661            reply_timeout_ms: default_reply_timeout_ms(),
662        }
663    }
664}
665
666const fn default_max_connections() -> usize {
667    LimitsConfig::DEFAULT_MAX_CONNECTIONS
668}
669const fn default_max_subscriptions_per_connection() -> usize {
670    LimitsConfig::DEFAULT_MAX_SUBSCRIPTIONS_PER_CONNECTION
671}
672const fn default_max_conversations_per_connection() -> usize {
673    LimitsConfig::DEFAULT_MAX_CONVERSATIONS_PER_CONNECTION
674}
675const fn default_max_pending_pushes_per_connection() -> usize {
676    LimitsConfig::DEFAULT_MAX_PENDING_PUSHES_PER_CONNECTION
677}
678const fn default_max_pending_conversation_replies_per_connection() -> usize {
679    LimitsConfig::DEFAULT_MAX_PENDING_CONVERSATION_REPLIES_PER_CONNECTION
680}
681const fn default_max_pending_replies_per_conversation() -> usize {
682    LimitsConfig::DEFAULT_MAX_PENDING_REPLIES_PER_CONVERSATION
683}
684const fn default_max_connection_inbox_bytes() -> usize {
685    LimitsConfig::DEFAULT_MAX_CONNECTION_INBOX_BYTES
686}
687const fn default_max_connection_outbound_bytes() -> usize {
688    LimitsConfig::DEFAULT_MAX_CONNECTION_OUTBOUND_BYTES
689}
690const fn default_max_subscription_inbox_depth() -> usize {
691    LimitsConfig::DEFAULT_MAX_SUBSCRIPTION_INBOX_DEPTH
692}
693const fn default_delivery_slice_budget() -> usize {
694    LimitsConfig::DEFAULT_DELIVERY_SLICE_BUDGET
695}
696
697const fn default_reply_timeout_ms() -> u64 {
698    LimitsConfig::DEFAULT_REPLY_TIMEOUT_MS
699}
700
701/// Participant lifecycle configuration (`[participant]`).
702///
703/// Present iff the deployment activates the participant protocol. Every field
704/// is required — serde carries no defaults here, so a missing field fails
705/// config loading with a typed error naming the field, and
706/// [`ParticipantConfig::collect_errors`] rejects semantically impossible
707/// values during the same accumulated validation pass as the rest of the
708/// config. All values are deployment-owner decisions (no assumed defaults).
709///
710/// Every field here is consumed by the live production handler. Frontier and
711/// retention limits are required inputs; there are no deployment defaults.
712#[derive(Debug, Clone, Copy, serde::Deserialize)]
713#[serde(deny_unknown_fields)]
714pub struct ParticipantConfig {
715    /// Complete participant wire-frame limit (`WF`) negotiated with every
716    /// participant-capable connection. Must be at least the protocol's
717    /// minimum complete frame; enforced by the shared codec at service
718    /// construction and pre-checked during config validation.
719    pub wire_frame_limit: u64,
720    /// Secret-bearing attach/enrollment receipt lifetime in milliseconds.
721    pub attach_receipt_ttl_ms: u64,
722    /// Non-secret receipt-provenance lifetime in milliseconds. Must be at
723    /// least `attach_receipt_ttl_ms` (provenance explains the receipt and
724    /// cannot expire first).
725    pub receipt_provenance_ttl_ms: u64,
726    /// Reporting threshold for server-wide live secret-bearing receipts
727    /// (stage-8 shared scope `LiveReceiptServer`).
728    ///
729    /// Lane p0-39: this scope is NOT an admission gate. It is where an honest
730    /// third party would meet a number someone else's churn consumed, and no
731    /// configured refusal is tolerable there — retention is bounded by
732    /// `attach_receipt_ttl_ms` alone. This number is a TRIPWIRE: when
733    /// in-window occupancy reaches it, the server counts the observation and
734    /// warns on the rising edge. Nothing is ever refused because of it.
735    /// Setting it higher than the deployment's plausible steady state is the
736    /// point; a churn storm is what it is meant to disclose.
737    pub live_receipt_server_report_threshold: u64,
738    /// Per-participant WINDOW SIZE for live secret-bearing receipts (stage-8
739    /// scope `LiveReceiptParticipant`).
740    ///
741    /// Lane p0-39: this is a bound on retention, not a refusal threshold. At
742    /// a full window the participant's own oldest live receipt is displaced
743    /// and the new one lands, so a rotation can never be refused by the very
744    /// receipt it is about to end. Occupancy never exceeds this value.
745    pub max_live_attach_receipts_per_participant: u64,
746    /// Reporting threshold for server-wide retained non-secret provenance
747    /// fingerprints (stage-8 shared scope `ProvenanceServer`).
748    ///
749    /// Lane p0-39: a tripwire, never a gate — see
750    /// [`Self::live_receipt_server_report_threshold`]. Retention here is
751    /// bounded by `receipt_provenance_ttl_ms` alone.
752    ///
753    /// Board #37 (ruling 2026-08-12): a fingerprint occupies from the moment
754    /// the client proves it possesses the secret its receipt minted through
755    /// that receipt's own provenance deadline — not from the minting commit.
756    /// A receipt whose delivery was never observed classifies through its
757    /// window but is never counted here, so an enrol-and-crash cycle leaves
758    /// no provenance residue behind it.
759    pub receipt_provenance_server_report_threshold: u64,
760    /// Reporting threshold for per-conversation provenance fingerprints
761    /// (stage-8 shared scope `ProvenanceConversation`). A tripwire, never a
762    /// gate: one participant's churn must not refuse the next participant of
763    /// the same conversation.
764    pub receipt_provenance_per_conversation_report_threshold: u64,
765    /// Per-participant WINDOW SIZE for provenance fingerprints (stage-8 scope
766    /// `ProvenanceParticipant`).
767    ///
768    /// Lane p0-39: the participant's retained fingerprints — one per
769    /// committed rotation, plus its proven enrollment fingerprint — are held
770    /// to this many, oldest displaced first. Per-participant pressure is
771    /// self-inflicted, so the number bounds memory without refusing: the
772    /// (N+1)th honest fingerprint always lands, including on cold replay from
773    /// durable state. Occupancy never exceeds this value.
774    pub max_receipt_provenance_per_participant: u64,
775    /// Server-wide identity-slot limit (the contract's
776    /// `max_retired_identity_slots` server scope): the total number of
777    /// participant identities — live or retired — mintable across ALL
778    /// conversations. Enrollment refuses with server-scope
779    /// `IdentityCapacityExceeded` (tested BEFORE the conversation scope)
780    /// when every slot is reserved.
781    pub max_retired_identity_slots_server: u64,
782    /// Per-CONVERSATION identity limit `I` (the contract's half-open
783    /// `0..=I` bound on permanent participant ordinals — the conversation
784    /// scope of `max_retired_identity_slots`, NOT a per-participant
785    /// reservation). Enrollment assigns monotone participant indices in
786    /// `0..I` within one conversation and refuses with conversation-scope
787    /// `IdentityCapacityExceeded` when occupancy reaches this value; slots
788    /// and ids are never reused. The server-wide companion is
789    /// [`Self::max_retired_identity_slots_server`].
790    pub identity_slots: u64,
791    /// Maximum entries one observer-recovery handshake batch may name.
792    pub observer_recovery_max_entries: u64,
793    /// Semantic conversations one connection may track — the protocol's
794    /// signed connection-conversation limit. Consumed on BOTH of its contract
795    /// paths: the stage-6 capacity gate every conversation-scoped semantic
796    /// operation runs (register row 5641) and the observer-recovery batch
797    /// preflight (register row 5642), over one shared per-connection
798    /// dispatch map.
799    pub max_semantic_conversations_per_connection: u64,
800    /// Maximum canonical entries in one ordinary retained-record row.
801    pub max_ordinary_record_entries: u64,
802    /// Maximum canonical bytes in one ordinary retained-record row.
803    pub max_ordinary_record_bytes: u64,
804    /// Maximum canonical entries in one generated marker row.
805    pub max_generated_marker_entries: u64,
806    /// Maximum canonical bytes in one generated marker row.
807    pub max_generated_marker_bytes: u64,
808    /// Entry component of the mandatory transaction envelope `Q`.
809    pub mandatory_transaction_bound_entries: u64,
810    /// Byte component of the mandatory transaction envelope `Q`.
811    pub mandatory_transaction_bound_bytes: u64,
812    /// Entry component of the full recovery claim `K`.
813    pub full_recovery_claim_entries: u64,
814    /// Byte component of the full recovery claim `K`.
815    pub full_recovery_claim_bytes: u64,
816    /// Total retained durable entry capacity per conversation.
817    pub retained_capacity_entries: u64,
818    /// Total retained canonical-byte capacity per conversation.
819    pub retained_capacity_bytes: u64,
820    /// Maximum retained causal-record rows restored for one conversation.
821    pub max_retained_record_rows: u64,
822    /// `[participant.pruning]` (DUR-018, DUR-024 R4): absent, acknowledged
823    /// rows behind the newest checkpoint are cut keeping the pinned default
824    /// (`DEFAULT_KEEP_ACKNOWLEDGED_BEHIND_CHECKPOINT`, 512 rows) and the
825    /// server says so once at start. Present, the configured value governs.
826    /// Pruning is never off.
827    #[serde(default)]
828    pub pruning: Option<PruningConfig>,
829    /// Maximum closure churn cycles in one episode.
830    pub closure_episode_churn_limit: u64,
831}
832
833impl ParticipantConfig {
834    /// Accumulates semantic validation errors for the participant section.
835    ///
836    /// Zero is rejected wherever it would be unlimited-by-silence, gate
837    /// nothing, or violate a protocol precondition; the TTL ordering mirrors
838    /// the protocol's own frozen configuration precedence.
839    pub(crate) fn collect_errors(&self, errors: &mut Vec<String>) {
840        // The receipt/identity block follows the contract's frozen nine-field
841        // validation order: both TTLs, the five receipt/provenance caps, the
842        // server identity limit, then the conversation identity limit.
843        let nonzero: [(&str, u64); 23] = [
844            ("wire_frame_limit", self.wire_frame_limit),
845            ("attach_receipt_ttl_ms", self.attach_receipt_ttl_ms),
846            ("receipt_provenance_ttl_ms", self.receipt_provenance_ttl_ms),
847            (
848                "live_receipt_server_report_threshold",
849                self.live_receipt_server_report_threshold,
850            ),
851            (
852                "max_live_attach_receipts_per_participant",
853                self.max_live_attach_receipts_per_participant,
854            ),
855            (
856                "receipt_provenance_server_report_threshold",
857                self.receipt_provenance_server_report_threshold,
858            ),
859            (
860                "receipt_provenance_per_conversation_report_threshold",
861                self.receipt_provenance_per_conversation_report_threshold,
862            ),
863            (
864                "max_receipt_provenance_per_participant",
865                self.max_receipt_provenance_per_participant,
866            ),
867            (
868                "max_retired_identity_slots_server",
869                self.max_retired_identity_slots_server,
870            ),
871            ("identity_slots", self.identity_slots),
872            (
873                "observer_recovery_max_entries",
874                self.observer_recovery_max_entries,
875            ),
876            (
877                "max_semantic_conversations_per_connection",
878                self.max_semantic_conversations_per_connection,
879            ),
880            (
881                "max_ordinary_record_entries",
882                self.max_ordinary_record_entries,
883            ),
884            ("max_ordinary_record_bytes", self.max_ordinary_record_bytes),
885            (
886                "max_generated_marker_entries",
887                self.max_generated_marker_entries,
888            ),
889            (
890                "max_generated_marker_bytes",
891                self.max_generated_marker_bytes,
892            ),
893            (
894                "mandatory_transaction_bound_entries",
895                self.mandatory_transaction_bound_entries,
896            ),
897            (
898                "mandatory_transaction_bound_bytes",
899                self.mandatory_transaction_bound_bytes,
900            ),
901            (
902                "full_recovery_claim_entries",
903                self.full_recovery_claim_entries,
904            ),
905            ("full_recovery_claim_bytes", self.full_recovery_claim_bytes),
906            ("retained_capacity_entries", self.retained_capacity_entries),
907            ("retained_capacity_bytes", self.retained_capacity_bytes),
908            ("max_retained_record_rows", self.max_retained_record_rows),
909        ];
910        for (field, value) in nonzero {
911            if value == 0 {
912                errors.push(format!("participant.{field}: must be greater than zero"));
913            }
914        }
915        if self.receipt_provenance_ttl_ms < self.attach_receipt_ttl_ms {
916            errors.push(
917                "participant.receipt_provenance_ttl_ms: must be at least \
918                 attach_receipt_ttl_ms (provenance cannot expire before the receipt it explains)"
919                    .to_owned(),
920            );
921        }
922        if self.full_recovery_claim_entries != self.mandatory_transaction_bound_entries {
923            errors.push(
924                "participant.full_recovery_claim_entries: must equal \
925                 mandatory_transaction_bound_entries"
926                    .to_owned(),
927            );
928        }
929        if self.full_recovery_claim_bytes != self.mandatory_transaction_bound_bytes {
930            errors.push(
931                "participant.full_recovery_claim_bytes: must equal \
932                 mandatory_transaction_bound_bytes"
933                    .to_owned(),
934            );
935        }
936        if !(2..=u64::from(u32::MAX)).contains(&self.closure_episode_churn_limit) {
937            errors.push(
938                "participant.closure_episode_churn_limit: must be in 2..=u32::MAX".to_owned(),
939            );
940        }
941        self.collect_unit2_derived_errors(errors);
942    }
943
944    fn collect_unit2_derived_errors(&self, errors: &mut Vec<String>) {
945        if self
946            .max_retained_record_rows
947            .checked_mul(self.identity_slots)
948            .is_none()
949        {
950            errors.push("participant.UNIT2_MAX_LIVE_RECIPIENT_OBLIGATIONS: max_retained_record_rows * identity_slots overflows u64".to_owned());
951        }
952    }
953}
954
955/// Which connection-services adapter the server constructs (D2).
956#[derive(Debug, Clone, Copy, PartialEq, Eq)]
957pub enum ServiceProfile {
958    /// Full channel/conversation/durability services — the default. Constructs the
959    /// haematite store, channel supervisor, conversation supervisor, and dedup
960    /// cache exactly as before.
961    Full,
962    /// Capability-scoped worker front door: the connection supervisor only, with no
963    /// channel/conversation/haematite machinery. Backs worker registration,
964    /// correlated push/reply, and notifier-consumed reserved publishes; ordinary
965    /// channel and conversation frames are rejected with a typed error frame.
966    WorkerFrontDoor,
967}
968
969impl ServiceProfile {
970    /// Config value selecting the full-service profile.
971    pub const FULL: &'static str = "full";
972    /// Config value selecting the worker-front-door profile.
973    pub const WORKER_FRONT_DOOR: &'static str = "worker-front-door";
974
975    /// Parses a `[services] profile` value into a typed profile.
976    ///
977    /// # Errors
978    /// Returns [`ServerError::ConfigValidation`] for any value other than
979    /// [`Self::FULL`] or [`Self::WORKER_FRONT_DOOR`].
980    pub fn parse(value: &str) -> Result<Self, ServerError> {
981        match value {
982            Self::FULL => Ok(Self::Full),
983            Self::WORKER_FRONT_DOOR => Ok(Self::WorkerFrontDoor),
984            other => Err(ServerError::ConfigValidation {
985                message: format!(
986                    "services.profile: unknown profile '{other}'; expected \"{}\" or \"{}\"",
987                    Self::FULL,
988                    Self::WORKER_FRONT_DOOR
989                ),
990            }),
991        }
992    }
993}
994
995/// `[auth.lys]`: the Lys issuer whose passes are admitted (ACCESS-001 R2).
996#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
997#[serde(deny_unknown_fields)]
998pub struct LysConfig {
999    /// The issuer URL exactly as the pass's `iss` names it.
1000    pub issuer: String,
1001    /// The Lys network liminal's subjects are placed under.
1002    pub network_id: String,
1003    /// Path to the issuer's published key set as JSON, read once at start.
1004    pub keys_path: std::path::PathBuf,
1005    /// Ask the issuer live for deliberate decisions (opening a link,
1006    /// deploying a service, an administrator's act) with the holder's own
1007    /// pass. Off, Lys cannot be asked and every deliberate route refuses by
1008    /// that name; it never falls back to the pass.
1009    #[serde(default)]
1010    pub live: bool,
1011    /// This liminal as a machine Lys knows, for links (ACCESS-002 R1).
1012    #[serde(default)]
1013    pub machine: Option<MachineConfig>,
1014    /// Argus's functions as liminal services, bound to Lys and the server's
1015    /// publish path (ACCESS-003 R1).
1016    #[serde(default)]
1017    pub services: Option<LysServicesConfig>,
1018    /// Address-only broker and its observed-address UDP echo.
1019    #[serde(default)]
1020    pub broker: Option<BrokerConfig>,
1021    /// Blind forwarding of marked relay streams.
1022    #[serde(default)]
1023    pub relay: Option<crate::services::relay::RelayConfig>,
1024    /// Channel membership from Lys's one shared membership contract
1025    /// (ACCESS-006 R1): the explicit product mapping of liminal channels to
1026    /// placed Lys resources, and the provider route it is asked through.
1027    /// Absent, no channel is membership-protected and every channel keeps
1028    /// its prior contract.
1029    #[serde(default)]
1030    pub membership: Option<MembershipConfig>,
1031    /// The templates services are created from (SRV-028 R1): the only
1032    /// command lines this server will ever run for a seat.
1033    #[serde(default)]
1034    pub templates: Vec<ServiceTemplateConfig>,
1035    /// The base of Lys's API routes (`.../api/`) for live asks, placements
1036    /// and the service routes, when they are not served at the issuer
1037    /// itself; absent, the issuer is the base, as before.
1038    #[serde(default)]
1039    pub api_url: Option<String>,
1040    /// Path to this liminal's Lys app credential, read once at start; a new
1041    /// service and its subjects are placed under the network with it
1042    /// (SRV-028 R3). Absent, every create is refused by name.
1043    #[serde(default)]
1044    pub app_credential_path: Option<std::path::PathBuf>,
1045}
1046
1047/// `[[auth.lys.templates]]`: one command a seat may create services from
1048/// (SRV-028 R1). Read-only at run time.
1049#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
1050#[serde(deny_unknown_fields)]
1051pub struct ServiceTemplateConfig {
1052    /// The template's name, one segment.
1053    pub name: String,
1054    /// The program, an absolute path to an executable file.
1055    pub command: std::path::PathBuf,
1056    /// Its arguments, passed as written.
1057    #[serde(default)]
1058    pub args: Vec<String>,
1059    /// The subject roots a service of this template serves; each service
1060    /// serves `<root>.<service name>`.
1061    pub subjects: Vec<String>,
1062    /// Names of variables passed through from the server's environment;
1063    /// nothing else of it reaches the process.
1064    #[serde(default)]
1065    pub env: Vec<String>,
1066    /// How long a stop waits after SIGTERM before SIGKILL; positive.
1067    pub stop_timeout_ms: u64,
1068}
1069
1070/// `[auth.lys.membership]`: channel membership bound to the verified holder
1071/// and the placed resource (ACCESS-006 R1).
1072///
1073/// The mapping is explicit: a channel is membership-protected only when it is
1074/// listed here with its workspace and placed resource, and a participant
1075/// conversation is placed under a channel only when it is listed here. No
1076/// mapping is guessed from a subject string, and a readable parent is never
1077/// taken as membership of a child. Every bound is required and positive;
1078/// none has a default. Validated whole when the membership gate is built at
1079/// start ([`crate::auth_membership::MembershipGate::new`]).
1080#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
1081#[serde(deny_unknown_fields)]
1082pub struct MembershipConfig {
1083    /// The membership contract version this liminal speaks; it must equal
1084    /// `lys_pass::membership::CONTRACT_VERSION`.
1085    pub contract: u32,
1086    /// The identity of the Lys grant log decisions are asked from.
1087    pub log_identity: String,
1088    /// The reset epoch of that grant log.
1089    pub log_epoch: u64,
1090    /// The product schema's workspace kind, as Lys places it.
1091    pub workspace_kind: String,
1092    /// The product schema's channel kind, as Lys places it.
1093    pub channel_kind: String,
1094    /// Path to this liminal's app credential for the Lys membership routes,
1095    /// read once at start. Its bytes never appear in a `Debug`.
1096    pub credential_path: std::path::PathBuf,
1097    /// Most rows one membership page carries; positive.
1098    pub page_rows: u32,
1099    /// Most encoded bytes one membership page carries; positive.
1100    pub page_bytes: u32,
1101    /// Most decisions the local membership index retains at once; positive.
1102    /// A decision past it is refused by name before anything is allocated
1103    /// for it, never evicted to make room.
1104    pub max_decisions: usize,
1105    /// The workspace a Lys-admitted `Connect` must hold a current grant in
1106    /// (scoped guest admission). Absent, `Connect` asks no admission and a
1107    /// protected channel is still decided per act.
1108    #[serde(default)]
1109    pub connect_workspace: Option<String>,
1110    /// The membership-protected channels.
1111    pub channels: Vec<MembershipChannelConfig>,
1112    /// Participant conversations placed under a protected channel.
1113    #[serde(default)]
1114    pub conversations: Vec<MembershipConversationConfig>,
1115}
1116
1117/// One membership-protected channel: the liminal channel name, the
1118/// workspace it is placed under and the placed resource id.
1119#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
1120#[serde(deny_unknown_fields)]
1121pub struct MembershipChannelConfig {
1122    /// The liminal channel (subject) name, matched exactly.
1123    pub channel: String,
1124    /// The workspace resource id the channel is placed under.
1125    pub workspace: String,
1126    /// The placed channel resource id.
1127    pub resource: String,
1128}
1129
1130/// One participant conversation placed under a protected channel.
1131#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
1132#[serde(deny_unknown_fields)]
1133pub struct MembershipConversationConfig {
1134    /// The participant conversation id.
1135    pub conversation: u64,
1136    /// The protected channel it is placed under, as listed in `channels`.
1137    pub channel: String,
1138}
1139
1140/// `[auth.lys.services]`: the production bindings of the ACCESS-003
1141/// services. They ask Lys as this machine, so `[auth.lys.machine]` is
1142/// required beside it.
1143#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
1144#[serde(deny_unknown_fields)]
1145pub struct LysServicesConfig {
1146    /// The machine id Lys records, on whose runner `seats.<name>` starts.
1147    pub machine: String,
1148    /// The host name service messages are published as (`liminal.<host>`).
1149    pub host: String,
1150    /// Run the context watch over the `context-watch` channel, reporting
1151    /// each reading to Lys.
1152    #[serde(default)]
1153    pub context_watch: bool,
1154}
1155
1156/// `[auth.lys.broker]`: the address-only broker's UDP echo listener.
1157#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
1158#[serde(deny_unknown_fields)]
1159pub struct BrokerConfig {
1160    /// The socket on which a device asks for its observed address.
1161    pub udp_listen: SocketAddr,
1162}
1163
1164/// `[auth.lys.machine]`: this liminal's agent identity and pass.
1165///
1166/// Its links are keyed from the Ed25519 identity Lys recorded for it,
1167/// its own end of every crossing is decided from its own pass, and the
1168/// holders that may open links to it are named with their public keys.
1169/// The link identity is an agent's own key and pass. The machine section
1170/// name is retained so existing file-pass configurations still read.
1171#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
1172#[serde(deny_unknown_fields)]
1173pub struct MachineConfig {
1174    /// Carrier order and message-scoped direct deadlines.
1175    #[serde(default)]
1176    pub carrier_choice: crate::link_open::ChoiceConfig,
1177    /// The device this machine is a district of, when configured.
1178    #[serde(default)]
1179    pub parent: Option<String>,
1180    /// Path to the agent's Ed25519 seed, 64 hexadecimal digits, read once
1181    /// at start. The link handshake keys from its X25519 derivation.
1182    pub identity_path: std::path::PathBuf,
1183    /// Path to the agent's own Lys pass, verified at start; absent when
1184    /// the signed agent dial is configured.
1185    #[serde(default)]
1186    pub pass_path: std::path::PathBuf,
1187    /// The agent whose capability certificate names this identity's key.
1188    #[serde(default)]
1189    pub agent_id: String,
1190    /// The app the agent asks the issuer for a pass to.
1191    #[serde(default)]
1192    pub audience: String,
1193    /// The public key recorded for each holder that may open a link,
1194    /// holder id to Ed25519 public key as 64 hexadecimal digits.
1195    #[serde(default)]
1196    pub machines: std::collections::BTreeMap<String, String>,
1197    /// Outbound links keyed from this machine, each configured by name.
1198    #[serde(default)]
1199    pub links: Vec<OutboundLinkConfig>,
1200}
1201
1202/// One exporting link; an absent wire address leaves carrier choice to the caller.
1203#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
1204#[serde(deny_unknown_fields)]
1205pub struct OutboundLinkConfig {
1206    /// The link id placed under the configured network.
1207    pub link_id: String,
1208    /// The holder whose Ed25519 key is recorded in this machine's directory mirror.
1209    pub peer_holder: String,
1210    /// The peer's wire listener as host:port, if this link uses the wire.
1211    #[serde(default)]
1212    pub peer_address: Option<String>,
1213    /// Placed subject prefixes exported over this link.
1214    pub exports: Vec<String>,
1215}
1216
1217/// `[participant.pruning]`: the one bound pruning has (DUR-018 R1). It is a
1218/// bound on acknowledged rows behind a checkpoint, never on a conversation
1219/// log's row count or age.
1220#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
1221#[serde(deny_unknown_fields)]
1222pub struct PruningConfig {
1223    /// Acknowledged rows kept behind the newest checkpoint; everything
1224    /// acknowledged below them is cut, once a receipt is written.
1225    pub keep_acknowledged_behind_checkpoint: u64,
1226}