syncbat 0.10.0

Sync-first runtime layer for batpak-family operation kits.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
//! Generic receipt envelope types for syncbat operation runs.

use std::collections::BTreeMap;
use std::sync::Arc;
use std::{error::Error, fmt};

use batpak::event::{EventKind, EventPayload};
use batpak::store::{EncodedBytes, ExtensionKey};
use serde::{Deserialize, Serialize};

use crate::operation::OperationDescriptor;

/// Batpak custom event kind used for syncbat receipt events.
///
/// Category `0xC` is a caller-defined category and is not used by the core
/// examples, which currently reserve their example payloads under category
/// `0x1`. Type id `0x5B7` is scoped to syncbat's generic receipt envelope.
pub const SYNCBAT_RECEIPT_EVENT_KIND: EventKind = EventKind::custom(0xC, 0x5B7);

/// Stable hash bytes carried by a syncbat receipt.
pub type ReceiptHash = [u8; 32];

/// Caller-owned raw byte hasher for runtime receipt input/output hashes.
pub trait ReceiptHasher {
    /// Return a stable hash for already-encoded operation bytes.
    fn hash(&self, bytes: &[u8]) -> ReceiptHash;
}

impl<F> ReceiptHasher for F
where
    F: Fn(&[u8]) -> ReceiptHash,
{
    fn hash(&self, bytes: &[u8]) -> ReceiptHash {
        self(bytes)
    }
}

/// Runtime policy for populating receipt input/output hashes.
///
/// The default is [`ReceiptHashPolicy::Blake3`]: a runtime receipt always binds
/// to the exact bytes that produced it. An unhashed receipt
/// (`input_hash`/`output_hash` both `None`) binds to nothing, so the unhashed
/// mode is reachable only as the explicit opt-out
/// [`ReceiptHashPolicy::Deferred`]. This mirrors the store's signing-policy
/// idiom: the safe behavior is the default and the weaker behavior is an
/// explicit escape hatch.
#[derive(Clone, Default)]
#[non_exhaustive]
pub enum ReceiptHashPolicy {
    /// Hash raw handler input/output bytes with the built-in deterministic
    /// Blake3 hasher (a 32-byte digest over the raw bytes). This is the safe
    /// default: every recorded receipt carries the hash of the bytes it covers.
    #[default]
    Blake3,
    /// Defer hash population to a later layer; runtime receipts carry no byte
    /// hashes. This is the explicit opt-out for callers that truly want unhashed
    /// receipts (e.g. a higher layer that hashes and binds the bytes itself).
    Deferred,
    /// Hash raw handler input/output bytes with a deterministic caller-owned hasher.
    RawBytes(Arc<dyn ReceiptHasher>),
}

impl ReceiptHashPolicy {
    /// Build a raw-byte hash policy from a caller-owned deterministic hasher.
    #[must_use]
    pub fn raw_bytes(hasher: impl ReceiptHasher + 'static) -> Self {
        Self::RawBytes(Arc::new(hasher))
    }

    /// Return the configured raw-byte hash for `bytes`, when enabled.
    #[must_use]
    pub fn hash(&self, bytes: &[u8]) -> Option<ReceiptHash> {
        match self {
            Self::Blake3 => Some(*blake3::hash(bytes).as_bytes()),
            Self::Deferred => None,
            Self::RawBytes(hasher) => Some(hasher.hash(bytes)),
        }
    }
}

/// Opaque extension drawer attached to a syncbat receipt.
///
/// Keys are profile-owned strings. Values are already-encoded bytes so this
/// layer does not impose a schema on higher-level operation kits.
pub type ReceiptExtensionDrawer = BTreeMap<String, Vec<u8>>;

/// Opaque receipt metadata a handler or admission guard attaches to the current
/// invocation via [`crate::Ctx`]. The runtime drains it into the recorded
/// [`ReceiptEnvelope`]'s drawers — `signed` into [`ReceiptEnvelope::signed_extensions`]
/// (copied into batpak receipt extensions by the store sink) and `local` into
/// [`ReceiptEnvelope::local_extensions`] (envelope body only). It exists so a
/// handler can stamp correlation/attempt metadata onto its receipt WITHOUT
/// owning the receipt envelope, preserving the runtime's sole ownership of
/// receipt persistence.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ReceiptMetadata {
    /// Entries destined for the envelope's signed drawer.
    pub signed: ReceiptExtensionDrawer,
    /// Entries kept only in the envelope's local drawer.
    pub local: ReceiptExtensionDrawer,
}

