liminal_server/config/types/participant.rs
1/// Participant lifecycle configuration (`[participant]`).
2///
3/// Present iff the deployment activates the participant protocol. Every field
4/// is required — serde carries no defaults here, so a missing field fails
5/// config loading with a typed error naming the field, and
6/// [`ParticipantConfig::collect_errors`] rejects semantically impossible
7/// values during the same accumulated validation pass as the rest of the
8/// config. All values are deployment-owner decisions (no assumed defaults).
9///
10/// Every field here is consumed by the live production handler. Frontier and
11/// retention limits are required inputs; there are no deployment defaults.
12#[derive(Debug, Clone, Copy, serde::Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct ParticipantConfig {
15 /// Complete participant wire-frame limit (`WF`) negotiated with every
16 /// participant-capable connection. Must be at least the protocol's
17 /// minimum complete frame; enforced by the shared codec at service
18 /// construction and pre-checked during config validation.
19 pub wire_frame_limit: u64,
20 /// Secret-bearing attach/enrollment receipt lifetime in milliseconds.
21 pub attach_receipt_ttl_ms: u64,
22 /// Non-secret receipt-provenance lifetime in milliseconds. Must be at
23 /// least `attach_receipt_ttl_ms` (provenance explains the receipt and
24 /// cannot expire first).
25 pub receipt_provenance_ttl_ms: u64,
26 /// Reporting threshold for server-wide live secret-bearing receipts
27 /// (stage-8 shared scope `LiveReceiptServer`).
28 ///
29 /// Lane p0-39: this scope is NOT an admission gate. It is where an honest
30 /// third party would meet a number someone else's churn consumed, and no
31 /// configured refusal is tolerable there — retention is bounded by
32 /// `attach_receipt_ttl_ms` alone. This number is a TRIPWIRE: when
33 /// in-window occupancy reaches it, the server counts the observation and
34 /// warns on the rising edge. Nothing is ever refused because of it.
35 /// Setting it higher than the deployment's plausible steady state is the
36 /// point; a churn storm is what it is meant to disclose.
37 pub live_receipt_server_report_threshold: u64,
38 /// Per-participant WINDOW SIZE for live secret-bearing receipts (stage-8
39 /// scope `LiveReceiptParticipant`).
40 ///
41 /// Lane p0-39: this is a bound on retention, not a refusal threshold. At
42 /// a full window the participant's own oldest live receipt is displaced
43 /// and the new one lands, so a rotation can never be refused by the very
44 /// receipt it is about to end. Occupancy never exceeds this value.
45 pub max_live_attach_receipts_per_participant: u64,
46 /// Reporting threshold for server-wide retained non-secret provenance
47 /// fingerprints (stage-8 shared scope `ProvenanceServer`).
48 ///
49 /// Lane p0-39: a tripwire, never a gate — see
50 /// [`Self::live_receipt_server_report_threshold`]. Retention here is
51 /// bounded by `receipt_provenance_ttl_ms` alone.
52 ///
53 /// Board #37 (ruling 2026-08-12): a fingerprint occupies from the moment
54 /// the client proves it possesses the secret its receipt minted through
55 /// that receipt's own provenance deadline — not from the minting commit.
56 /// A receipt whose delivery was never observed classifies through its
57 /// window but is never counted here, so an enrol-and-crash cycle leaves
58 /// no provenance residue behind it.
59 pub receipt_provenance_server_report_threshold: u64,
60 /// Reporting threshold for per-conversation provenance fingerprints
61 /// (stage-8 shared scope `ProvenanceConversation`). A tripwire, never a
62 /// gate: one participant's churn must not refuse the next participant of
63 /// the same conversation.
64 pub receipt_provenance_per_conversation_report_threshold: u64,
65 /// Per-participant WINDOW SIZE for provenance fingerprints (stage-8 scope
66 /// `ProvenanceParticipant`).
67 ///
68 /// Lane p0-39: the participant's retained fingerprints — one per
69 /// committed rotation, plus its proven enrollment fingerprint — are held
70 /// to this many, oldest displaced first. Per-participant pressure is
71 /// self-inflicted, so the number bounds memory without refusing: the
72 /// (N+1)th honest fingerprint always lands, including on cold replay from
73 /// durable state. Occupancy never exceeds this value.
74 pub max_receipt_provenance_per_participant: u64,
75 /// Server-wide identity-slot limit (the contract's
76 /// `max_retired_identity_slots` server scope): the total number of
77 /// participant identities — live or retired — mintable across ALL
78 /// conversations. Enrollment refuses with server-scope
79 /// `IdentityCapacityExceeded` (tested BEFORE the conversation scope)
80 /// when every slot is reserved.
81 pub max_retired_identity_slots_server: u64,
82 /// Per-CONVERSATION identity limit `I` (the contract's half-open
83 /// `0..=I` bound on permanent participant ordinals — the conversation
84 /// scope of `max_retired_identity_slots`, NOT a per-participant
85 /// reservation). Enrollment assigns monotone participant indices in
86 /// `0..I` within one conversation and refuses with conversation-scope
87 /// `IdentityCapacityExceeded` when occupancy reaches this value; slots
88 /// and ids are never reused. The server-wide companion is
89 /// [`Self::max_retired_identity_slots_server`].
90 pub identity_slots: u64,
91 /// Maximum entries one observer-recovery handshake batch may name.
92 pub observer_recovery_max_entries: u64,
93 /// Semantic conversations one connection may track — the protocol's
94 /// signed connection-conversation limit. Consumed on BOTH of its contract
95 /// paths: the stage-6 capacity gate every conversation-scoped semantic
96 /// operation runs (register row 5641) and the observer-recovery batch
97 /// preflight (register row 5642), over one shared per-connection
98 /// dispatch map.
99 pub max_semantic_conversations_per_connection: u64,
100 /// Maximum canonical entries in one ordinary retained-record row.
101 pub max_ordinary_record_entries: u64,
102 /// Maximum canonical bytes in one ordinary retained-record row.
103 pub max_ordinary_record_bytes: u64,
104 /// Maximum canonical entries in one generated marker row.
105 pub max_generated_marker_entries: u64,
106 /// Maximum canonical bytes in one generated marker row.
107 pub max_generated_marker_bytes: u64,
108 /// Entry component of the mandatory transaction envelope `Q`.
109 pub mandatory_transaction_bound_entries: u64,
110 /// Byte component of the mandatory transaction envelope `Q`.
111 pub mandatory_transaction_bound_bytes: u64,
112 /// Entry component of the full recovery claim `K`.
113 pub full_recovery_claim_entries: u64,
114 /// Byte component of the full recovery claim `K`.
115 pub full_recovery_claim_bytes: u64,
116 /// Total retained durable entry capacity per conversation.
117 pub retained_capacity_entries: u64,
118 /// Total retained canonical-byte capacity per conversation.
119 pub retained_capacity_bytes: u64,
120 /// Maximum retained causal-record rows restored for one conversation.
121 pub max_retained_record_rows: u64,
122 /// `[participant.pruning]` (DUR-018, DUR-024 R4): absent, acknowledged
123 /// rows behind the newest checkpoint are cut keeping the pinned default
124 /// (`DEFAULT_KEEP_ACKNOWLEDGED_BEHIND_CHECKPOINT`, 512 rows) and the
125 /// server says so once at start. Present, the configured value governs.
126 /// Pruning is never off.
127 #[serde(default)]
128 pub pruning: Option<PruningConfig>,
129 /// Maximum closure churn cycles in one episode.
130 pub closure_episode_churn_limit: u64,
131}
132
133impl ParticipantConfig {
134 /// Accumulates semantic validation errors for the participant section.
135 ///
136 /// Zero is rejected wherever it would be unlimited-by-silence, gate
137 /// nothing, or violate a protocol precondition; the TTL ordering mirrors
138 /// the protocol's own frozen configuration precedence.
139 pub(crate) fn collect_errors(&self, errors: &mut Vec<String>) {
140 // The receipt/identity block follows the contract's frozen nine-field
141 // validation order: both TTLs, the five receipt/provenance caps, the
142 // server identity limit, then the conversation identity limit.
143 let nonzero: [(&str, u64); 23] = [
144 ("wire_frame_limit", self.wire_frame_limit),
145 ("attach_receipt_ttl_ms", self.attach_receipt_ttl_ms),
146 ("receipt_provenance_ttl_ms", self.receipt_provenance_ttl_ms),
147 (
148 "live_receipt_server_report_threshold",
149 self.live_receipt_server_report_threshold,
150 ),
151 (
152 "max_live_attach_receipts_per_participant",
153 self.max_live_attach_receipts_per_participant,
154 ),
155 (
156 "receipt_provenance_server_report_threshold",
157 self.receipt_provenance_server_report_threshold,
158 ),
159 (
160 "receipt_provenance_per_conversation_report_threshold",
161 self.receipt_provenance_per_conversation_report_threshold,
162 ),
163 (
164 "max_receipt_provenance_per_participant",
165 self.max_receipt_provenance_per_participant,
166 ),
167 (
168 "max_retired_identity_slots_server",
169 self.max_retired_identity_slots_server,
170 ),
171 ("identity_slots", self.identity_slots),
172 (
173 "observer_recovery_max_entries",
174 self.observer_recovery_max_entries,
175 ),
176 (
177 "max_semantic_conversations_per_connection",
178 self.max_semantic_conversations_per_connection,
179 ),
180 (
181 "max_ordinary_record_entries",
182 self.max_ordinary_record_entries,
183 ),
184 ("max_ordinary_record_bytes", self.max_ordinary_record_bytes),
185 (
186 "max_generated_marker_entries",
187 self.max_generated_marker_entries,
188 ),
189 (
190 "max_generated_marker_bytes",
191 self.max_generated_marker_bytes,
192 ),
193 (
194 "mandatory_transaction_bound_entries",
195 self.mandatory_transaction_bound_entries,
196 ),
197 (
198 "mandatory_transaction_bound_bytes",
199 self.mandatory_transaction_bound_bytes,
200 ),
201 (
202 "full_recovery_claim_entries",
203 self.full_recovery_claim_entries,
204 ),
205 ("full_recovery_claim_bytes", self.full_recovery_claim_bytes),
206 ("retained_capacity_entries", self.retained_capacity_entries),
207 ("retained_capacity_bytes", self.retained_capacity_bytes),
208 ("max_retained_record_rows", self.max_retained_record_rows),
209 ];
210 for (field, value) in nonzero {
211 if value == 0 {
212 errors.push(format!("participant.{field}: must be greater than zero"));
213 }
214 }
215 if self.receipt_provenance_ttl_ms < self.attach_receipt_ttl_ms {
216 errors.push(
217 "participant.receipt_provenance_ttl_ms: must be at least \
218 attach_receipt_ttl_ms (provenance cannot expire before the receipt it explains)"
219 .to_owned(),
220 );
221 }
222 if self.full_recovery_claim_entries != self.mandatory_transaction_bound_entries {
223 errors.push(
224 "participant.full_recovery_claim_entries: must equal \
225 mandatory_transaction_bound_entries"
226 .to_owned(),
227 );
228 }
229 if self.full_recovery_claim_bytes != self.mandatory_transaction_bound_bytes {
230 errors.push(
231 "participant.full_recovery_claim_bytes: must equal \
232 mandatory_transaction_bound_bytes"
233 .to_owned(),
234 );
235 }
236 if !(2..=u64::from(u32::MAX)).contains(&self.closure_episode_churn_limit) {
237 errors.push(
238 "participant.closure_episode_churn_limit: must be in 2..=u32::MAX".to_owned(),
239 );
240 }
241 self.collect_unit2_derived_errors(errors);
242 }
243
244 fn collect_unit2_derived_errors(&self, errors: &mut Vec<String>) {
245 if self
246 .max_retained_record_rows
247 .checked_mul(self.identity_slots)
248 .is_none()
249 {
250 errors.push("participant.UNIT2_MAX_LIVE_RECIPIENT_OBLIGATIONS: max_retained_record_rows * identity_slots overflows u64".to_owned());
251 }
252 }
253}
254/// `[participant.pruning]`: the one bound pruning has (DUR-018 R1). It is a
255/// bound on acknowledged rows behind a checkpoint, never on a conversation
256/// log's row count or age.
257#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
258#[serde(deny_unknown_fields)]
259pub struct PruningConfig {
260 /// Acknowledged rows kept behind the newest checkpoint; everything
261 /// acknowledged below them is cut, once a receipt is written.
262 pub keep_acknowledged_behind_checkpoint: u64,
263}