Skip to main content

dpp_calc/kernel/
receipt.rs

1//! Proof-of-calculation receipt — auditable envelope for every calculator result.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7use super::error::CalcError;
8
9// Re-export the JCS hashing helpers so callers keep using `receipt::jcs_hash` /
10// `receipt::input_hash` — they are split into `hashing.rs` for readability but
11// belong to the same proof-of-calculation surface.
12pub use super::hashing::{input_hash, jcs_hash};
13
14/// Proof-of-calculation envelope emitted by every calculator function.
15///
16/// Carries enough information to reproduce or audit the result: both inputs
17/// and numeric outputs are JCS-hashed (RFC 8785) so an auditor can verify the
18/// same inputs produce the same outputs, and the exact ruleset + factor dataset
19/// versions are recorded. The receipt may be signed by the vault via
20/// [`seal_with_jws`](CalculationReceipt::seal_with_jws) after calling
21/// [`canonical_bytes_for_signing`](CalculationReceipt::canonical_bytes_for_signing).
22///
23/// Intended to be stored alongside the computed value in the proof-bound store.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct CalculationReceipt {
26    /// Unique receipt identifier (UUIDv7, time-sortable).
27    pub receipt_id: Uuid,
28    /// SHA-256 of the JCS (RFC 8785) canonical JSON of the calculator inputs.
29    pub input_hash: String,
30    /// SHA-256 of the JCS (RFC 8785) canonical JSON of the numeric output values.
31    /// Empty string until populated via [`with_output_hash`](CalculationReceipt::with_output_hash).
32    pub output_hash: String,
33    /// Machine-readable identifier of the ruleset used.
34    pub ruleset_id: String,
35    /// Version of the ruleset (semver-shaped string).
36    pub ruleset_version: String,
37    /// Version of the signed Compliance-Current bundle that delivered this
38    /// ruleset. `None` when the ruleset came from the built-in baseline
39    /// (no signed bundle involved).
40    pub bundle_version: Option<String>,
41    /// Identifier of the factor dataset (empty if no factor provider was used).
42    pub factor_dataset_id: String,
43    /// Version of the factor dataset (empty if no factor provider was used).
44    pub factor_dataset_version: String,
45    /// SHA-256 of the full factor table at calculation time.
46    /// `None` when the calculation did not use a `FactorProvider`.
47    pub factor_set_hash: Option<String>,
48    /// UTC timestamp when the calculation ran.
49    pub computed_at: DateTime<Utc>,
50    /// JWS signature produced by the vault/engine after calculation.
51    /// `None` until the caller calls [`seal_with_jws`](CalculationReceipt::seal_with_jws).
52    pub jws: Option<String>,
53}
54
55impl CalculationReceipt {
56    pub fn new(
57        input_hash: impl Into<String>,
58        ruleset_id: impl Into<String>,
59        ruleset_version: impl Into<String>,
60    ) -> Self {
61        Self {
62            receipt_id: Uuid::now_v7(),
63            input_hash: input_hash.into(),
64            output_hash: String::new(),
65            ruleset_id: ruleset_id.into(),
66            ruleset_version: ruleset_version.into(),
67            bundle_version: None,
68            factor_dataset_id: String::new(),
69            factor_dataset_version: String::new(),
70            factor_set_hash: None,
71            computed_at: Utc::now(),
72            jws: None,
73        }
74    }
75
76    /// Bind the numeric output values to this receipt.
77    pub fn with_output_hash(mut self, hash: impl Into<String>) -> Self {
78        self.output_hash = hash.into();
79        self
80    }
81
82    /// Stamp the signed Compliance-Current bundle version that delivered this
83    /// ruleset. Leave unset (`None`) for the built-in baseline rulesets.
84    pub fn with_bundle_version(mut self, bundle_version: impl Into<String>) -> Self {
85        self.bundle_version = Some(bundle_version.into());
86        self
87    }
88
89    /// Attach factor-provider provenance to this receipt.
90    pub fn with_factor_provider(mut self, provider: &dyn super::factor::FactorProvider) -> Self {
91        self.factor_dataset_id = provider.dataset_id().to_owned();
92        self.factor_dataset_version = provider.dataset_version().to_owned();
93        self.factor_set_hash = Some(provider.table_hash().to_owned());
94        self
95    }
96
97    /// Attach a JWS signature produced by the external signing infrastructure.
98    /// Call after [`canonical_bytes_for_signing`](CalculationReceipt::canonical_bytes_for_signing)
99    /// to avoid signing the jws field itself.
100    pub fn seal_with_jws(mut self, jws: String) -> Self {
101        self.jws = Some(jws);
102        self
103    }
104
105    /// JCS-canonical bytes of this receipt without the `jws` field.
106    ///
107    /// Pass these bytes to the vault's signing infrastructure, then call
108    /// [`seal_with_jws`](CalculationReceipt::seal_with_jws) with the resulting
109    /// JWS to produce the final sealed receipt.
110    pub fn canonical_bytes_for_signing(&self) -> Result<Vec<u8>, CalcError> {
111        let mut v =
112            serde_json::to_value(self).map_err(|e| CalcError::CanonicalizeError(e.to_string()))?;
113        if let Some(obj) = v.as_object_mut() {
114            obj.remove("jws");
115        }
116        serde_jcs::to_vec(&v).map_err(|e| CalcError::CanonicalizeError(e.to_string()))
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::factor::FactorProvider;
124
125    struct DummyProvider;
126    impl FactorProvider for DummyProvider {
127        fn dataset_id(&self) -> &str {
128            "dummy-ds"
129        }
130        fn dataset_version(&self) -> &str {
131            "1.2.3"
132        }
133        fn gwp100(&self, _activity_uuid: &str) -> Result<f64, CalcError> {
134            Ok(1.0)
135        }
136        fn table_hash(&self) -> &str {
137            "deadbeef"
138        }
139    }
140
141    #[test]
142    fn builder_records_output_factor_and_jws() {
143        let receipt = CalculationReceipt::new("in-hash", "ruleset-x", "1.0.0")
144            .with_output_hash("out-hash")
145            .with_factor_provider(&DummyProvider)
146            .seal_with_jws("jws-token".to_owned());
147
148        assert_eq!(receipt.input_hash, "in-hash");
149        assert_eq!(receipt.output_hash, "out-hash");
150        assert_eq!(receipt.ruleset_id, "ruleset-x");
151        assert_eq!(receipt.ruleset_version, "1.0.0");
152        assert_eq!(receipt.factor_dataset_id, "dummy-ds");
153        assert_eq!(receipt.factor_dataset_version, "1.2.3");
154        assert_eq!(receipt.factor_set_hash.as_deref(), Some("deadbeef"));
155        assert_eq!(receipt.jws.as_deref(), Some("jws-token"));
156    }
157
158    #[test]
159    fn bundle_version_defaults_to_none_and_round_trips() {
160        let receipt = CalculationReceipt::new("in", "r", "1.0.0");
161        assert_eq!(receipt.bundle_version, None);
162
163        let json = serde_json::to_value(&receipt).unwrap();
164        assert_eq!(json["bundle_version"], serde_json::Value::Null);
165
166        let stamped = receipt.with_bundle_version("bundle-2026.07");
167        assert_eq!(stamped.bundle_version.as_deref(), Some("bundle-2026.07"));
168    }
169
170    #[test]
171    fn canonical_bytes_exclude_the_jws_field() {
172        let sealed = CalculationReceipt::new("in", "r", "1.0.0").seal_with_jws("secret".to_owned());
173        let bytes = sealed.canonical_bytes_for_signing().unwrap();
174        let text = String::from_utf8(bytes).unwrap();
175        assert!(
176            !text.contains("secret"),
177            "jws must be excluded from the signing payload"
178        );
179        assert!(text.contains("in"));
180    }
181}