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///
40/// The default is [`ReceiptHashPolicy::Blake3`]: a runtime receipt always binds
41/// to the exact bytes that produced it. An unhashed receipt
42/// (`input_hash`/`output_hash` both `None`) binds to nothing, so the unhashed
43/// mode is reachable only as the explicit opt-out
44/// [`ReceiptHashPolicy::Deferred`]. This mirrors the store's signing-policy
45/// idiom: the safe behavior is the default and the weaker behavior is an
46/// explicit escape hatch.
47#[derive(Clone, Default)]
48#[non_exhaustive]
49pub enum ReceiptHashPolicy {
50    /// Hash raw handler input/output bytes with the built-in deterministic
51    /// Blake3 hasher (a 32-byte digest over the raw bytes). This is the safe
52    /// default: every recorded receipt carries the hash of the bytes it covers.
53    #[default]
54    Blake3,
55    /// Defer hash population to a later layer; runtime receipts carry no byte
56    /// hashes. This is the explicit opt-out for callers that truly want unhashed
57    /// receipts (e.g. a higher layer that hashes and binds the bytes itself).
58    Deferred,
59    /// Hash raw handler input/output bytes with a deterministic caller-owned hasher.
60    RawBytes(Arc<dyn ReceiptHasher>),
61}
62
63impl ReceiptHashPolicy {
64    /// Build a raw-byte hash policy from a caller-owned deterministic hasher.
65    #[must_use]
66    pub fn raw_bytes(hasher: impl ReceiptHasher + 'static) -> Self {
67        Self::RawBytes(Arc::new(hasher))
68    }
69
70    /// Return the configured raw-byte hash for `bytes`, when enabled.
71    #[must_use]
72    pub fn hash(&self, bytes: &[u8]) -> Option<ReceiptHash> {
73        match self {
74            Self::Blake3 => Some(*blake3::hash(bytes).as_bytes()),
75            Self::Deferred => None,
76            Self::RawBytes(hasher) => Some(hasher.hash(bytes)),
77        }
78    }
79}
80
81/// Opaque extension drawer attached to a syncbat receipt.
82///
83/// Keys are profile-owned strings. Values are already-encoded bytes so this
84/// layer does not impose a schema on higher-level operation kits.
85pub type ReceiptExtensionDrawer = BTreeMap<String, Vec<u8>>;
86
87/// Opaque receipt metadata a handler or admission guard attaches to the current
88/// invocation via [`crate::Ctx`]. The runtime drains it into the recorded
89/// [`ReceiptEnvelope`]'s drawers — `signed` into [`ReceiptEnvelope::signed_extensions`]
90/// (copied into batpak receipt extensions by the store sink) and `local` into
91/// [`ReceiptEnvelope::local_extensions`] (envelope body only). It exists so a
92/// handler can stamp correlation/attempt metadata onto its receipt WITHOUT
93/// owning the receipt envelope, preserving the runtime's sole ownership of
94/// receipt persistence.
95#[derive(Clone, Debug, Default, Eq, PartialEq)]
96pub struct ReceiptMetadata {
97    /// Entries destined for the envelope's signed drawer.
98    pub signed: ReceiptExtensionDrawer,
99    /// Entries kept only in the envelope's local drawer.
100    pub local: ReceiptExtensionDrawer,
101}
102
103impl ReceiptMetadata {
104    /// Return true when no metadata has been attached.
105    #[must_use]
106    pub fn is_empty(&self) -> bool {
107        self.signed.is_empty() && self.local.is_empty()
108    }
109}
110
111/// Runtime result recorded for a completed operation attempt.
112#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
113#[non_exhaustive]
114pub enum ReceiptOutcome {
115    /// The operation completed and produced any expected output.
116    Completed,
117    /// The operation ran but failed before producing a usable result.
118    Failed {
119        /// Stable failure class.
120        code: String,
121        /// Human-readable failure detail.
122        message: String,
123    },
124    /// The direct sink or policy layer declined to execute or publish the
125    /// operation result.
126    ///
127    /// `Core` checkout dispatch emits `Completed` or `Failed`; `Denied` is
128    /// reserved for direct receipt sinks, admission checks, and network guards
129    /// that reject a call before handler execution.
130    Denied {
131        /// Stable denial class.
132        code: String,
133        /// Human-readable denial detail.
134        message: String,
135    },
136}
137
138impl ReceiptOutcome {
139    /// Construct a failed outcome.
140    #[must_use]
141    pub fn failed(code: impl Into<String>, message: impl Into<String>) -> Self {
142        Self::Failed {
143            code: code.into(),
144            message: message.into(),
145        }
146    }
147
148    /// Construct a denied outcome.
149    #[must_use]
150    pub fn denied(code: impl Into<String>, message: impl Into<String>) -> Self {
151        Self::Denied {
152            code: code.into(),
153            message: message.into(),
154        }
155    }
156
157    /// Return the stable outcome class used in receipt extensions.
158    #[must_use]
159    pub const fn class(&self) -> &'static str {
160        match self {
161            Self::Completed => "completed",
162            Self::Failed { .. } => "failed",
163            Self::Denied { .. } => "denied",
164        }
165    }
166}
167
168/// Batpak append receipt fields associated with a persisted syncbat receipt.
169#[derive(Clone, Debug, Eq, PartialEq)]
170#[non_exhaustive]
171pub struct BatpakReceiptFields {
172    /// Unique ID of the persisted receipt event.
173    pub event_id: batpak::id::EventId,
174    /// Global sequence assigned by batpak at commit time.
175    pub sequence: u64,
176    /// Blake3 hash of the committed receipt payload bytes.
177    pub content_hash: ReceiptHash,
178    /// Signing-key identity reported by batpak.
179    pub key_id: ReceiptHash,
180    /// Detached receipt signature when store signing is enabled.
181    pub signature: Option<[u8; 64]>,
182    /// Opaque receipt extensions committed with the batpak append receipt.
183    pub extensions: BTreeMap<ExtensionKey, EncodedBytes>,
184}
185
186impl From<batpak::store::AppendReceipt> for BatpakReceiptFields {
187    fn from(receipt: batpak::store::AppendReceipt) -> Self {
188        Self {
189            event_id: receipt.event_id,
190            sequence: receipt.global_sequence,
191            content_hash: receipt.content_hash,
192            key_id: receipt.key_id,
193            signature: receipt.signature,
194            extensions: receipt.extensions,
195        }
196    }
197}
198
199/// Generic syncbat receipt envelope persisted as an event payload.
200///
201/// The signed drawer is copied into batpak receipt extensions by
202/// [`crate::store_sink::StoreReceiptSink`]. The local drawer remains only in
203/// the syncbat envelope body for callers that need local, profile-owned
204/// diagnostics without adding batpak receipt-extension keys.
205#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
206#[non_exhaustive]
207pub struct ReceiptEnvelope {
208    /// Stable operation descriptor name.
209    pub descriptor_name: String,
210    /// Stable receipt kind from the operation descriptor.
211    pub receipt_kind: String,
212    /// Optional hash of the operation input bytes.
213    pub input_hash: Option<ReceiptHash>,
214    /// Optional hash of the operation output bytes.
215    pub output_hash: Option<ReceiptHash>,
216    /// Runtime result for this operation attempt.
217    pub outcome: ReceiptOutcome,
218    /// Opaque extension drawer intended for batpak receipt extensions.
219    pub signed_extensions: ReceiptExtensionDrawer,
220    /// Opaque extension drawer kept in the syncbat envelope body.
221    pub local_extensions: ReceiptExtensionDrawer,
222}
223
224impl ReceiptEnvelope {
225    /// Construct an envelope from an operation descriptor.
226    #[must_use]
227    pub fn new(descriptor: &OperationDescriptor, outcome: ReceiptOutcome) -> Self {
228        Self::from_descriptor(descriptor.name(), descriptor.receipt_kind(), outcome)
229    }
230
231    /// Construct an envelope from stable descriptor receipt fields.
232    #[must_use]
233    pub fn from_descriptor(
234        descriptor_name: impl Into<String>,
235        receipt_kind: impl Into<String>,
236        outcome: ReceiptOutcome,
237    ) -> Self {
238        Self {
239            descriptor_name: descriptor_name.into(),
240            receipt_kind: receipt_kind.into(),
241            input_hash: None,
242            output_hash: None,
243            outcome,
244            signed_extensions: BTreeMap::new(),
245            local_extensions: BTreeMap::new(),
246        }
247    }
248
249    /// Attach an input hash.
250    #[must_use]
251    pub fn with_input_hash(mut self, hash: ReceiptHash) -> Self {
252        self.input_hash = Some(hash);
253        self
254    }
255
256    /// Attach an output hash.
257    #[must_use]
258    pub fn with_output_hash(mut self, hash: ReceiptHash) -> Self {
259        self.output_hash = Some(hash);
260        self
261    }
262
263    /// Insert one signed extension entry.
264    #[must_use]
265    pub fn with_signed_extension(
266        mut self,
267        key: impl Into<String>,
268        value: impl Into<Vec<u8>>,
269    ) -> Self {
270        self.signed_extensions.insert(key.into(), value.into());
271        self
272    }
273
274    /// Insert one local extension entry.
275    #[must_use]
276    pub fn with_local_extension(
277        mut self,
278        key: impl Into<String>,
279        value: impl Into<Vec<u8>>,
280    ) -> Self {
281        self.local_extensions.insert(key.into(), value.into());
282        self
283    }
284}
285
286impl EventPayload for ReceiptEnvelope {
287    const KIND: EventKind = SYNCBAT_RECEIPT_EVENT_KIND;
288}
289
290/// Receipt envelope plus sink-owned persistence metadata.
291///
292/// This type is returned by sinks after recording. The persisted event payload
293/// remains [`ReceiptEnvelope`], so append-result metadata cannot accidentally
294/// become part of the event body.
295#[derive(Clone, Debug, Eq, PartialEq)]
296#[non_exhaustive]
297pub struct RecordedReceipt {
298    /// Envelope body that was recorded.
299    pub envelope: ReceiptEnvelope,
300    /// Batpak receipt fields when the envelope was recorded through batpak.
301    pub batpak_receipt: Option<BatpakReceiptFields>,
302}
303
304impl RecordedReceipt {
305    /// Construct recorded receipt metadata for a receipt envelope.
306    #[must_use]
307    pub fn new(envelope: ReceiptEnvelope) -> Self {
308        Self {
309            envelope,
310            batpak_receipt: None,
311        }
312    }
313
314    /// Attach batpak receipt fields.
315    #[must_use]
316    pub fn with_batpak_receipt(mut self, receipt: impl Into<BatpakReceiptFields>) -> Self {
317        self.batpak_receipt = Some(receipt.into());
318        self
319    }
320}
321
322/// Error returned when a receipt sink cannot record an envelope.
323#[derive(Debug, Clone, Eq, PartialEq)]
324pub struct ReceiptSinkError {
325    message: String,
326}
327
328impl ReceiptSinkError {
329    /// Construct a receipt-sink error from a displayable message.
330    #[must_use]
331    pub fn new(message: impl Into<String>) -> Self {
332        Self {
333            message: message.into(),
334        }
335    }
336
337    /// Return the sink error message.
338    #[must_use]
339    pub fn message(&self) -> &str {
340        &self.message
341    }
342}
343
344impl fmt::Display for ReceiptSinkError {
345    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
346        f.write_str(&self.message)
347    }
348}
349
350impl Error for ReceiptSinkError {}
351
352/// Sink for completed syncbat receipt envelopes.
353pub trait ReceiptSink {
354    /// Persist a receipt envelope and return sink-owned persistence metadata.
355    ///
356    /// # Errors
357    /// Returns [`ReceiptSinkError`] when the sink rejects or fails the write.
358    fn record_receipt(
359        &self,
360        envelope: &ReceiptEnvelope,
361    ) -> Result<RecordedReceipt, ReceiptSinkError>;
362}
363
364#[cfg(test)]
365mod receipt_sink_error_tests {
366    use super::ReceiptSinkError;
367
368    #[test]
369    fn message_returns_the_constructed_text() {
370        // Pins the accessor: returning a constant ("xyzzy") would mask the real
371        // sink failure reason from callers and receipts.
372        let err = ReceiptSinkError::new("sink offline");
373        assert_eq!(err.message(), "sink offline");
374        assert_eq!(err.to_string(), "sink offline");
375    }
376}
377
378#[cfg(test)]
379mod receipt_metadata_tests {
380    use super::ReceiptMetadata;
381
382    #[test]
383    fn is_empty_reports_both_drawers() {
384        // Fresh metadata is empty: kills the `-> false` constant.
385        assert!(
386            ReceiptMetadata::default().is_empty(),
387            "freshly constructed metadata has no entries"
388        );
389
390        // A signed-only entry is non-empty: the `signed.is_empty()` side is
391        // false here, so `&& -> ||` would flip the result to empty, and the
392        // `-> true` constant would also claim empty.
393        let mut signed_only = ReceiptMetadata::default();
394        signed_only.signed.insert("k".to_owned(), vec![1]);
395        assert!(
396            !signed_only.is_empty(),
397            "a signed entry makes metadata non-empty"
398        );
399
400        // A local-only entry exercises the other side of the conjunction.
401        let mut local_only = ReceiptMetadata::default();
402        local_only.local.insert("k".to_owned(), vec![2]);
403        assert!(
404            !local_only.is_empty(),
405            "a local entry makes metadata non-empty"
406        );
407    }
408}