Skip to main content

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}