Skip to main content

liminal_server/server/participant/production/
marker_source.rs

1//! Bounded durable marker-source association for fenced Attached rows.
2
3use std::sync::Arc;
4
5use liminal::durability::DurableStore;
6use liminal_protocol::lifecycle::{
7    FencedMarkerSourceExpectation, LiveFrontierOwner, MarkerSequenceOwner,
8    RetainedFencedMarkerSource,
9};
10
11use super::log::{
12    DecodedStoredOperation, OperationLog, StoredMarkerDrain, StoredOperationV2, StoredOperationV3,
13};
14use super::restore_checkpoint_locator::checkpoint_conversation_route;
15
16/// Typed reason an exact marker source cannot authorize later mint inputs.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
18pub enum MarkerSourceRefusalReason {
19    /// The point read or durable row decode failed.
20    #[error("durable marker source could not be read or decoded")]
21    DurableRead,
22    /// No durable row exists at the selected sequence.
23    #[error("marker source sequence does not exist")]
24    Missing,
25    /// The selected durable row is not a marker source.
26    #[error("marker source sequence does not select MarkerDrained")]
27    WrongOperation,
28    /// Canonical marker identity bytes disagree with restored frontier truth.
29    #[error("durable marker body differs from the frontier-validated record")]
30    MarkerBody,
31    /// Marker delivery sequence disagrees.
32    #[error("durable marker delivery sequence differs from the frontier-validated record")]
33    DeliverySequence,
34    /// Marker transaction order disagrees.
35    #[error("durable marker transaction order differs from the frontier-validated record")]
36    TransactionOrder,
37    /// Marker candidate phase disagrees.
38    #[error("durable marker candidate phase differs from the frontier-validated record")]
39    CandidatePhase,
40    /// Marker participant disagrees.
41    #[error("durable marker participant differs from the frontier-validated record")]
42    Participant,
43    /// Measured marker row charge disagrees.
44    #[error("durable marker retained charge differs from its measured canonical bytes")]
45    RetainedCharge,
46}
47
48/// Refused association retaining the move-only owner/recovery pair.
49#[derive(Debug)]
50pub struct MarkerSourceRefused {
51    retained: RetainedFencedMarkerSource,
52    marker_source_sequence: u64,
53    reason: MarkerSourceRefusalReason,
54}
55
56impl MarkerSourceRefused {
57    /// Returns the exact typed refusal.
58    #[must_use]
59    pub const fn reason(&self) -> MarkerSourceRefusalReason {
60        self.reason
61    }
62
63    /// Returns unchanged retained inputs and the selected sequence.
64    #[must_use]
65    pub fn into_parts(self) -> (RetainedFencedMarkerSource, u64) {
66        (self.retained, self.marker_source_sequence)
67    }
68}
69
70/// Source-associated inputs which still contain no one-use marker token.
71#[derive(Debug)]
72pub struct ValidatedFencedMarkerInputs {
73    retained: RetainedFencedMarkerSource,
74    marker_source_sequence: u64,
75}
76
77impl ValidatedFencedMarkerInputs {
78    /// Returns unchanged owner/recovery plus the validated source sequence.
79    #[must_use]
80    pub fn into_parts(
81        self,
82    ) -> (
83        LiveFrontierOwner,
84        liminal_protocol::lifecycle::DetachedCredentialRecovery,
85        u64,
86    ) {
87        let (owner, recovery) = self.retained.into_parts();
88        (owner, recovery, self.marker_source_sequence)
89    }
90}
91
92/// Point-reads and validates one durable marker source before the retained owner
93/// may proceed to its private one-use authority mint.
94///
95/// # Errors
96/// Returns a typed refusal containing the unchanged retained inputs when the row
97/// is absent, unreadable, has the wrong kind, or disagrees with frontier truth.
98pub async fn validate_marker_source(
99    store: Arc<dyn DurableStore>,
100    retained: RetainedFencedMarkerSource,
101    marker_source_sequence: u64,
102) -> Result<ValidatedFencedMarkerInputs, Box<MarkerSourceRefused>> {
103    let expectation = retained.expectation();
104    let log = OperationLog::new(store, expectation.conversation_id());
105    let result = read_and_validate(&log, marker_source_sequence, expectation).await;
106    match result {
107        Ok(()) => Ok(ValidatedFencedMarkerInputs {
108            retained,
109            marker_source_sequence,
110        }),
111        Err(reason) => Err(Box::new(MarkerSourceRefused {
112            retained,
113            marker_source_sequence,
114            reason,
115        })),
116    }
117}
118
119async fn read_and_validate(
120    log: &OperationLog,
121    marker_source_sequence: u64,
122    expectation: FencedMarkerSourceExpectation,
123) -> Result<(), MarkerSourceRefusalReason> {
124    // The cited row sits wherever its commit put it: flat below the native
125    // head, routed at or after it. A flat read alone answered `Missing` for
126    // every marker drained since commits went routed.
127    let route = checkpoint_conversation_route(expectation.conversation_id());
128    let operation = log
129        .read_committed_at(&route, marker_source_sequence)
130        .await
131        .map_err(|_| MarkerSourceRefusalReason::DurableRead)?
132        .ok_or(MarkerSourceRefusalReason::Missing)?
133        .operation;
134    let row = match operation {
135        DecodedStoredOperation::V2(StoredOperationV2::MarkerDrained { row })
136        | DecodedStoredOperation::V3(StoredOperationV3::MarkerDrained { row }) => row,
137        DecodedStoredOperation::V2(_) | DecodedStoredOperation::V3(_) => {
138            return Err(MarkerSourceRefusalReason::WrongOperation);
139        }
140    };
141    validate_row(&row, expectation)
142}
143
144fn validate_row(
145    row: &StoredMarkerDrain,
146    expectation: FencedMarkerSourceExpectation,
147) -> Result<(), MarkerSourceRefusalReason> {
148    let canonical_marker = canonical_marker_bytes(expectation);
149    if row.marker != canonical_marker {
150        return Err(MarkerSourceRefusalReason::MarkerBody);
151    }
152    let charge = row.retained_charge;
153    let order = expectation.admission_order();
154    if charge.delivery_seq != expectation.marker_delivery_seq() {
155        return Err(MarkerSourceRefusalReason::DeliverySequence);
156    }
157    if charge.transaction_order != order.transaction_order() {
158        return Err(MarkerSourceRefusalReason::TransactionOrder);
159    }
160    if charge.candidate_phase != order.candidate_phase() as u8 {
161        return Err(MarkerSourceRefusalReason::CandidatePhase);
162    }
163    if charge.participant_id != expectation.participant_id() {
164        return Err(MarkerSourceRefusalReason::Participant);
165    }
166    let marker_bytes = u64::try_from(canonical_marker.len())
167        .map_err(|_| MarkerSourceRefusalReason::RetainedCharge)?;
168    if charge.charge.entries != 1 || charge.charge.bytes != marker_bytes {
169        return Err(MarkerSourceRefusalReason::RetainedCharge);
170    }
171    Ok(())
172}
173
174pub(super) fn canonical_marker_bytes(expectation: FencedMarkerSourceExpectation) -> Vec<u8> {
175    format!(
176        "MarkerCandidateAuthority {{ delivery_seq: {:?}, admission_order: {:?}, target_binding: {:?}, provenance: {:?}, current_owner: {:?} }}",
177        expectation.marker_delivery_seq(),
178        expectation.admission_order(),
179        expectation.target_binding(),
180        expectation.provenance(),
181        MarkerSequenceOwner::Marker,
182    )
183    .into_bytes()
184}