Skip to main content

a3s_code_core/research/
claim.rs

1//! Digest-bound research claims with explicit support, conflict, and gap states.
2
3use 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/// Explicit claim lifecycle. Missing evidence never becomes an implicit success.
12#[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/// One content-addressed claim in a research evidence fabric.
37///
38/// The statement itself is never stored as plaintext. Hosts retain claim text
39/// behind `statement_digest` and attach support, conflict, or gap digests
40/// before publication. Code validates identity and state shape only.
41#[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    /// Bind this claim to the admitted research Run namespace.
85    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}