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}