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, 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
61impl core::fmt::Debug for OperatorCredentialReissued {
62 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
63 formatter
64 .debug_struct("OperatorCredentialReissued")
65 .field("conversation_id", &self.conversation_id)
66 .field("participant_id", &self.participant_id)
67 .field("presented_generation", &self.presented_generation)
68 .field("issued_generation", &self.issued_generation)
69 .field(
70 "attach_secret",
71 &crate::redacted::Redacted(self.attach_secret.as_bytes()),
72 )
73 .finish()
74 }
75}
76
77/// Every typed refusal `OperatorCredentialReissue` can answer (§0.18 item 2).
78///
79/// Each variant commits no receipt, order, cursor, binding, lifecycle record,
80/// or retention mutation, and each is a NAMED refusal rather than whatever the
81/// code happens to do.
82#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
83#[serde(tag = "refusal", rename_all = "snake_case")]
84pub enum OperatorCredentialReissueRefusal {
85 /// Pre-guard lookup miss: no durable conversation resolves this id.
86 ConversationUnknown {
87 /// The presented conversation id, and nothing beyond it.
88 conversation_id: ConversationId,
89 },
90 /// Pre-guard lookup miss: the conversation exists, the identity does not.
91 ParticipantUnknown {
92 /// The presented conversation id.
93 conversation_id: ConversationId,
94 /// The presented participant id, and nothing beyond it.
95 participant_id: ParticipantId,
96 },
97 /// Guard (a): a tombstoned identity. Re-issue never remints a retired one.
98 Retired {
99 /// The presented conversation id.
100 conversation_id: ConversationId,
101 /// The retired identity.
102 participant_id: ParticipantId,
103 /// Generation the identity held when it was retired.
104 retired_generation: u64,
105 },
106 /// Guard (b): a live binding. A bound member is demonstrably operating
107 /// under working authority, and re-issue against it would be seat
108 /// revocation — v1 has no operator Leave and A7 does not create one.
109 LiveBinding {
110 /// The presented conversation id.
111 conversation_id: ConversationId,
112 /// The bound identity.
113 participant_id: ParticipantId,
114 /// Current credential generation.
115 current_generation: u64,
116 /// Which live binding state refused: `bound` or `pending_finalization`.
117 binding_state: &'static str,
118 },
119 /// ⚠ NOT one of §0.18's four guards — a defect this build MEASURED, named
120 /// rather than absorbed, and returned to the seat as a contract flag.
121 ///
122 /// The identity's last committed detach still holds its exact-replay cell
123 /// open. `commit_attach` requires that cell's request generation to equal
124 /// the member's current generation (`lifecycle::attach.rs`,
125 /// `transition_detach_cell`'s `DetachCell::Committed` arm), and a re-issue
126 /// moves the generation while the cell stays where it is. So a re-issue
127 /// against this shape would mint a lawful-looking credential that the
128 /// ORDINARY attach path of §0.18 item 5 then refuses with a bare
129 /// `AttachCommitError::DetachCellAuthority` invariant — an unattachable
130 /// credential, which is the silent trap this estate refuses to ship.
131 ///
132 /// Refusing is the only answer available inside this lane's authority:
133 /// terminalizing the cell here would change what an exact detach-token
134 /// replay is answered with, which is existing refusal/restoration
135 /// semantics and not this lane's to move.
136 DetachReplayOpen {
137 /// The presented conversation id.
138 conversation_id: ConversationId,
139 /// The identity holding the open replay cell.
140 participant_id: ParticipantId,
141 /// Current credential generation.
142 current_generation: u64,
143 },
144 /// Guard (c): a live attach or enrollment receipt. A live receipt means the
145 /// R-C0 recovery window is still open and the ordinary recovery path must
146 /// be exhausted first.
147 LiveReceipt {
148 /// The presented conversation id.
149 conversation_id: ConversationId,
150 /// The identity holding the live receipt.
151 participant_id: ParticipantId,
152 /// Current credential generation.
153 current_generation: u64,
154 /// Which receipt is still live: `attach` or `enrollment`.
155 receipt: &'static str,
156 },
157 /// Guard (d): the compare-and-set failed.
158 ///
159 /// ⛔ This payload is NORMATIVE (§0.18 item 4). The presented/current pair
160 /// is the ONLY way an operator who lost a re-issue response learns the
161 /// post-rotation generation, so a future edit that minimizes it silently
162 /// breaks lost-response recovery.
163 GenerationMismatch {
164 /// The presented conversation id.
165 conversation_id: ConversationId,
166 /// The identity.
167 participant_id: ParticipantId,
168 /// Generation the operator presented.
169 presented_generation: u64,
170 /// Generation the identity actually holds.
171 current_generation: u64,
172 },
173}
174
175/// The complete answer of one `OperatorCredentialReissue`.
176#[derive(Clone, Debug, PartialEq, Eq)]
177pub enum OperatorCredentialReissueOutcome {
178 /// One atomic durable commit happened; the secret is inside, once.
179 Issued(OperatorCredentialReissued),
180 /// A typed, provably mutation-free refusal.
181 Refused(OperatorCredentialReissueRefusal),
182}
183
184/// A re-issue that could not be decided at all (service fatal, unloadable
185/// conversation, durable failure).
186///
187/// Deliberately distinct from [`OperatorCredentialReissueRefusal`]: a refusal
188/// is a decided answer with a proven-empty state delta, while this is the
189/// absence of an answer.
190#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
191#[error("operator credential re-issue could not be decided: {message}")]
192pub struct OperatorCredentialReissueError {
193 /// Diagnostic text for the operator.
194 pub message: String,
195}
196
197/// The one serialized participant-state authority, as this surface sees it.
198pub trait OperatorCredentialReissuer: std::fmt::Debug + Send + Sync {
199 /// Runs one re-issue at the serialized participant-state point.
200 ///
201 /// # Errors
202 ///
203 /// Returns [`OperatorCredentialReissueError`] when the operation could not
204 /// be decided. Every DECIDED refusal is an `Ok`
205 /// [`OperatorCredentialReissueOutcome::Refused`].
206 fn reissue(
207 &self,
208 request: OperatorCredentialReissueRequest,
209 ) -> Result<OperatorCredentialReissueOutcome, OperatorCredentialReissueError>;
210}
211
212/// The endpoint's slot for the participant authority.
213///
214/// The health server binds BEFORE the participant handler exists, exactly as
215/// it does for the refused-load record, so the authority is published into
216/// this slot once built. Until then the route answers "no participant is
217/// installed" rather than pretending an identity is unknown.
218#[derive(Clone, Debug, Default)]
219pub struct SharedOperatorCredentialReissue {
220 reissuer: Arc<Mutex<Option<Arc<dyn OperatorCredentialReissuer>>>>,
221}
222
223impl SharedOperatorCredentialReissue {
224 /// Publishes the participant authority into the surface.
225 pub fn install(&self, reissuer: Arc<dyn OperatorCredentialReissuer>) {
226 *self.reissuer.lock().unwrap_or_else(PoisonError::into_inner) = Some(reissuer);
227 }
228
229 /// Whether a participant authority is installed at all.
230 #[must_use]
231 pub fn participant_installed(&self) -> bool {
232 self.reissuer
233 .lock()
234 .unwrap_or_else(PoisonError::into_inner)
235 .is_some()
236 }
237
238 /// Runs one re-issue, or reports that no participant is installed.
239 ///
240 /// # Errors
241 ///
242 /// Returns [`OperatorCredentialReissueError`] when the operation could not
243 /// be decided.
244 pub fn reissue(
245 &self,
246 request: OperatorCredentialReissueRequest,
247 ) -> Result<Option<OperatorCredentialReissueOutcome>, OperatorCredentialReissueError> {
248 let reissuer = self
249 .reissuer
250 .lock()
251 .unwrap_or_else(PoisonError::into_inner)
252 .clone();
253 reissuer
254 .map(|reissuer| reissuer.reissue(request))
255 .transpose()
256 }
257}
258
259/// Lowercase hex of the minted secret.
260///
261/// Local rather than a dependency: the server takes no new crate for thirty-two
262/// bytes, and this is the only place a secret is ever rendered.
263#[must_use]
264pub fn encode_hex(bytes: &[u8; 32]) -> String {
265 let mut rendered = String::with_capacity(bytes.len() * 2);
266 for byte in bytes {
267 // Writing into a `String` is infallible; the result is discarded
268 // deliberately rather than unwrapped.
269 let _ = write!(rendered, "{byte:02x}");
270 }
271 rendered
272}
273
274#[cfg(test)]
275mod tests {
276 use super::{
277 OperatorCredentialReissueError, OperatorCredentialReissueOutcome,
278 OperatorCredentialReissueRefusal, OperatorCredentialReissueRequest,
279 OperatorCredentialReissued, OperatorCredentialReissuer, SharedOperatorCredentialReissue,
280 encode_hex,
281 };
282 use std::sync::Arc;
283
284 #[derive(Debug)]
285 struct FixedReissuer(OperatorCredentialReissueOutcome);
286
287 impl OperatorCredentialReissuer for FixedReissuer {
288 fn reissue(
289 &self,
290 _request: OperatorCredentialReissueRequest,
291 ) -> Result<OperatorCredentialReissueOutcome, OperatorCredentialReissueError> {
292 Ok(self.0.clone())
293 }
294 }
295
296 fn request() -> OperatorCredentialReissueRequest {
297 OperatorCredentialReissueRequest {
298 conversation_id: 7,
299 participant_id: 3,
300 expected_current_generation: 14,
301 }
302 }
303
304 /// The minted secret is served once in the JSON answer and never in a
305 /// `Debug` (LIM-010 R1): an operator log of the value shows its length.
306 #[test]
307 fn a_reissued_credential_debug_shows_no_secret_byte() {
308 let secret = crate::credential_debug_tests::token_shaped::<32>(32);
309 let reissued = OperatorCredentialReissued {
310 conversation_id: 7,
311 participant_id: 3,
312 presented_generation: 14,
313 issued_generation: 15,
314 attach_secret: encode_hex(&secret),
315 };
316 crate::credential_debug_tests::shows_none_of(&reissued, &secret);
317 }
318
319 /// An uninstalled surface must not answer like a node whose identity is
320 /// unknown: it says it is looking at nothing.
321 #[test]
322 fn an_uninstalled_surface_reports_that_no_participant_is_installed() {
323 let surface = SharedOperatorCredentialReissue::default();
324
325 assert!(!surface.participant_installed());
326 assert_eq!(surface.reissue(request()), Ok(None));
327 }
328
329 /// An installed authority answers, and the answer travels unchanged.
330 #[test]
331 fn an_installed_authority_answers_the_surface() {
332 let issued = OperatorCredentialReissued {
333 conversation_id: 7,
334 participant_id: 3,
335 presented_generation: 14,
336 issued_generation: 15,
337 attach_secret: encode_hex(&[0xAB; 32]),
338 };
339 let surface = SharedOperatorCredentialReissue::default();
340 surface.install(Arc::new(FixedReissuer(
341 OperatorCredentialReissueOutcome::Issued(issued.clone()),
342 )));
343
344 assert!(surface.participant_installed());
345 assert_eq!(
346 surface.reissue(request()),
347 Ok(Some(OperatorCredentialReissueOutcome::Issued(issued)))
348 );
349 }
350
351 /// The normative CAS payload survives serialization with BOTH generations.
352 /// §0.18 item 4: an operator who lost the response learns the post-rotation
353 /// generation from exactly this row and nowhere else.
354 #[test]
355 fn the_generation_mismatch_refusal_serializes_both_generations() -> Result<(), serde_json::Error>
356 {
357 let refusal = OperatorCredentialReissueRefusal::GenerationMismatch {
358 conversation_id: 7,
359 participant_id: 3,
360 presented_generation: 14,
361 current_generation: 15,
362 };
363
364 let rendered = serde_json::to_value(&refusal)?;
365
366 assert_eq!(rendered["refusal"], "generation_mismatch");
367 assert_eq!(rendered["presented_generation"], 14);
368 assert_eq!(rendered["current_generation"], 15);
369 Ok(())
370 }
371
372 /// Hex is lowercase, fixed width, and covers every byte.
373 #[test]
374 fn hex_rendering_is_lowercase_and_fixed_width() {
375 let mut bytes = [0_u8; 32];
376 bytes[0] = 0x00;
377 bytes[1] = 0x0F;
378 bytes[31] = 0xFF;
379
380 let rendered = encode_hex(&bytes);
381
382 assert_eq!(rendered.len(), 64);
383 assert!(rendered.starts_with("000f"));
384 assert!(rendered.ends_with("ff"));
385 }
386}