Skip to main content

syncbat/
receipt.rs

1//! Generic receipt envelope types for syncbat operation runs.
2
3use std::collections::BTreeMap;
4use std::sync::Arc;
5use std::{error::Error, fmt};
6
7use batpak::event::{EventKind, EventPayload};
8use batpak::store::{EncodedBytes, ExtensionKey};
9use serde::{Deserialize, Serialize};
10
11use crate::operation::OperationDescriptor;
12
13/// Batpak custom event kind used for syncbat receipt events.
14///
15/// Category `0xC` is a caller-defined category and is not used by the core
16/// examples, which currently reserve their example payloads under category
17/// `0x1`. Type id `0x5B7` is scoped to syncbat's generic receipt envelope.
18pub const SYNCBAT_RECEIPT_EVENT_KIND: EventKind = EventKind::custom(0xC, 0x5B7);
19
20/// Stable hash bytes carried by a syncbat receipt.
21pub type ReceiptHash = [u8; 32];
22
23/// Caller-owned raw byte hasher for runtime receipt input/output hashes.
24pub trait ReceiptHasher {
25    /// Return a stable hash for already-encoded operation bytes.
26    fn hash(&self, bytes: &[u8]) -> ReceiptHash;
27}
28
29impl<F> ReceiptHasher for F
30where
31    F: Fn(&[u8]) -> ReceiptHash,
32{
33    fn hash(&self, bytes: &[u8]) -> ReceiptHash {
34        self(bytes)
35    }
36}
37
38/// Runtime policy for populating receipt input/output hashes.
39#[derive(Clone, Default)]
40#[non_exhaustive]
41pub enum ReceiptHashPolicy {
42    /// Defer hash population to a later layer; runtime receipts carry no byte
43    /// hashes.
44    #[default]
45    Deferred,
46    /// Hash raw handler input/output bytes with a deterministic caller-owned hasher.
47    RawBytes(Arc<dyn ReceiptHasher>),
48}
49
50impl ReceiptHashPolicy {
51    /// Build a raw-byte hash policy from a caller-owned deterministic hasher.
52    #[must_use]
53    pub fn raw_bytes(hasher: impl ReceiptHasher + 'static) -> Self {
54        Self::RawBytes(Arc::new(hasher))
55    }
56
57    /// Return the configured raw-byte hash for `bytes`, when enabled.
58    #[must_use]
59    pub fn hash(&self, bytes: &[u8]) -> Option<ReceiptHash> {
60        match self {
61            Self::Deferred => None,
62            Self::RawBytes(hasher) => Some(hasher.hash(bytes)),
63        }
64    }
65}
66
67/// Opaque extension drawer attached to a syncbat receipt.
68///
69/// Keys are profile-owned strings. Values are already-encoded bytes so this
70/// layer does not impose a schema on higher-level operation kits.
71pub type ReceiptExtensionDrawer = BTreeMap<String, Vec<u8>>;
72
73/// Runtime result recorded for a completed operation attempt.
74#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
75#[non_exhaustive]
76pub enum ReceiptOutcome {
77    /// The operation completed and produced any expected output.
78    Completed,
79    /// The operation ran but failed before producing a usable result.
80    Failed {
81        /// Stable failure class.
82        code: String,
83        /// Human-readable failure detail.
84        message: String,
85    },
86    /// The direct sink or policy layer declined to execute or publish the
87    /// operation result.
88    ///
89    /// `Core` checkout dispatch emits `Completed` or `Failed`; `Denied` is
90    /// reserved for direct receipt sinks, admission checks, and network guards
91    /// that reject a call before handler execution.
92    Denied {
93        /// Stable denial class.
94        code: String,
95        /// Human-readable denial detail.
96        message: String,
97    },
98}
99
100impl ReceiptOutcome {
101    /// Construct a failed outcome.
102    #[must_use]
103    pub fn failed(code: impl Into<String>, message: impl Into<String>) -> Self {
104        Self::Failed {
105            code: code.into(),
106            message: message.into(),
107        }
108    }
109
110    /// Construct a denied outcome.
111    #[must_use]
112    pub fn denied(code: impl Into<String>, message: impl Into<String>) -> Self {
113        Self::Denied {
114            code: code.into(),
115            message: message.into(),
116        }
117    }
118
119    /// Return the stable outcome class used in receipt extensions.
120    #[must_use]
121    pub const fn class(&self) -> &'static str {
122        match self {
123            Self::Completed => "completed",
124            Self::Failed { .. } => "failed",
125            Self::Denied { .. } => "denied",
126        }
127    }
128}
129
130/// Batpak append receipt fields associated with a persisted syncbat receipt.
131#[derive(Clone, Debug, Eq, PartialEq)]
132#[non_exhaustive]
133pub struct BatpakReceiptFields {
134    /// Unique ID of the persisted receipt event.
135    pub event_id: batpak::id::EventId,
136    /// Global sequence assigned by batpak at commit time.
137    pub sequence: u64,
138    /// Blake3 hash of the committed receipt payload bytes.
139    pub content_hash: ReceiptHash,
140    /// Signing-key identity reported by batpak.
141    pub key_id: ReceiptHash,
142    /// Detached receipt signature when store signing is enabled.
143    pub signature: Option<[u8; 64]>,
144    /// Opaque receipt extensions committed with the batpak append receipt.
145    pub extensions: BTreeMap<ExtensionKey, EncodedBytes>,
146}
147
148impl From<batpak::store::AppendReceipt> for BatpakReceiptFields {
149    fn from(receipt: batpak::store::AppendReceipt) -> Self {
150        Self {
151            event_id: receipt.event_id,
152            sequence: receipt.sequence,
153            content_hash: receipt.content_hash,
154            key_id: receipt.key_id,
155            signature: receipt.signature,
156            extensions: receipt.extensions,
157        }
158    }
159}
160
161/// Generic syncbat receipt envelope persisted as an event payload.
162///
163/// The signed drawer is copied into batpak receipt extensions by
164/// [`crate::store_sink::StoreReceiptSink`]. The local drawer remains only in
165/// the syncbat envelope body for callers that need local, profile-owned
166/// diagnostics without adding batpak receipt-extension keys.
167#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
168#[non_exhaustive]
169pub struct ReceiptEnvelope {
170    /// Stable operation descriptor name.
171    pub descriptor_name: String,
172    /// Stable receipt kind from the operation descriptor.
173    pub receipt_kind: String,
174    /// Optional hash of the operation input bytes.
175    pub input_hash: Option<ReceiptHash>,
176    /// Optional hash of the operation output bytes.
177    pub output_hash: Option<ReceiptHash>,
178    /// Runtime result for this operation attempt.
179    pub outcome: ReceiptOutcome,
180    /// Opaque extension drawer intended for batpak receipt extensions.
181    pub signed_extensions: ReceiptExtensionDrawer,
182    /// Opaque extension drawer kept in the syncbat envelope body.
183    pub local_extensions: ReceiptExtensionDrawer,
184}
185
186impl ReceiptEnvelope {
187    /// Construct an envelope from an operation descriptor.
188    #[must_use]
189    pub fn new(descriptor: &OperationDescriptor, outcome: ReceiptOutcome) -> Self {
190        Self::from_descriptor(descriptor.name(), descriptor.receipt_kind(), outcome)
191    }
192
193    /// Construct an envelope from stable descriptor receipt fields.
194    #[must_use]
195    pub fn from_descriptor(
196        descriptor_name: impl Into<String>,
197        receipt_kind: impl Into<String>,
198        outcome: ReceiptOutcome,
199    ) -> Self {
200        Self {
201            descriptor_name: descriptor_name.into(),
202            receipt_kind: receipt_kind.into(),
203            input_hash: None,
204            output_hash: None,
205            outcome,
206            signed_extensions: BTreeMap::new(),
207            local_extensions: BTreeMap::new(),
208        }
209    }
210
211    /// Attach an input hash.
212    #[must_use]
213    pub fn with_input_hash(mut self, hash: ReceiptHash) -> Self {
214        self.input_hash = Some(hash);
215        self
216    }
217
218    /// Attach an output hash.
219    #[must_use]
220    pub fn with_output_hash(mut self, hash: ReceiptHash) -> Self {
221        self.output_hash = Some(hash);
222        self
223    }
224
225    /// Insert one signed extension entry.
226    #[must_use]
227    pub fn with_signed_extension(
228        mut self,
229        key: impl Into<String>,
230        value: impl Into<Vec<u8>>,
231    ) -> Self {
232        self.signed_extensions.insert(key.into(), value.into());
233        self
234    }
235
236    /// Insert one local extension entry.
237    #[must_use]
238    pub fn with_local_extension(
239        mut self,
240        key: impl Into<String>,
241        value: impl Into<Vec<u8>>,
242    ) -> Self {
243        self.local_extensions.insert(key.into(), value.into());
244        self
245    }
246}
247
248impl EventPayload for ReceiptEnvelope {
249    const KIND: EventKind = SYNCBAT_RECEIPT_EVENT_KIND;
250}
251
252/// Receipt envelope plus sink-owned persistence metadata.
253///
254/// This type is returned by sinks after recording. The persisted event payload
255/// remains [`ReceiptEnvelope`], so append-result metadata cannot accidentally
256/// become part of the event body.
257#[derive(Clone, Debug, Eq, PartialEq)]
258#[non_exhaustive]
259pub struct RecordedReceipt {
260    /// Envelope body that was recorded.
261    pub envelope: ReceiptEnvelope,
262    /// Batpak receipt fields when the envelope was recorded through batpak.
263    pub batpak_receipt: Option<BatpakReceiptFields>,
264}
265
266impl RecordedReceipt {
267    /// Construct recorded receipt metadata for a receipt envelope.
268    #[must_use]
269    pub fn new(envelope: ReceiptEnvelope) -> Self {
270        Self {
271            envelope,
272            batpak_receipt: None,
273        }
274    }
275
276    /// Attach batpak receipt fields.
277    #[must_use]
278    pub fn with_batpak_receipt(mut self, receipt: impl Into<BatpakReceiptFields>) -> Self {
279        self.batpak_receipt = Some(receipt.into());
280        self
281    }
282}
283
284/// Error returned when a receipt sink cannot record an envelope.
285#[derive(Debug, Clone, Eq, PartialEq)]
286pub struct ReceiptSinkError {
287    message: String,
288}
289
290impl ReceiptSinkError {
291    /// Construct a receipt-sink error from a displayable message.
292    #[must_use]
293    pub fn new(message: impl Into<String>) -> Self {
294        Self {
295            message: message.into(),
296        }
297    }
298
299    /// Return the sink error message.
300    #[must_use]
301    pub fn message(&self) -> &str {
302        &self.message
303    }
304}
305
306impl fmt::Display for ReceiptSinkError {
307    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
308        f.write_str(&self.message)
309    }
310}
311
312impl Error for ReceiptSinkError {}
313
314/// Sink for completed syncbat receipt envelopes.
315pub trait ReceiptSink {
316    /// Persist a receipt envelope and return sink-owned persistence metadata.
317    ///
318    /// # Errors
319    /// Returns [`ReceiptSinkError`] when the sink rejects or fails the write.
320    fn record_receipt(
321        &self,
322        envelope: &ReceiptEnvelope,
323    ) -> Result<RecordedReceipt, ReceiptSinkError>;
324}