impl ReceiptMetadata {
    /// Return true when no metadata has been attached.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.signed.is_empty() && self.local.is_empty()
    }
}

/// Runtime result recorded for a completed operation attempt.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[non_exhaustive]
pub enum ReceiptOutcome {
    /// The operation completed and produced any expected output.
    Completed,
    /// The operation ran but failed before producing a usable result.
    Failed {
        /// Stable failure class.
        code: String,
        /// Human-readable failure detail.
        message: String,
    },
    /// The direct sink or policy layer declined to execute or publish the
    /// operation result.
    ///
    /// `Core` checkout dispatch emits `Completed` or `Failed`; `Denied` is
    /// reserved for direct receipt sinks, admission checks, and network guards
    /// that reject a call before handler execution.
    Denied {
        /// Stable denial class.
        code: String,
        /// Human-readable denial detail.
        message: String,
    },
}

impl ReceiptOutcome {
    /// Construct a failed outcome.
    #[must_use]
    pub fn failed(code: impl Into<String>, message: impl Into<String>) -> Self {
        Self::Failed {
            code: code.into(),
            message: message.into(),
        }
    }

    /// Construct a denied outcome.
    #[must_use]
    pub fn denied(code: impl Into<String>, message: impl Into<String>) -> Self {
        Self::Denied {
            code: code.into(),
            message: message.into(),
        }
    }

    /// Return the stable outcome class used in receipt extensions.
    #[must_use]
    pub const fn class(&self) -> &'static str {
        match self {
            Self::Completed => "completed",
            Self::Failed { .. } => "failed",
            Self::Denied { .. } => "denied",
        }
    }
}

/// Batpak append receipt fields associated with a persisted syncbat receipt.
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct BatpakReceiptFields {
    /// Unique ID of the persisted receipt event.
    pub event_id: batpak::id::EventId,
    /// Global sequence assigned by batpak at commit time.
    pub sequence: u64,
    /// Blake3 hash of the committed receipt payload bytes.
    pub content_hash: ReceiptHash,
    /// Signing-key identity reported by batpak.
    pub key_id: ReceiptHash,
    /// Detached receipt signature when store signing is enabled.
    pub signature: Option<[u8; 64]>,
    /// Opaque receipt extensions committed with the batpak append receipt.
    pub extensions: BTreeMap<ExtensionKey, EncodedBytes>,
}

impl From<batpak::store::AppendReceipt> for BatpakReceiptFields {
    fn from(receipt: batpak::store::AppendReceipt) -> Self {
        Self {
            event_id: receipt.event_id,
            sequence: receipt.global_sequence,
            content_hash: receipt.content_hash,
            key_id: receipt.key_id,
            signature: receipt.signature,
            extensions: receipt.extensions,
        }
    }
}

/// Generic syncbat receipt envelope persisted as an event payload.
///
/// The signed drawer is copied into batpak receipt extensions by
/// [`crate::store_sink::StoreReceiptSink`]. The local drawer remains only in
/// the syncbat envelope body for callers that need local, profile-owned
/// diagnostics without adding batpak receipt-extension keys.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[non_exhaustive]
pub struct ReceiptEnvelope {
    /// Stable operation descriptor name.
    pub descriptor_name: String,
    /// Stable receipt kind from the operation descriptor.
    pub receipt_kind: String,
    /// Optional hash of the operation input bytes.
    pub input_hash: Option<ReceiptHash>,
    /// Optional hash of the operation output bytes.
    pub output_hash: Option<ReceiptHash>,
    /// Runtime result for this operation attempt.
    pub outcome: ReceiptOutcome,
    /// Opaque extension drawer intended for batpak receipt extensions.
    pub signed_extensions: ReceiptExtensionDrawer,
    /// Opaque extension drawer kept in the syncbat envelope body.
    pub local_extensions: ReceiptExtensionDrawer,
}

impl ReceiptEnvelope {
    /// Construct an envelope from an operation descriptor.
    #[must_use]
    pub fn new(descriptor: &OperationDescriptor, outcome: ReceiptOutcome) -> Self {
        Self::from_descriptor(descriptor.name(), descriptor.receipt_kind(), outcome)
    }

    /// Construct an envelope from stable descriptor receipt fields.
    #[must_use]
    pub fn from_descriptor(
        descriptor_name: impl Into<String>,
        receipt_kind: impl Into<String>,
        outcome: ReceiptOutcome,
    ) -> Self {
        Self {
            descriptor_name: descriptor_name.into(),
            receipt_kind: receipt_kind.into(),
            input_hash: None,
            output_hash: None,
            outcome,
            signed_extensions: BTreeMap::new(),
            local_extensions: BTreeMap::new(),
        }
    }

