Skip to main content

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}