1use super::{
2 digest, validate_digest_field, validate_id, ResearchContractError, RESEARCH_MAX_DIGESTS,
3};
4use serde::{Deserialize, Serialize};
5
6pub const RESEARCH_PROVENANCE_RECEIPT_SCHEMA_V1: &str = "a3s.code.provenance-receipt.v1";
7const RESEARCH_PROVENANCE_RECEIPT_DIGEST_DOMAIN: &str = "a3s.code.provenance-receipt.identity.v1";
8
9#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum ResearchArtifactKindV1 {
13 Figure,
14 Table,
15 Dataset,
16 Notebook,
17 Model,
18 Report,
19 Other,
20}
21
22pub const RESEARCH_ARTIFACT_KINDS: &[ResearchArtifactKindV1] = &[
23 ResearchArtifactKindV1::Figure,
24 ResearchArtifactKindV1::Table,
25 ResearchArtifactKindV1::Dataset,
26 ResearchArtifactKindV1::Notebook,
27 ResearchArtifactKindV1::Model,
28 ResearchArtifactKindV1::Report,
29 ResearchArtifactKindV1::Other,
30];
31
32#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase", deny_unknown_fields)]
35pub struct ResearchProvenanceReceiptV1 {
36 pub schema: String,
37 pub project_id: String,
38 pub project_revision: u64,
39 pub run_id: String,
40 pub artifact_id: String,
41 pub artifact_kind: ResearchArtifactKindV1,
42 pub artifact_digest: String,
43 pub input_digests: Vec<String>,
44 pub workflow_digest: String,
45 pub code_digest: String,
46 pub environment_digest: String,
47 pub provider_id: String,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub model_digest: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub random_seed: Option<u64>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub validation_digest: Option<String>,
54 pub receipt_digest: String,
55}
56
57impl ResearchProvenanceReceiptV1 {
58 #[allow(clippy::too_many_arguments)]
59 pub fn new(
60 project_id: impl Into<String>,
61 project_revision: u64,
62 run_id: impl Into<String>,
63 artifact_id: impl Into<String>,
64 artifact_kind: ResearchArtifactKindV1,
65 artifact_digest: impl Into<String>,
66 mut input_digests: Vec<String>,
67 workflow_digest: impl Into<String>,
68 code_digest: impl Into<String>,
69 environment_digest: impl Into<String>,
70 provider_id: impl Into<String>,
71 model_digest: Option<String>,
72 random_seed: Option<u64>,
73 validation_digest: Option<String>,
74 ) -> Result<Self, ResearchContractError> {
75 input_digests.sort();
76 input_digests.dedup();
77 let mut receipt = Self {
78 schema: RESEARCH_PROVENANCE_RECEIPT_SCHEMA_V1.to_owned(),
79 project_id: project_id.into(),
80 project_revision,
81 run_id: run_id.into(),
82 artifact_id: artifact_id.into(),
83 artifact_kind,
84 artifact_digest: artifact_digest.into(),
85 input_digests,
86 workflow_digest: workflow_digest.into(),
87 code_digest: code_digest.into(),
88 environment_digest: environment_digest.into(),
89 provider_id: provider_id.into(),
90 model_digest,
91 random_seed,
92 validation_digest,
93 receipt_digest: String::new(),
94 };
95 receipt.validate_without_digest()?;
96 receipt.receipt_digest = receipt.expected_digest()?;
97 Ok(receipt)
98 }
99
100 pub fn validate(&self) -> Result<(), ResearchContractError> {
101 self.validate_without_digest()?;
102 validate_digest_field("receiptDigest", &self.receipt_digest)?;
103 if self.receipt_digest != self.expected_digest()? {
104 return Err(ResearchContractError::DigestMismatch("receiptDigest"));
105 }
106 Ok(())
107 }
108
109 pub fn from_slice(bytes: &[u8]) -> Result<Self, ResearchContractError> {
112 let receipt: Self = super::decode_json_slice(bytes)?;
113 receipt.validate()?;
114 Ok(receipt)
115 }
116
117 pub fn to_vec(&self) -> Result<Vec<u8>, ResearchContractError> {
119 self.validate()?;
120 super::encode_json(self)
121 }
122
123 fn validate_without_digest(&self) -> Result<(), ResearchContractError> {
124 if self.schema != RESEARCH_PROVENANCE_RECEIPT_SCHEMA_V1 {
125 return Err(ResearchContractError::UnsupportedSchema);
126 }
127 validate_id("projectId", &self.project_id)?;
128 if self.project_revision == 0 {
129 return Err(ResearchContractError::InvalidField("projectRevision"));
130 }
131 validate_id("runId", &self.run_id)?;
132 validate_id("artifactId", &self.artifact_id)?;
133 validate_digest_field("artifactDigest", &self.artifact_digest)?;
134 if self.input_digests.is_empty() || self.input_digests.len() > RESEARCH_MAX_DIGESTS {
135 return Err(ResearchContractError::InvalidField("inputDigests"));
136 }
137 for pair in self.input_digests.windows(2) {
138 if pair[0] >= pair[1] {
139 return Err(ResearchContractError::InvalidField("inputDigests"));
140 }
141 }
142 for digest in &self.input_digests {
143 validate_digest_field("inputDigests", digest)?;
144 }
145 validate_digest_field("workflowDigest", &self.workflow_digest)?;
146 validate_digest_field("codeDigest", &self.code_digest)?;
147 validate_digest_field("environmentDigest", &self.environment_digest)?;
148 validate_id("providerId", &self.provider_id)?;
149 if let Some(model_digest) = &self.model_digest {
150 validate_digest_field("modelDigest", model_digest)?;
151 }
152 if let Some(validation_digest) = &self.validation_digest {
153 validate_digest_field("validationDigest", validation_digest)?;
154 }
155 Ok(())
156 }
157
158 fn expected_digest(&self) -> Result<String, ResearchContractError> {
159 #[derive(Serialize)]
160 struct Identity<'a> {
161 schema: &'a str,
162 project_id: &'a str,
163 project_revision: u64,
164 run_id: &'a str,
165 artifact_id: &'a str,
166 artifact_kind: ResearchArtifactKindV1,
167 artifact_digest: &'a str,
168 input_digests: &'a [String],
169 workflow_digest: &'a str,
170 code_digest: &'a str,
171 environment_digest: &'a str,
172 provider_id: &'a str,
173 model_digest: Option<&'a str>,
174 random_seed: Option<u64>,
175 validation_digest: Option<&'a str>,
176 }
177 digest(
178 RESEARCH_PROVENANCE_RECEIPT_DIGEST_DOMAIN,
179 &Identity {
180 schema: &self.schema,
181 project_id: &self.project_id,
182 project_revision: self.project_revision,
183 run_id: &self.run_id,
184 artifact_id: &self.artifact_id,
185 artifact_kind: self.artifact_kind,
186 artifact_digest: &self.artifact_digest,
187 input_digests: &self.input_digests,
188 workflow_digest: &self.workflow_digest,
189 code_digest: &self.code_digest,
190 environment_digest: &self.environment_digest,
191 provider_id: &self.provider_id,
192 model_digest: self.model_digest.as_deref(),
193 random_seed: self.random_seed,
194 validation_digest: self.validation_digest.as_deref(),
195 },
196 )
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 fn digest(ch: char) -> String {
205 format!("sha256:{}", ch.to_string().repeat(64))
206 }
207
208 #[test]
209 fn receipt_sorts_inputs_and_binds_environment_and_workflow() {
210 let receipt = ResearchProvenanceReceiptV1::new(
211 "project-1",
212 1,
213 "run-1",
214 "artifact-1",
215 ResearchArtifactKindV1::Figure,
216 digest('a'),
217 vec![digest('c'), digest('b'), digest('c')],
218 digest('d'),
219 digest('e'),
220 digest('f'),
221 "local",
222 Some(digest('f')),
223 Some(7),
224 None,
225 )
226 .unwrap();
227 assert_eq!(receipt.input_digests, vec![digest('b'), digest('c')]);
228 assert!(receipt.validate().is_ok());
229 let encoded = receipt.to_vec().unwrap();
230 assert_eq!(
231 ResearchProvenanceReceiptV1::from_slice(&encoded).unwrap(),
232 receipt
233 );
234 }
235
236 #[test]
237 fn receipt_rejects_duplicate_or_unsorted_mutation() {
238 let mut receipt = ResearchProvenanceReceiptV1::new(
239 "project-1",
240 1,
241 "run-1",
242 "artifact-1",
243 ResearchArtifactKindV1::Report,
244 digest('a'),
245 vec![digest('b'), digest('c')],
246 digest('d'),
247 digest('e'),
248 digest('f'),
249 "local",
250 None,
251 None,
252 None,
253 )
254 .unwrap();
255 receipt.input_digests.reverse();
256 assert_eq!(
257 receipt.validate(),
258 Err(ResearchContractError::InvalidField("inputDigests"))
259 );
260 }
261}