Skip to main content

lean_ctx_protocol/hive/
receipts_v2.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Engine receipts v2 (C0 2.5 `schemas/receipts.schema.json`, K2 review M3).
4//!
5//! `POST /v1/receipts` [`ReceiptsRequestV2`] → [`ReceiptsAckV2`]: closed
6//! engine receipt documents ([`EngineReceiptV2`], invocation + observation),
7//! signed by the instance key (Ed25519 over [`RECEIPTS_DOMAIN`] ‖
8//! canonical_json(body with `key_id`, without `signature`), as engine
9//! metrics). Hive refuses v1 documents.
10//!
11//! No content, path or reference leaves the engine, enforced by the types:
12//! input and output are [`ReceiptHmac`] values under an engine-local key
13//! Hive never receives; source references are a count; failure recovery
14//! references do not exist here; the only digests are the Hive-issued
15//! policy digest and the item digest over this document. Every string is a
16//! closed-pattern newtype or a closed enum and every struct refuses unknown
17//! fields.
18
19use std::collections::BTreeSet;
20use std::fmt;
21
22use serde::{Deserialize, Deserializer, Serialize, Serializer};
23
24use super::metrics_v2::{KeyId, MAX_COUNT, SignatureHex, TimeStamp, VaultId};
25use super::metrics_v2::{closed_string, schema_const};
26use crate::ValidationError;
27
28pub const RECEIPTS_V2_SCHEMA: &str = "leanctx.hive.receipts/v2";
29pub const RECEIPTS_V2_ACK_SCHEMA: &str = "leanctx.hive.receipts-ack/v2";
30pub const ENGINE_RECEIPT_V2_SCHEMA: &str = "leanctx.engine-receipt/v2";
31/// Domain of the batch signature (instance key).
32pub const RECEIPTS_DOMAIN: &str = "leanctx.hive.receipts.v2\n";
33/// `POST /v1/receipts`, relative to the engine API base.
34pub const RECEIPTS_ROUTE: &str = "/receipts";
35/// The only engine id a v2 receipt names.
36pub const RECEIPT_ENGINE_ID: &str = "lean-ctx-local";
37/// The only capability a v2 receipt names.
38pub const RECEIPT_CAPABILITY_ID: &str = "capability://leanctx/context-optimization";
39/// Prefix of a [`ReceiptHmac`].
40pub const HMAC_PREFIX: &str = "hmac-sha256:";
41
42pub const MAX_RECEIPTS_V2: usize = 256;
43pub const MAX_RECEIPTS_BODY_BYTES: usize = 1 << 20;
44pub const MAX_MEASUREMENTS: usize = 32;
45pub const MAX_SOURCE_COUNT: u16 = 256;
46
47fn is_lower_hex(value: &str) -> bool {
48    value
49        .bytes()
50        .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
51}
52
53fn is_hmac(value: &str) -> bool {
54    value
55        .strip_prefix(HMAC_PREFIX)
56        .is_some_and(|hex| hex.len() == 64 && is_lower_hex(hex))
57}
58
59fn is_sha256(value: &str) -> bool {
60    value
61        .strip_prefix("sha256:")
62        .is_some_and(|hex| hex.len() == 64 && is_lower_hex(hex))
63}
64
65fn is_receipt_id(value: &str) -> bool {
66    value.strip_prefix("rcpt_").is_some_and(|rest| {
67        (1..=64).contains(&rest.len())
68            && rest
69                .bytes()
70                .all(|b| b.is_ascii_digit() || b.is_ascii_lowercase())
71    })
72}
73
74fn is_invocation_id(value: &str) -> bool {
75    (32..=64).contains(&value.len()) && is_lower_hex(value)
76}
77
78/// `^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,6}(-[0-9A-Za-z.]{1,32})?$`
79fn is_semver(value: &str) -> bool {
80    let (core, pre) = match value.split_once('-') {
81        Some((core, pre)) => (core, Some(pre)),
82        None => (value, None),
83    };
84    let parts: Vec<&str> = core.split('.').collect();
85    let digits =
86        |p: &str, max: usize| (1..=max).contains(&p.len()) && p.bytes().all(|b| b.is_ascii_digit());
87    parts.len() == 3
88        && digits(parts[0], 4)
89        && digits(parts[1], 4)
90        && digits(parts[2], 6)
91        && pre.is_none_or(|p| {
92            (1..=32).contains(&p.len()) && p.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'.')
93        })
94}
95
96/// `^[a-z0-9_-]{1,64}$` and never a run of 24+ letters or digits.
97fn is_measurement_name(value: &str) -> bool {
98    let shape = (1..=64).contains(&value.len())
99        && value
100            .bytes()
101            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-'));
102    let mut run = 0usize;
103    let token_like = value.bytes().any(|b| {
104        run = if b.is_ascii_alphanumeric() {
105            run + 1
106        } else {
107            0
108        };
109        run >= 24
110    });
111    shape && !token_like
112}
113
114closed_string!(
115    /// `hmac-sha256:<64 hex>` under the engine-local receipt key.
116    ReceiptHmac, is_hmac, "receipt hmac"
117);
118closed_string!(
119    /// `sha256:<64 hex>`: the Hive-issued policy digest or the item digest.
120    Sha256Ref, is_sha256, "sha256 digest"
121);
122closed_string!(
123    /// `rcpt_<1-64 of [0-9a-z]>`.
124    ReceiptIdV2, is_receipt_id, "receipt id"
125);
126closed_string!(
127    /// 32-64 lowercase hex, derived by hashing (never a path or name).
128    InvocationIdV2, is_invocation_id, "invocation id"
129);
130closed_string!(
131    /// `major.minor.patch[-pre]` as the contract allows it.
132    ReceiptSemver, is_semver, "version"
133);
134closed_string!(
135    /// A measurement name (never token-like).
136    MeasurementName, is_measurement_name, "measurement name"
137);
138
139schema_const!(ReceiptsSchema, RECEIPTS_V2_SCHEMA);
140schema_const!(ReceiptsAckSchema, RECEIPTS_V2_ACK_SCHEMA);
141schema_const!(EngineReceiptSchema, ENGINE_RECEIPT_V2_SCHEMA);
142schema_const!(EngineIdConst, RECEIPT_ENGINE_ID);
143schema_const!(CapabilityIdConst, RECEIPT_CAPABILITY_ID);
144schema_const!(EngineKindConst, "engine");
145
146/// `schema_version: 1` and nothing else.
147#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
148pub struct SchemaVersionOne;
149
150impl Serialize for SchemaVersionOne {
151    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
152        serializer.serialize_u8(1)
153    }
154}
155
156impl<'de> Deserialize<'de> for SchemaVersionOne {
157    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
158        (u64::deserialize(deserializer)? == 1)
159            .then_some(Self)
160            .ok_or_else(|| serde::de::Error::custom("schema_version must be 1"))
161    }
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "snake_case")]
166pub enum MeasurementUnit {
167    Tokens,
168    Bytes,
169    Ms,
170    Count,
171    Lines,
172    RatioPpm,
173}
174
175impl MeasurementUnit {
176    /// The unit a local measurement names, `None` outside the closed set.
177    pub fn of(unit: &str) -> Option<Self> {
178        Some(match unit {
179            "tokens" => Self::Tokens,
180            "bytes" => Self::Bytes,
181            "ms" => Self::Ms,
182            "count" => Self::Count,
183            "lines" => Self::Lines,
184            "ratio_ppm" => Self::RatioPpm,
185            _ => return None,
186        })
187    }
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(rename_all = "snake_case")]
192pub enum Classification {
193    Measured,
194    Estimated,
195    Unavailable,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(rename_all = "snake_case")]
200pub enum AdmissionDecision {
201    Admitted,
202    Rejected,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
206#[serde(rename_all = "snake_case")]
207pub enum ObservationStatus {
208    Succeeded,
209    Degraded,
210    Rejected,
211    Failed,
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(rename_all = "snake_case")]
216pub enum FailureCode {
217    PolicyRejected,
218    SourceUnavailable,
219    SourceIntegrityMismatch,
220    ResourceLimit,
221    UnsupportedOperation,
222    Internal,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(deny_unknown_fields)]
227pub struct ReceiptEngineV2 {
228    pub engine_id: EngineIdConst,
229    pub engine_version: ReceiptSemver,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(deny_unknown_fields)]
234pub struct ReceiptOperationV2 {
235    pub capability_id: CapabilityIdConst,
236    pub capability_version: ReceiptSemver,
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(deny_unknown_fields)]
241pub struct PolicyAdmissionV2 {
242    pub decision: AdmissionDecision,
243    /// The effective-policy digest Hive issued, or `null`.
244    pub policy_digest: Option<Sha256Ref>,
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(deny_unknown_fields)]
249pub struct ReceiptInvocationV2 {
250    pub schema_version: SchemaVersionOne,
251    pub invocation_id: InvocationIdV2,
252    pub engine: ReceiptEngineV2,
253    pub operation: ReceiptOperationV2,
254    pub input_hmac: ReceiptHmac,
255    /// 1-256.
256    pub source_count: u16,
257    pub policy_admission: PolicyAdmissionV2,
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(deny_unknown_fields)]
262pub struct MeasurementV2 {
263    pub name: MeasurementName,
264    pub unit: MeasurementUnit,
265    pub classification: Classification,
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub value: Option<u64>,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271#[serde(deny_unknown_fields)]
272pub struct FailureV2 {
273    pub code: FailureCode,
274    pub retryable_by_host: bool,
275}
276
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(deny_unknown_fields)]
279pub struct ReceiptObservationV2 {
280    pub schema_version: SchemaVersionOne,
281    pub invocation_id: InvocationIdV2,
282    pub status: ObservationStatus,
283    pub output_hmac: Option<ReceiptHmac>,
284    /// 0-256.
285    pub source_count: u16,
286    pub measurements: Vec<MeasurementV2>,
287    pub failure: Option<FailureV2>,
288}
289
290/// The receipt artifact as uploaded.
291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
292#[serde(deny_unknown_fields)]
293pub struct EngineReceiptV2 {
294    pub schema: EngineReceiptSchema,
295    pub invocation: ReceiptInvocationV2,
296    pub observation: ReceiptObservationV2,
297}
298
299impl EngineReceiptV2 {
300    pub fn validate(&self) -> Result<(), ValidationError> {
301        let (i, o) = (&self.invocation, &self.observation);
302        if i.invocation_id != o.invocation_id {
303            return Err(ValidationError::new("receipt: invocation_id differs"));
304        }
305        if !(1..=MAX_SOURCE_COUNT).contains(&i.source_count) || o.source_count > MAX_SOURCE_COUNT {
306            return Err(ValidationError::new("receipt: source_count out of range"));
307        }
308        if o.measurements.len() > MAX_MEASUREMENTS
309            || o.measurements
310                .iter()
311                .any(|m| m.value.is_some_and(|v| v > MAX_COUNT))
312        {
313            return Err(ValidationError::new("receipt: measurements"));
314        }
315        Ok(())
316    }
317
318    /// `sha256:` of canonical_json(document).
319    pub fn digest(&self) -> Result<Sha256Ref, ValidationError> {
320        use sha2::Digest as _;
321        let value = serde_json::to_value(self)
322            .map_err(|e| ValidationError::new(format!("receipt: encode: {e}")))?;
323        let bytes = super::canonical_json(&value)?;
324        let hex: String = sha2::Sha256::digest(&bytes)
325            .iter()
326            .map(|b| format!("{b:02x}"))
327            .collect();
328        Sha256Ref::new(&format!("sha256:{hex}"))
329            .ok_or_else(|| ValidationError::new("receipt: digest"))
330    }
331}
332
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
334#[serde(deny_unknown_fields)]
335pub struct ReceiptItemV2 {
336    pub receipt_id: ReceiptIdV2,
337    pub kind: EngineKindConst,
338    pub vault_id: VaultId,
339    pub created_at: TimeStamp,
340    pub digest: Sha256Ref,
341    pub document: EngineReceiptV2,
342}
343
344impl ReceiptItemV2 {
345    /// An item over `document`, its digest computed here.
346    pub fn new(
347        receipt_id: ReceiptIdV2,
348        vault_id: VaultId,
349        created_at: TimeStamp,
350        document: EngineReceiptV2,
351    ) -> Result<Self, ValidationError> {
352        document.validate()?;
353        Ok(Self {
354            receipt_id,
355            kind: EngineKindConst,
356            vault_id,
357            created_at,
358            digest: document.digest()?,
359            document,
360        })
361    }
362
363    pub fn validate(&self) -> Result<(), ValidationError> {
364        self.document.validate()?;
365        if self.document.digest()? != self.digest {
366            return Err(ValidationError::new(
367                "receipt: digest does not bind the document",
368            ));
369        }
370        Ok(())
371    }
372}
373
374/// The signed part of a batch.
375#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
376#[serde(deny_unknown_fields)]
377pub struct ReceiptsBodyV2 {
378    pub schema: ReceiptsSchema,
379    pub receipts: Vec<ReceiptItemV2>,
380}
381
382impl ReceiptsBodyV2 {
383    pub fn validate(&self) -> Result<(), ValidationError> {
384        if self.receipts.is_empty() || self.receipts.len() > MAX_RECEIPTS_V2 {
385            return Err(ValidationError::new("receipts: 1-256 per batch"));
386        }
387        let mut ids = BTreeSet::new();
388        if !self
389            .receipts
390            .iter()
391            .all(|r| ids.insert(r.receipt_id.as_str()))
392        {
393            return Err(ValidationError::new("receipts: duplicate receipt_id"));
394        }
395        self.receipts.iter().try_for_each(ReceiptItemV2::validate)
396    }
397
398    /// The bytes the signature covers: the batch with `key_id` and without
399    /// `signature`.
400    pub fn signing_input(&self, key_id: &KeyId) -> Result<Vec<u8>, ValidationError> {
401        let mut value = serde_json::to_value(self)
402            .map_err(|e| ValidationError::new(format!("receipts: encode: {e}")))?;
403        value["key_id"] = serde_json::Value::String(key_id.as_str().to_string());
404        super::signing_input(RECEIPTS_DOMAIN, &value)
405    }
406
407    pub fn signed(self, key_id: KeyId, signature: SignatureHex) -> ReceiptsRequestV2 {
408        ReceiptsRequestV2 {
409            schema: self.schema,
410            receipts: self.receipts,
411            key_id,
412            signature,
413        }
414    }
415}
416
417/// `POST /v1/receipts` body, at most 1 MiB.
418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
419#[serde(deny_unknown_fields)]
420pub struct ReceiptsRequestV2 {
421    pub schema: ReceiptsSchema,
422    pub receipts: Vec<ReceiptItemV2>,
423    pub key_id: KeyId,
424    pub signature: SignatureHex,
425}
426
427impl ReceiptsRequestV2 {
428    pub fn body(&self) -> ReceiptsBodyV2 {
429        ReceiptsBodyV2 {
430            schema: self.schema,
431            receipts: self.receipts.clone(),
432        }
433    }
434
435    pub fn validate(&self) -> Result<(), ValidationError> {
436        self.body().validate()
437    }
438}
439
440#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
441#[serde(deny_unknown_fields)]
442pub struct ReceiptsAckV2 {
443    pub schema: ReceiptsAckSchema,
444    pub accepted: u16,
445    pub duplicates: u16,
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn closed_strings_follow_the_contract_patterns() {
454        assert!(ReceiptHmac::new(&format!("hmac-sha256:{}", "a".repeat(64))).is_some());
455        for bad in [
456            format!("sha256:{}", "a".repeat(64)),
457            format!("hmac-sha256:{}", "A".repeat(64)),
458            "hmac-sha256:".to_string(),
459        ] {
460            assert!(ReceiptHmac::new(&bad).is_none(), "{bad}");
461        }
462        assert!(ReceiptIdV2::new("rcpt_01jb8z5").is_some());
463        assert!(ReceiptIdV2::new("engine-receipt-1").is_none());
464        assert!(InvocationIdV2::new(&"0".repeat(32)).is_some());
465        assert!(InvocationIdV2::new(&"0".repeat(31)).is_none());
466        assert!(InvocationIdV2::new("src/main.rs").is_none());
467        for good in ["3.12.0", "1.0.0", "3.12.0-rc.1"] {
468            assert!(ReceiptSemver::new(good).is_some(), "{good}");
469        }
470        for bad in ["3.12", "3.12.0-", "3.12.0-a b", "v3.12.0"] {
471            assert!(ReceiptSemver::new(bad).is_none(), "{bad}");
472        }
473        assert!(MeasurementName::new("tokens_baseline").is_some());
474        for bad in ["skprojabcdefghijklmnopqrstuvwxyz", "/etc/passwd", "A", ""] {
475            assert!(MeasurementName::new(bad).is_none(), "{bad}");
476        }
477    }
478}