Skip to main content

a3s_code_core/research/
evidence.rs

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/// Stable categories for digest-only research observations.
11#[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/// One append-only, bounded observation in a research evidence ledger.
26///
27/// The value contains references and metadata, never the source text or raw
28/// model/tool payload. A host may resolve the digests through its own artifact
29/// store after checking the associated authority and project revision.
30#[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    /// Decode a bounded JSON evidence fact and validate its identity before
86    /// returning it to a caller at a process boundary.
87    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    /// Encode a validated evidence fact for a process boundary.
94    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}