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