liminal_server/health/reissue.rs
1//! The operator WRITE surface for authorized credential re-issue (R18
2//! amendment A7, `PARTICIPANT-CONTRACT.md` §0.18).
3//!
4//! `OperatorCredentialReissue` is an operation of the operator surface — the
5//! same trust plane that serves `GET /unloadable-conversations` — and never of
6//! the participant wire. The participant protocol is byte-identical under A7:
7//! nothing here adds a wire frame, a response variant, or a protocol-crate
8//! delta.
9//!
10//! This module holds the operation's SHAPE and its plumbing. Like its
11//! read-surface sibling it is PULL-ONLY in the scheduling sense: the operation
12//! runs exactly when an operator calls it, and nothing here starts a thread,
13//! arms a timer, samples a clock, or sweeps anything. The endpoint's
14//! zero-idle-wake property (W4 leg 2, LAW-1) is untouched — a node nobody
15//! calls does no work at all for this surface.
16//!
17//! # The secret transits exactly once
18//!
19//! [`OperatorCredentialReissued::attach_secret`] is the ONE delivery of the
20//! minted credential (§0.18 item 4). There is deliberately no receipt replay
21//! for operator issue: R-C0's receipt machinery is untouched by A7, so a lost
22//! response is repaired by repeating the operation, and the
23//! [`OperatorCredentialReissueRefusal::GenerationMismatch`] payload is what
24//! tells an operator who lost the response what the post-rotation generation
25//! is. That payload is NORMATIVE, not a courtesy.
26
27use std::fmt::Write as _;
28use std::sync::{Arc, Mutex, PoisonError};
29
30use liminal_protocol::wire::{ConversationId, ParticipantId};
31
32/// The complete input of one `OperatorCredentialReissue` (§0.18 item 1).
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub struct OperatorCredentialReissueRequest {
35 /// Conversation holding the identity to re-issue.
36 pub conversation_id: ConversationId,
37 /// Permanent participant index within that conversation.
38 pub participant_id: ParticipantId,
39 /// Generation the operator believes is current — the compare-and-set that
40 /// makes concurrent operator repetitions serialize instead of
41 /// double-rotating.
42 pub expected_current_generation: u64,
43}
44
45/// The committed result of one re-issue, carrying its sole secret delivery.
46#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
47pub struct OperatorCredentialReissued {
48 /// Conversation the re-issue committed in.
49 pub conversation_id: ConversationId,
50 /// Identity whose credential was re-issued.
51 pub participant_id: ParticipantId,
52 /// Generation the compare-and-set matched (G).
53 pub presented_generation: u64,
54 /// Generation this re-issue minted (G+1).
55 pub issued_generation: u64,
56 /// The minted attach secret, lowercase hex. Returned EXACTLY ONCE: nothing
57 /// replays it, and no receipt row holds it.
58 pub attach_secret: String,
59}
60
61/// Every typed refusal `OperatorCredentialReissue` can answer (§0.18 item 2).
62///
63/// Each variant commits no receipt, order, cursor, binding, lifecycle record,
64/// or retention mutation, and each is a NAMED refusal rather than whatever the
65/// code happens to do.
66#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
67#[serde(tag = "refusal", rename_all = "snake_case")]
68pub enum OperatorCredentialReissueRefusal {
69 /// Pre-guard lookup miss: no durable conversation resolves this id.
70 ConversationUnknown {
71 /// The presented conversation id, and nothing beyond it.
72 conversation_id: ConversationId,
73 },
74 /// Pre-guard lookup miss: the conversation exists, the identity does not.
75 ParticipantUnknown {
76 /// The presented conversation id.
77 conversation_id: ConversationId,
78 /// The presented participant id, and nothing beyond it.
79 participant_id: ParticipantId,
80 },
81 /// Guard (a): a tombstoned identity. Re-issue never remints a retired one.
82 Retired {
83 /// The presented conversation id.
84 conversation_id: ConversationId,
85 /// The retired identity.
86 participant_id: ParticipantId,
87 /// Generation the identity held when it was retired.
88 retired_generation: u64,
89 },
90 /// Guard (b): a live binding. A bound member is demonstrably operating
91 /// under working authority, and re-issue against it would be seat
92 /// revocation — v1 has no operator Leave and A7 does not create one.
93 LiveBinding {
94 /// The presented conversation id.
95 conversation_id: ConversationId,
96 /// The bound identity.
97 participant_id: ParticipantId,
98 /// Current credential generation.
99 current_generation: u64,
100 /// Which live binding state refused: `bound` or `pending_finalization`.
101 binding_state: &'static str,
102 },
103 /// ⚠ NOT one of §0.18's four guards — a defect this build MEASURED, named
104 /// rather than absorbed, and returned to the seat as a contract flag.
105 ///
106 /// The identity's last committed detach still holds its exact-replay cell
107 /// open. `commit_attach` requires that cell's request generation to equal
108 /// the member's current generation (`lifecycle::attach.rs`,
109 /// `transition_detach_cell`'s `DetachCell::Committed` arm), and a re-issue
110 /// moves the generation while the cell stays where it is. So a re-issue
111 /// against this shape would mint a lawful-looking credential that the
112 /// ORDINARY attach path of §0.18 item 5 then refuses with a bare
113 /// `AttachCommitError::DetachCellAuthority` invariant — an unattachable
114 /// credential, which is the silent trap this estate refuses to ship.
115 ///
116 /// Refusing is the only answer available inside this lane's authority:
117 /// terminalizing the cell here would change what an exact detach-token
118 /// replay is answered with, which is existing refusal/restoration
119 /// semantics and not this lane's to move.
120 DetachReplayOpen {
121 /// The presented conversation id.
122 conversation_id: ConversationId,
123 /// The identity holding the open replay cell.
124 participant_id: ParticipantId,
125 /// Current credential generation.
126 current_generation: u64,
127 },
128 /// Guard (c): a live attach or enrollment receipt. A live receipt means the
129 /// R-C0 recovery window is still open and the ordinary recovery path must
130 /// be exhausted first.
131 LiveReceipt {
132 /// The presented conversation id.
133 conversation_id: ConversationId,
134 /// The identity holding the live receipt.
135 participant_id: ParticipantId,
136 /// Current credential generation.
137 current_generation: u64,
138 /// Which receipt is still live: `attach` or `enrollment`.
139 receipt: &'static str,
140 },
141 /// Guard (d): the compare-and-set failed.
142 ///
143 /// ⛔ This payload is NORMATIVE (§0.18 item 4). The presented/current pair
144 /// is the ONLY way an operator who lost a re-issue response learns the
145 /// post-rotation generation, so a future edit that minimizes it silently
146 /// breaks lost-response recovery.
147 GenerationMismatch {
148 /// The presented conversation id.
149 conversation_id: ConversationId,
150 /// The identity.
151 participant_id: ParticipantId,
152 /// Generation the operator presented.
153 presented_generation: u64,
154 /// Generation the identity actually holds.
155 current_generation: u64,
156 },
157}
158
159/// The complete answer of one `OperatorCredentialReissue`.
160#[derive(Clone, Debug, PartialEq, Eq)]
161pub enum OperatorCredentialReissueOutcome {
162 /// One atomic durable commit happened; the secret is inside, once.
163 Issued(OperatorCredentialReissued),
164 /// A typed, provably mutation-free refusal.
165 Refused(OperatorCredentialReissueRefusal),
166}
167
168/// A re-issue that could not be decided at all (service fatal, unloadable
169/// conversation, durable failure).
170///
171/// Deliberately distinct from [`OperatorCredentialReissueRefusal`]: a refusal
172/// is a decided answer with a proven-empty state delta, while this is the
173/// absence of an answer.
174#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
175#[error("operator credential re-issue could not be decided: {message}")]
176pub struct OperatorCredentialReissueError {
177 /// Diagnostic text for the operator.
178 pub message: String,
179}
180
181/// The one serialized participant-state authority, as this surface sees it.
182pub trait OperatorCredentialReissuer: std::fmt::Debug + Send + Sync {
183 /// Runs one re-issue at the serialized participant-state point.
184 ///
185 /// # Errors
186 ///
187 /// Returns [`OperatorCredentialReissueError`] when the operation could not
188 /// be decided. Every DECIDED refusal is an `Ok`
189 /// [`OperatorCredentialReissueOutcome::Refused`].
190 fn reissue(
191 &self,
192 request: OperatorCredentialReissueRequest,
193 ) -> Result<OperatorCredentialReissueOutcome, OperatorCredentialReissueError>;
194}
195
196/// The endpoint's slot for the participant authority.
197///
198/// The health server binds BEFORE the participant handler exists, exactly as
199/// it does for the refused-load record, so the authority is published into
200/// this slot once built. Until then the route answers "no participant is
201/// installed" rather than pretending an identity is unknown.
202#[derive(Clone, Debug, Default)]
203pub struct SharedOperatorCredentialReissue {
204 reissuer: Arc<Mutex<Option<Arc<dyn OperatorCredentialReissuer>>>>,
205}
206
207impl SharedOperatorCredentialReissue {
208 /// Publishes the participant authority into the surface.
209 pub fn install(&self, reissuer: Arc<dyn OperatorCredentialReissuer>) {
210 *self.reissuer.lock().unwrap_or_else(PoisonError::into_inner) = Some(reissuer);
211 }
212
213 /// Whether a participant authority is installed at all.
214 #[must_use]
215 pub fn participant_installed(&self) -> bool {
216 self.reissuer
217 .lock()
218 .unwrap_or_else(PoisonError::into_inner)
219 .is_some()
220 }
221
222 /// Runs one re-issue, or reports that no participant is installed.
223 ///
224 /// # Errors
225 ///
226 /// Returns [`OperatorCredentialReissueError`] when the operation could not
227 /// be decided.
228 pub fn reissue(
229 &self,
230 request: OperatorCredentialReissueRequest,
231 ) -> Result<Option<OperatorCredentialReissueOutcome>, OperatorCredentialReissueError> {
232 let reissuer = self
233 .reissuer
234 .lock()
235 .unwrap_or_else(PoisonError::into_inner)
236 .clone();
237 reissuer
238 .map(|reissuer| reissuer.reissue(request))
239 .transpose()
240 }
241}
242
243/// Lowercase hex of the minted secret.
244///
245/// Local rather than a dependency: the server takes no new crate for thirty-two
246/// bytes, and this is the only place a secret is ever rendered.
247#[must_use]
248pub fn encode_hex(bytes: &[u8; 32]) -> String {
249 let mut rendered = String::with_capacity(bytes.len() * 2);
250 for byte in bytes {
251 // Writing into a `String` is infallible; the result is discarded
252 // deliberately rather than unwrapped.
253 let _ = write!(rendered, "{byte:02x}");
254 }
255 rendered
256}
257
258#[cfg(test)]
259mod tests {
260 use super::{
261 OperatorCredentialReissueError, OperatorCredentialReissueOutcome,
262 OperatorCredentialReissueRefusal, OperatorCredentialReissueRequest,
263 OperatorCredentialReissued, OperatorCredentialReissuer, SharedOperatorCredentialReissue,
264 encode_hex,
265 };
266 use std::sync::Arc;
267
268 #[derive(Debug)]
269 struct FixedReissuer(OperatorCredentialReissueOutcome);
270
271 impl OperatorCredentialReissuer for FixedReissuer {
272 fn reissue(
273 &self,
274 _request: OperatorCredentialReissueRequest,
275 ) -> Result<OperatorCredentialReissueOutcome, OperatorCredentialReissueError> {
276 Ok(self.0.clone())
277 }
278 }
279
280 fn request() -> OperatorCredentialReissueRequest {
281 OperatorCredentialReissueRequest {
282 conversation_id: 7,
283 participant_id: 3,
284 expected_current_generation: 14,
285 }
286 }
287
288 /// An uninstalled surface must not answer like a node whose identity is
289 /// unknown: it says it is looking at nothing.
290 #[test]
291 fn an_uninstalled_surface_reports_that_no_participant_is_installed() {
292 let surface = SharedOperatorCredentialReissue::default();
293
294 assert!(!surface.participant_installed());
295 assert_eq!(surface.reissue(request()), Ok(None));
296 }
297
298 /// An installed authority answers, and the answer travels unchanged.
299 #[test]
300 fn an_installed_authority_answers_the_surface() {
301 let issued = OperatorCredentialReissued {
302 conversation_id: 7,
303 participant_id: 3,
304 presented_generation: 14,
305 issued_generation: 15,
306 attach_secret: encode_hex(&[0xAB; 32]),
307 };
308 let surface = SharedOperatorCredentialReissue::default();
309 surface.install(Arc::new(FixedReissuer(
310 OperatorCredentialReissueOutcome::Issued(issued.clone()),
311 )));
312
313 assert!(surface.participant_installed());
314 assert_eq!(
315 surface.reissue(request()),
316 Ok(Some(OperatorCredentialReissueOutcome::Issued(issued)))
317 );
318 }
319
320 /// The normative CAS payload survives serialization with BOTH generations.
321 /// §0.18 item 4: an operator who lost the response learns the post-rotation
322 /// generation from exactly this row and nowhere else.
323 #[test]
324 fn the_generation_mismatch_refusal_serializes_both_generations() -> Result<(), serde_json::Error>
325 {
326 let refusal = OperatorCredentialReissueRefusal::GenerationMismatch {
327 conversation_id: 7,
328 participant_id: 3,
329 presented_generation: 14,
330 current_generation: 15,
331 };
332
333 let rendered = serde_json::to_value(&refusal)?;
334
335 assert_eq!(rendered["refusal"], "generation_mismatch");
336 assert_eq!(rendered["presented_generation"], 14);
337 assert_eq!(rendered["current_generation"], 15);
338 Ok(())
339 }
340
341 /// Hex is lowercase, fixed width, and covers every byte.
342 #[test]
343 fn hex_rendering_is_lowercase_and_fixed_width() {
344 let mut bytes = [0_u8; 32];
345 bytes[0] = 0x00;
346 bytes[1] = 0x0F;
347 bytes[31] = 0xFF;
348
349 let rendered = encode_hex(&bytes);
350
351 assert_eq!(rendered.len(), 64);
352 assert!(rendered.starts_with("000f"));
353 assert!(rendered.ends_with("ff"));
354 }
355}