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.instance-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,
117    is_hmac,
118    "receipt hmac"
119);
120closed_string!(
121    /// `sha256:<64 hex>`: the Hive-issued policy digest or the item digest.
122    Sha256Ref,
123    is_sha256,
124    "sha256 digest"
125);
126closed_string!(
127    /// `rcpt_<1-64 of [0-9a-z]>`.
128    ReceiptIdV2,
129    is_receipt_id,
130    "receipt id"
131);
132closed_string!(
133    /// 32-64 lowercase hex, derived by hashing (never a path or name).
134    InvocationIdV2,
135    is_invocation_id,
136    "invocation id"
137);
138closed_string!(
139    /// `major.minor.patch[-pre]` as the contract allows it.
140    ReceiptSemver,
141    is_semver,
142    "version"
143);
144closed_string!(
145    /// A measurement name (never token-like).
146    MeasurementName,
147    is_measurement_name,
148    "measurement name"
149);
150
151schema_const!(ReceiptsSchema, RECEIPTS_V2_SCHEMA);
152schema_const!(ReceiptsAckSchema, RECEIPTS_V2_ACK_SCHEMA);
153schema_const!(EngineReceiptSchema, ENGINE_RECEIPT_V2_SCHEMA);
154schema_const!(EngineIdConst, RECEIPT_ENGINE_ID);
155schema_const!(CapabilityIdConst, RECEIPT_CAPABILITY_ID);
156schema_const!(EngineKindConst, "instance");
157
158/// `schema_version: 1` and nothing else.
159#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
160pub struct SchemaVersionOne;
161
162impl Serialize for SchemaVersionOne {
163    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
164        serializer.serialize_u8(1)
165    }
166}
167
168impl<'de> Deserialize<'de> for SchemaVersionOne {
169    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
170        (u64::deserialize(deserializer)? == 1)
171            .then_some(Self)
172            .ok_or_else(|| serde::de::Error::custom("schema_version must be 1"))
173    }
174}
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(rename_all = "snake_case")]
178pub enum MeasurementUnit {
179    Tokens,
180    Bytes,
181    Ms,
182    Count,
183    Lines,
184    RatioPpm,
185}
186
187impl MeasurementUnit {
188    /// The unit a local measurement names, `None` outside the closed set.
189    pub fn of(unit: &str) -> Option<Self> {
190        Some(match unit {
191            "tokens" => Self::Tokens,
192            "bytes" => Self::Bytes,
193            "ms" => Self::Ms,
194            "count" => Self::Count,
195            "lines" => Self::Lines,
196            "ratio_ppm" => Self::RatioPpm,
197            _ => return None,
198        })
199    }
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "snake_case")]
204pub enum Classification {
205    Measured,
206    Estimated,
207    Unavailable,
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "snake_case")]
212pub enum AdmissionDecision {
213    Admitted,
214    Rejected,
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(rename_all = "snake_case")]
219pub enum ObservationStatus {
220    Succeeded,
221    Degraded,
222    Rejected,
223    Failed,
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(rename_all = "snake_case")]
228pub enum FailureCode {
229    PolicyRejected,
230    SourceUnavailable,
231    SourceIntegrityMismatch,
232    ResourceLimit,
233    UnsupportedOperation,
234    Internal,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238#[serde(deny_unknown_fields)]
239pub struct ReceiptEngineV2 {
240    pub instance_id: EngineIdConst,
241    pub instance_version: ReceiptSemver,
242}
243
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(deny_unknown_fields)]
246pub struct ReceiptOperationV2 {
247    pub capability_id: CapabilityIdConst,
248    pub capability_version: ReceiptSemver,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(deny_unknown_fields)]
253pub struct PolicyAdmissionV2 {
254    pub decision: AdmissionDecision,
255    /// The effective-policy digest Hive issued, or `null`.
256    pub policy_digest: Option<Sha256Ref>,
257}
258
259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(deny_unknown_fields)]
261pub struct ReceiptInvocationV2 {
262    pub schema_version: SchemaVersionOne,
263    pub invocation_id: InvocationIdV2,
264    pub instance: ReceiptEngineV2,
265    pub operation: ReceiptOperationV2,
266    pub input_hmac: ReceiptHmac,
267    /// 1-256.
268    pub source_count: u16,
269    pub policy_admission: PolicyAdmissionV2,
270}
271
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
273#[serde(deny_unknown_fields)]
274pub struct MeasurementV2 {
275    pub name: MeasurementName,
276    pub unit: MeasurementUnit,
277    pub classification: Classification,
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub value: Option<u64>,
280}
281
282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283#[serde(deny_unknown_fields)]
284pub struct FailureV2 {
285    pub code: FailureCode,
286    pub retryable_by_host: bool,
287}
288
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290#[serde(deny_unknown_fields)]
291pub struct ReceiptObservationV2 {
292    pub schema_version: SchemaVersionOne,
293    pub invocation_id: InvocationIdV2,
294    pub status: ObservationStatus,
295    pub output_hmac: Option<ReceiptHmac>,
296    /// 0-256.
297    pub source_count: u16,
298    pub measurements: Vec<MeasurementV2>,
299    pub failure: Option<FailureV2>,
300}
301
302/// The receipt artifact as uploaded.
303#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
304#[serde(deny_unknown_fields)]
305pub struct EngineReceiptV2 {
306    pub schema: EngineReceiptSchema,
307    pub invocation: ReceiptInvocationV2,
308    pub observation: ReceiptObservationV2,
309}
310
311impl EngineReceiptV2 {
312    pub fn validate(&self) -> Result<(), ValidationError> {
313        let (i, o) = (&self.invocation, &self.observation);
314        if i.invocation_id != o.invocation_id {
315            return Err(ValidationError::new("receipt: invocation_id differs"));
316        }
317        if !(1..=MAX_SOURCE_COUNT).contains(&i.source_count) || o.source_count > MAX_SOURCE_COUNT {
318            return Err(ValidationError::new("receipt: source_count out of range"));
319        }
320        if o.measurements.len() > MAX_MEASUREMENTS
321            || o.measurements
322                .iter()
323                .any(|m| m.value.is_some_and(|v| v > MAX_COUNT))
324        {
325            return Err(ValidationError::new("receipt: measurements"));
326        }
327        Ok(())
328    }
329
330    /// `sha256:` of canonical_json(document).
331    pub fn digest(&self) -> Result<Sha256Ref, ValidationError> {
332        use sha2::Digest as _;
333        let value = serde_json::to_value(self)
334            .map_err(|e| ValidationError::new(format!("receipt: encode: {e}")))?;
335        let bytes = super::canonical_json(&value)?;
336        let hex: String = sha2::Sha256::digest(&bytes)
337            .iter()
338            .map(|b| format!("{b:02x}"))
339            .collect();
340        Sha256Ref::new(&format!("sha256:{hex}"))
341            .ok_or_else(|| ValidationError::new("receipt: digest"))
342    }
343}
344
345#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
346#[serde(deny_unknown_fields)]
347pub struct ReceiptItemV2 {
348    pub receipt_id: ReceiptIdV2,
349    pub kind: EngineKindConst,
350    pub vault_id: VaultId,
351    pub created_at: TimeStamp,
352    pub digest: Sha256Ref,
353    pub document: EngineReceiptV2,
354}
355
356impl ReceiptItemV2 {
357    /// An item over `document`, its digest computed here.
358    pub fn new(
359        receipt_id: ReceiptIdV2,
360        vault_id: VaultId,
361        created_at: TimeStamp,
362        document: EngineReceiptV2,
363    ) -> Result<Self, ValidationError> {
364        document.validate()?;
365        Ok(Self {
366            receipt_id,
367            kind: EngineKindConst,
368            vault_id,
369            created_at,
370            digest: document.digest()?,
371            document,
372        })
373    }
374
375    pub fn validate(&self) -> Result<(), ValidationError> {
376        self.document.validate()?;
377        if self.document.digest()? != self.digest {
378            return Err(ValidationError::new(
379                "receipt: digest does not bind the document",
380            ));
381        }
382        Ok(())
383    }
384}
385
386/// The signed part of a batch.
387#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
388#[serde(deny_unknown_fields)]
389pub struct ReceiptsBodyV2 {
390    pub schema: ReceiptsSchema,
391    pub receipts: Vec<ReceiptItemV2>,
392}
393
394impl ReceiptsBodyV2 {
395    pub fn validate(&self) -> Result<(), ValidationError> {
396        if self.receipts.is_empty() || self.receipts.len() > MAX_RECEIPTS_V2 {
397            return Err(ValidationError::new("receipts: 1-256 per batch"));
398        }
399        let mut ids = BTreeSet::new();
400        if !self
401            .receipts
402            .iter()
403            .all(|r| ids.insert(r.receipt_id.as_str()))
404        {
405            return Err(ValidationError::new("receipts: duplicate receipt_id"));
406        }
407        self.receipts.iter().try_for_each(ReceiptItemV2::validate)
408    }
409
410    /// The bytes the signature covers: the batch with `key_id` and without
411    /// `signature`.
412    pub fn signing_input(&self, key_id: &KeyId) -> Result<Vec<u8>, ValidationError> {
413        let mut value = serde_json::to_value(self)
414            .map_err(|e| ValidationError::new(format!("receipts: encode: {e}")))?;
415        value["key_id"] = serde_json::Value::String(key_id.as_str().to_string());
416        super::signing_input(RECEIPTS_DOMAIN, &value)
417    }
418
419    pub fn signed(self, key_id: KeyId, signature: SignatureHex) -> ReceiptsRequestV2 {
420        ReceiptsRequestV2 {
421            schema: self.schema,
422            receipts: self.receipts,
423            key_id,
424            signature,
425        }
426    }
427}
428
429/// `POST /v1/receipts` body, at most 1 MiB.
430#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
431#[serde(deny_unknown_fields)]
432pub struct ReceiptsRequestV2 {
433    pub schema: ReceiptsSchema,
434    pub receipts: Vec<ReceiptItemV2>,
435    pub key_id: KeyId,
436    pub signature: SignatureHex,
437}
438
439impl ReceiptsRequestV2 {
440    pub fn body(&self) -> ReceiptsBodyV2 {
441        ReceiptsBodyV2 {
442            schema: self.schema,
443            receipts: self.receipts.clone(),
444        }
445    }
446
447    pub fn validate(&self) -> Result<(), ValidationError> {
448        self.body().validate()
449    }
450}
451
452#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
453#[serde(deny_unknown_fields)]
454pub struct ReceiptsAckV2 {
455    pub schema: ReceiptsAckSchema,
456    pub accepted: u16,
457    pub duplicates: u16,
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    #[test]
465    fn closed_strings_follow_the_contract_patterns() {
466        assert!(ReceiptHmac::new(&format!("hmac-sha256:{}", "a".repeat(64))).is_some());
467        for bad in [
468            format!("sha256:{}", "a".repeat(64)),
469            format!("hmac-sha256:{}", "A".repeat(64)),
470            "hmac-sha256:".to_string(),
471        ] {
472            assert!(ReceiptHmac::new(&bad).is_none(), "{bad}");
473        }
474        assert!(ReceiptIdV2::new("rcpt_01jb8z5").is_some());
475        assert!(ReceiptIdV2::new("engine-receipt-1").is_none());
476        assert!(InvocationIdV2::new(&"0".repeat(32)).is_some());
477        assert!(InvocationIdV2::new(&"0".repeat(31)).is_none());
478        assert!(InvocationIdV2::new("src/main.rs").is_none());
479        for good in ["3.12.0", "1.0.0", "3.12.0-rc.1"] {
480            assert!(ReceiptSemver::new(good).is_some(), "{good}");
481        }
482        for bad in ["3.12", "3.12.0-", "3.12.0-a b", "v3.12.0"] {
483            assert!(ReceiptSemver::new(bad).is_none(), "{bad}");
484        }
485        assert!(MeasurementName::new("tokens_baseline").is_some());
486        for bad in ["skprojabcdefghijklmnopqrstuvwxyz", "/etc/passwd", "A", ""] {
487            assert!(MeasurementName::new(bad).is_none(), "{bad}");
488        }
489    }
490}