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