1use super::{
4 digest, validate_digest_field, validate_id, ResearchContractError, RESEARCH_MAX_DIGESTS,
5};
6use serde::{Deserialize, Serialize};
7
8pub const RESEARCH_CLAIM_SCHEMA_V1: &str = "a3s.code.research-claim.v1";
9const RESEARCH_CLAIM_DIGEST_DOMAIN: &str = "a3s.code.research-claim.identity.v1";
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum ResearchClaimStatusV1 {
15 Proposed,
16 Supported,
17 Conflicted,
18 Unsupported,
19}
20
21impl ResearchClaimStatusV1 {
22 pub const fn as_str(self) -> &'static str {
23 match self {
24 Self::Proposed => "proposed",
25 Self::Supported => "supported",
26 Self::Conflicted => "conflicted",
27 Self::Unsupported => "unsupported",
28 }
29 }
30
31 pub const fn is_publication_ready(self) -> bool {
32 matches!(self, Self::Supported | Self::Conflicted | Self::Unsupported)
33 }
34}
35
36#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase", deny_unknown_fields)]
43pub struct ResearchClaimV1 {
44 pub schema: String,
45 pub claim_id: String,
46 pub project_id: String,
47 pub run_id: String,
48 pub statement_digest: String,
49 pub status: ResearchClaimStatusV1,
50 pub support_digests: Vec<String>,
51 pub conflict_digests: Vec<String>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub gap_digest: Option<String>,
54 pub observed_at_ms: u64,
55 pub claim_digest: String,
56}
57
58impl ResearchClaimV1 {
59 pub fn new(
60 claim_id: impl Into<String>,
61 project_id: impl Into<String>,
62 run_id: impl Into<String>,
63 statement_digest: impl Into<String>,
64 observed_at_ms: u64,
65 ) -> Result<Self, ResearchContractError> {
66 let mut claim = Self {
67 schema: RESEARCH_CLAIM_SCHEMA_V1.to_owned(),
68 claim_id: claim_id.into(),
69 project_id: project_id.into(),
70 run_id: run_id.into(),
71 statement_digest: statement_digest.into(),
72 status: ResearchClaimStatusV1::Proposed,
73 support_digests: Vec::new(),
74 conflict_digests: Vec::new(),
75 gap_digest: None,
76 observed_at_ms,
77 claim_digest: String::new(),
78 };
79 claim.validate_without_digest()?;
80 claim.claim_digest = claim.expected_digest()?;
81 Ok(claim)
82 }
83
84 pub fn validate_for_run(
86 &self,
87 run: &crate::research::ResearchRunV1,
88 ) -> Result<(), ResearchContractError> {
89 self.validate()?;
90 if self.project_id != run.project_id {
91 return Err(ResearchContractError::InvalidField("projectId"));
92 }
93 if self.run_id != run.run_id {
94 return Err(ResearchContractError::InvalidField("runId"));
95 }
96 Ok(())
97 }
98
99 pub fn mark_supported(
100 mut self,
101 mut support_digests: Vec<String>,
102 ) -> Result<Self, ResearchContractError> {
103 if !matches!(
104 self.status,
105 ResearchClaimStatusV1::Proposed | ResearchClaimStatusV1::Supported
106 ) {
107 return Err(ResearchContractError::InvalidTransition {
108 from: self.status.as_str(),
109 to: ResearchClaimStatusV1::Supported.as_str(),
110 });
111 }
112 support_digests.sort();
113 support_digests.dedup();
114 if support_digests.is_empty() {
115 return Err(ResearchContractError::InvalidField("supportDigests"));
116 }
117 self.status = ResearchClaimStatusV1::Supported;
118 self.support_digests = support_digests;
119 self.conflict_digests.clear();
120 self.gap_digest = None;
121 self.claim_digest = self.expected_digest()?;
122 self.validate()?;
123 Ok(self)
124 }
125
126 pub fn mark_conflicted(
127 mut self,
128 mut conflict_digests: Vec<String>,
129 ) -> Result<Self, ResearchContractError> {
130 if !matches!(
131 self.status,
132 ResearchClaimStatusV1::Proposed | ResearchClaimStatusV1::Conflicted
133 ) {
134 return Err(ResearchContractError::InvalidTransition {
135 from: self.status.as_str(),
136 to: ResearchClaimStatusV1::Conflicted.as_str(),
137 });
138 }
139 conflict_digests.sort();
140 conflict_digests.dedup();
141 if conflict_digests.is_empty() {
142 return Err(ResearchContractError::InvalidField("conflictDigests"));
143 }
144 self.status = ResearchClaimStatusV1::Conflicted;
145 self.conflict_digests = conflict_digests;
146 self.support_digests.clear();
147 self.gap_digest = None;
148 self.claim_digest = self.expected_digest()?;
149 self.validate()?;
150 Ok(self)
151 }
152
153 pub fn mark_unsupported(
154 mut self,
155 gap_digest: impl Into<String>,
156 ) -> Result<Self, ResearchContractError> {
157 if !matches!(
158 self.status,
159 ResearchClaimStatusV1::Proposed | ResearchClaimStatusV1::Unsupported
160 ) {
161 return Err(ResearchContractError::InvalidTransition {
162 from: self.status.as_str(),
163 to: ResearchClaimStatusV1::Unsupported.as_str(),
164 });
165 }
166 let gap_digest = gap_digest.into();
167 validate_digest_field("gapDigest", &gap_digest)?;
168 self.status = ResearchClaimStatusV1::Unsupported;
169 self.gap_digest = Some(gap_digest);
170 self.support_digests.clear();
171 self.conflict_digests.clear();
172 self.claim_digest = self.expected_digest()?;
173 self.validate()?;
174 Ok(self)
175 }
176
177 pub fn validate(&self) -> Result<(), ResearchContractError> {
178 self.validate_without_digest()?;
179 validate_digest_field("claimDigest", &self.claim_digest)?;
180 if self.claim_digest != self.expected_digest()? {
181 return Err(ResearchContractError::DigestMismatch("claimDigest"));
182 }
183 Ok(())
184 }
185
186 pub fn from_slice(bytes: &[u8]) -> Result<Self, ResearchContractError> {
187 let claim: Self = super::decode_json_slice(bytes)?;
188 claim.validate()?;
189 Ok(claim)
190 }
191
192 pub fn to_vec(&self) -> Result<Vec<u8>, ResearchContractError> {
193 self.validate()?;
194 super::encode_json(self)
195 }
196
197 fn validate_without_digest(&self) -> Result<(), ResearchContractError> {
198 if self.schema != RESEARCH_CLAIM_SCHEMA_V1 {
199 return Err(ResearchContractError::UnsupportedSchema);
200 }
201 validate_id("claimId", &self.claim_id)?;
202 validate_id("projectId", &self.project_id)?;
203 validate_id("runId", &self.run_id)?;
204 validate_digest_field("statementDigest", &self.statement_digest)?;
205 if self.observed_at_ms == 0 {
206 return Err(ResearchContractError::InvalidField("observedAtMs"));
207 }
208 if self.support_digests.len() > RESEARCH_MAX_DIGESTS
209 || self.conflict_digests.len() > RESEARCH_MAX_DIGESTS
210 {
211 return Err(ResearchContractError::InvalidField("supportDigests"));
212 }
213 validate_sorted_unique_digests("supportDigests", &self.support_digests)?;
214 validate_sorted_unique_digests("conflictDigests", &self.conflict_digests)?;
215 match self.status {
216 ResearchClaimStatusV1::Proposed => {
217 if !self.support_digests.is_empty()
218 || !self.conflict_digests.is_empty()
219 || self.gap_digest.is_some()
220 {
221 return Err(ResearchContractError::InvalidField("status"));
222 }
223 }
224 ResearchClaimStatusV1::Supported => {
225 if self.support_digests.is_empty()
226 || !self.conflict_digests.is_empty()
227 || self.gap_digest.is_some()
228 {
229 return Err(ResearchContractError::InvalidField("supportDigests"));
230 }
231 }
232 ResearchClaimStatusV1::Conflicted => {
233 if self.conflict_digests.is_empty()
234 || !self.support_digests.is_empty()
235 || self.gap_digest.is_some()
236 {
237 return Err(ResearchContractError::InvalidField("conflictDigests"));
238 }
239 }
240 ResearchClaimStatusV1::Unsupported => {
241 let Some(gap_digest) = &self.gap_digest else {
242 return Err(ResearchContractError::InvalidField("gapDigest"));
243 };
244 validate_digest_field("gapDigest", gap_digest)?;
245 if !self.support_digests.is_empty() || !self.conflict_digests.is_empty() {
246 return Err(ResearchContractError::InvalidField("gapDigest"));
247 }
248 }
249 }
250 Ok(())
251 }
252
253 fn expected_digest(&self) -> Result<String, ResearchContractError> {
254 #[derive(Serialize)]
255 struct Identity<'a> {
256 schema: &'a str,
257 claim_id: &'a str,
258 project_id: &'a str,
259 run_id: &'a str,
260 statement_digest: &'a str,
261 status: ResearchClaimStatusV1,
262 support_digests: &'a [String],
263 conflict_digests: &'a [String],
264 gap_digest: Option<&'a str>,
265 observed_at_ms: u64,
266 }
267 digest(
268 RESEARCH_CLAIM_DIGEST_DOMAIN,
269 &Identity {
270 schema: &self.schema,
271 claim_id: &self.claim_id,
272 project_id: &self.project_id,
273 run_id: &self.run_id,
274 statement_digest: &self.statement_digest,
275 status: self.status,
276 support_digests: &self.support_digests,
277 conflict_digests: &self.conflict_digests,
278 gap_digest: self.gap_digest.as_deref(),
279 observed_at_ms: self.observed_at_ms,
280 },
281 )
282 }
283}
284
285fn validate_sorted_unique_digests(
286 field: &'static str,
287 digests: &[String],
288) -> Result<(), ResearchContractError> {
289 for pair in digests.windows(2) {
290 if pair[0] >= pair[1] {
291 return Err(ResearchContractError::InvalidField(field));
292 }
293 }
294 for value in digests {
295 validate_digest_field(field, value)?;
296 }
297 Ok(())
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 fn digest(ch: char) -> String {
305 format!("sha256:{}", ch.to_string().repeat(64))
306 }
307
308 #[test]
309 fn proposed_claim_is_digest_bound_and_wire_safe() {
310 let claim = ResearchClaimV1::new("claim-1", "project-1", "run-1", digest('a'), 1).unwrap();
311 assert_eq!(claim.status, ResearchClaimStatusV1::Proposed);
312 let encoded = claim.to_vec().unwrap();
313 assert_eq!(ResearchClaimV1::from_slice(&encoded).unwrap(), claim);
314 }
315
316 #[test]
317 fn supported_claim_requires_support_digests_and_rejects_illegal_transitions() {
318 let claim = ResearchClaimV1::new("claim-1", "project-1", "run-1", digest('a'), 1).unwrap();
319 let supported = claim
320 .clone()
321 .mark_supported(vec![digest('c'), digest('b'), digest('c')])
322 .unwrap();
323 assert_eq!(supported.status, ResearchClaimStatusV1::Supported);
324 assert_eq!(supported.support_digests, vec![digest('b'), digest('c')]);
325 assert!(matches!(
326 supported.clone().mark_conflicted(vec![digest('d')]),
327 Err(ResearchContractError::InvalidTransition { .. })
328 ));
329 assert!(matches!(
330 ResearchClaimV1::new("claim-1", "project-1", "run-1", digest('a'), 1)
331 .unwrap()
332 .mark_supported(Vec::new()),
333 Err(ResearchContractError::InvalidField("supportDigests"))
334 ));
335 }
336
337 #[test]
338 fn unsupported_claim_requires_an_explicit_gap_digest() {
339 let claim = ResearchClaimV1::new("claim-1", "project-1", "run-1", digest('a'), 1)
340 .unwrap()
341 .mark_unsupported(digest('e'))
342 .unwrap();
343 assert_eq!(claim.status, ResearchClaimStatusV1::Unsupported);
344 assert_eq!(claim.gap_digest.as_deref(), Some(digest('e').as_str()));
345 let mut tampered = claim.clone();
346 tampered.gap_digest = Some(digest('f'));
347 assert!(matches!(
348 tampered.validate(),
349 Err(ResearchContractError::DigestMismatch("claimDigest"))
350 ));
351 }
352}