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, 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}