1use super::{digest, validate_digest_field, validate_id, validate_text, ResearchContractError};
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4
5pub const RESEARCH_EVIDENCE_FACT_SCHEMA_V1: &str = "a3s.code.evidence-fact.v1";
6const RESEARCH_EVIDENCE_FACT_DIGEST_DOMAIN: &str = "a3s.code.evidence-fact.identity.v1";
7pub const RESEARCH_MAX_FACT_METADATA: usize = 32;
8pub const RESEARCH_MAX_METADATA_VALUE_BYTES: usize = 1024;
9
10#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum ResearchEvidenceFactKindV1 {
14 Source,
15 SourceSpan,
16 Claim,
17 Citation,
18 Measurement,
19 Derivation,
20 Artifact,
21 Validation,
22 Review,
23}
24
25#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
31#[serde(rename_all = "camelCase", deny_unknown_fields)]
32pub struct ResearchEvidenceFactV1 {
33 pub schema: String,
34 pub run_id: String,
35 pub sequence: u64,
36 pub kind: ResearchEvidenceFactKindV1,
37 pub subject_id: String,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub source_digest: Option<String>,
40 pub content_digest: String,
41 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
42 pub metadata: BTreeMap<String, String>,
43 pub observed_at_ms: u64,
44 pub fact_digest: String,
45}
46
47impl ResearchEvidenceFactV1 {
48 #[allow(clippy::too_many_arguments)]
49 pub fn new(
50 run_id: impl Into<String>,
51 sequence: u64,
52 kind: ResearchEvidenceFactKindV1,
53 subject_id: impl Into<String>,
54 source_digest: Option<String>,
55 content_digest: impl Into<String>,
56 metadata: BTreeMap<String, String>,
57 observed_at_ms: u64,
58 ) -> Result<Self, ResearchContractError> {
59 let mut fact = Self {
60 schema: RESEARCH_EVIDENCE_FACT_SCHEMA_V1.to_owned(),
61 run_id: run_id.into(),
62 sequence,
63 kind,
64 subject_id: subject_id.into(),
65 source_digest,
66 content_digest: content_digest.into(),
67 metadata,
68 observed_at_ms,
69 fact_digest: String::new(),
70 };
71 fact.validate_without_digest()?;
72 fact.fact_digest = fact.expected_digest()?;
73 Ok(fact)
74 }
75
76 pub fn validate(&self) -> Result<(), ResearchContractError> {
77 self.validate_without_digest()?;
78 validate_digest_field("factDigest", &self.fact_digest)?;
79 if self.fact_digest != self.expected_digest()? {
80 return Err(ResearchContractError::DigestMismatch("factDigest"));
81 }
82 Ok(())
83 }
84
85 pub fn from_slice(bytes: &[u8]) -> Result<Self, ResearchContractError> {
88 let fact: Self = super::decode_json_slice(bytes)?;
89 fact.validate()?;
90 Ok(fact)
91 }
92
93 pub fn to_vec(&self) -> Result<Vec<u8>, ResearchContractError> {
95 self.validate()?;
96 super::encode_json(self)
97 }
98
99 fn validate_without_digest(&self) -> Result<(), ResearchContractError> {
100 if self.schema != RESEARCH_EVIDENCE_FACT_SCHEMA_V1 {
101 return Err(ResearchContractError::UnsupportedSchema);
102 }
103 validate_id("runId", &self.run_id)?;
104 if self.sequence == 0 {
105 return Err(ResearchContractError::InvalidField("sequence"));
106 }
107 validate_id("subjectId", &self.subject_id)?;
108 if let Some(source_digest) = &self.source_digest {
109 validate_digest_field("sourceDigest", source_digest)?;
110 }
111 validate_digest_field("contentDigest", &self.content_digest)?;
112 if self.metadata.len() > RESEARCH_MAX_FACT_METADATA {
113 return Err(ResearchContractError::InvalidField("metadata"));
114 }
115 for (key, value) in &self.metadata {
116 validate_id("metadata.key", key)?;
117 validate_text("metadata.value", value, RESEARCH_MAX_METADATA_VALUE_BYTES)?;
118 }
119 if self.observed_at_ms == 0 {
120 return Err(ResearchContractError::InvalidField("observedAtMs"));
121 }
122 Ok(())
123 }
124
125 fn expected_digest(&self) -> Result<String, ResearchContractError> {
126 #[derive(Serialize)]
127 struct Identity<'a> {
128 schema: &'a str,
129 run_id: &'a str,
130 sequence: u64,
131 kind: ResearchEvidenceFactKindV1,
132 subject_id: &'a str,
133 source_digest: Option<&'a str>,
134 content_digest: &'a str,
135 metadata: &'a BTreeMap<String, String>,
136 observed_at_ms: u64,
137 }
138 digest(
139 RESEARCH_EVIDENCE_FACT_DIGEST_DOMAIN,
140 &Identity {
141 schema: &self.schema,
142 run_id: &self.run_id,
143 sequence: self.sequence,
144 kind: self.kind,
145 subject_id: &self.subject_id,
146 source_digest: self.source_digest.as_deref(),
147 content_digest: &self.content_digest,
148 metadata: &self.metadata,
149 observed_at_ms: self.observed_at_ms,
150 },
151 )
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 fn digest(ch: char) -> String {
160 format!("sha256:{}", ch.to_string().repeat(64))
161 }
162
163 #[test]
164 fn fact_is_digest_bound_and_metadata_is_canonical() {
165 let mut metadata = BTreeMap::new();
166 metadata.insert("locator".to_owned(), "page-3".to_owned());
167 let fact = ResearchEvidenceFactV1::new(
168 "run-1",
169 1,
170 ResearchEvidenceFactKindV1::Claim,
171 "claim-1",
172 Some(digest('a')),
173 digest('b'),
174 metadata,
175 1,
176 )
177 .unwrap();
178 assert!(fact.validate().is_ok());
179 let encoded = fact.to_vec().unwrap();
180 assert_eq!(ResearchEvidenceFactV1::from_slice(&encoded).unwrap(), fact);
181 let mut tampered = fact.clone();
182 tampered.metadata.insert("extra".to_owned(), "x".to_owned());
183 assert!(matches!(
184 tampered.validate(),
185 Err(ResearchContractError::DigestMismatch("factDigest"))
186 ));
187 }
188
189 #[test]
190 fn fact_rejects_zero_sequence_and_raw_multiline_metadata() {
191 let error = ResearchEvidenceFactV1::new(
192 "run-1",
193 0,
194 ResearchEvidenceFactKindV1::Source,
195 "source-1",
196 None,
197 digest('a'),
198 BTreeMap::new(),
199 1,
200 )
201 .unwrap_err();
202 assert_eq!(error, ResearchContractError::InvalidField("sequence"));
203
204 let mut metadata = BTreeMap::new();
205 metadata.insert("note".to_owned(), "line one\nline two".to_owned());
206 let error = ResearchEvidenceFactV1::new(
207 "run-1",
208 1,
209 ResearchEvidenceFactKindV1::Source,
210 "source-1",
211 None,
212 digest('a'),
213 metadata,
214 1,
215 )
216 .unwrap_err();
217 assert_eq!(error, ResearchContractError::InvalidField("metadata.value"));
218 }
219}