    /// Attach an input hash.
    #[must_use]
    pub fn with_input_hash(mut self, hash: ReceiptHash) -> Self {
        self.input_hash = Some(hash);
        self
    }

    /// Attach an output hash.
    #[must_use]
    pub fn with_output_hash(mut self, hash: ReceiptHash) -> Self {
        self.output_hash = Some(hash);
        self
    }

    /// Insert one signed extension entry.
    #[must_use]
    pub fn with_signed_extension(
        mut self,
        key: impl Into<String>,
        value: impl Into<Vec<u8>>,
    ) -> Self {
        self.signed_extensions.insert(key.into(), value.into());
        self
    }

    /// Insert one local extension entry.
    #[must_use]
    pub fn with_local_extension(
        mut self,
        key: impl Into<String>,
        value: impl Into<Vec<u8>>,
    ) -> Self {
        self.local_extensions.insert(key.into(), value.into());
        self
    }
}

impl EventPayload for ReceiptEnvelope {
    const KIND: EventKind = SYNCBAT_RECEIPT_EVENT_KIND;
}

/// Receipt envelope plus sink-owned persistence metadata.
///
/// This type is returned by sinks after recording. The persisted event payload
/// remains [`ReceiptEnvelope`], so append-result metadata cannot accidentally
/// become part of the event body.
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct RecordedReceipt {
    /// Envelope body that was recorded.
    pub envelope: ReceiptEnvelope,
    /// Batpak receipt fields when the envelope was recorded through batpak.
    pub batpak_receipt: Option<BatpakReceiptFields>,
}

impl RecordedReceipt {
    /// Construct recorded receipt metadata for a receipt envelope.
    #[must_use]
    pub fn new(envelope: ReceiptEnvelope) -> Self {
        Self {
            envelope,
            batpak_receipt: None,
        }
    }

    /// Attach batpak receipt fields.
    #[must_use]
    pub fn with_batpak_receipt(mut self, receipt: impl Into<BatpakReceiptFields>) -> Self {
        self.batpak_receipt = Some(receipt.into());
        self
    }
}

/// Error returned when a receipt sink cannot record an envelope.
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct ReceiptSinkError {
    message: String,
}

impl ReceiptSinkError {
    /// Construct a receipt-sink error from a displayable message.
    #[must_use]
    pub fn new(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
        }
    }

    /// Return the sink error message.
    #[must_use]
    pub fn message(&self) -> &str {
        &self.message
    }
}

impl fmt::Display for ReceiptSinkError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.message)
    }
}

impl Error for ReceiptSinkError {}

/// Sink for completed syncbat receipt envelopes.
pub trait ReceiptSink {
    /// Persist a receipt envelope and return sink-owned persistence metadata.
    ///
    /// # Errors
    /// Returns [`ReceiptSinkError`] when the sink rejects or fails the write.
    fn record_receipt(
        &self,
        envelope: &ReceiptEnvelope,
    ) -> Result<RecordedReceipt, ReceiptSinkError>;
}

#[cfg(test)]
mod receipt_sink_error_tests {
    use super::ReceiptSinkError;

    #[test]
    fn message_returns_the_constructed_text() {
        // Pins the accessor: returning a constant ("xyzzy") would mask the real
        // sink failure reason from callers and receipts.
        let err = ReceiptSinkError::new("sink offline");
        assert_eq!(err.message(), "sink offline");
        assert_eq!(err.to_string(), "sink offline");
    }
}

#[cfg(test)]
mod receipt_metadata_tests {
    use super::ReceiptMetadata;

    #[test]
    fn is_empty_reports_both_drawers() {
        // Fresh metadata is empty: kills the `-> false` constant.
        assert!(
            ReceiptMetadata::default().is_empty(),
            "freshly constructed metadata has no entries"
        );

        // A signed-only entry is non-empty: the `signed.is_empty()` side is
        // false here, so `&& -> ||` would flip the result to empty, and the
        // `-> true` constant would also claim empty.
        let mut signed_only = ReceiptMetadata::default();
        signed_only.signed.insert("k".to_owned(), vec![1]);
        assert!(
            !signed_only.is_empty(),
            "a signed entry makes metadata non-empty"
        );

        // A local-only entry exercises the other side of the conjunction.
        let mut local_only = ReceiptMetadata::default();
        local_only.local.insert("k".to_owned(), vec![2]);
        assert!(
            !local_only.is_empty(),
            "a local entry makes metadata non-empty"
        );
    }
}