Skip to main content

dpp_calc/kernel/
receipt.rs

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