Skip to main content

a3s_code_core/research/
review.rs

1use super::{
2    digest, validate_digest_field, validate_id, validate_text, ResearchContractError,
3    RESEARCH_MAX_DIGESTS, RESEARCH_MAX_TEXT_BYTES,
4};
5use serde::{Deserialize, Serialize};
6
7pub const RESEARCH_REVIEW_FINDING_SCHEMA_V1: &str = "a3s.code.review-finding.v1";
8const RESEARCH_REVIEW_FINDING_DIGEST_DOMAIN: &str = "a3s.code.review-finding.identity.v1";
9
10/// Product-neutral classes that let a host render and route a finding.
11#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum ResearchReviewCategoryV1 {
14    Citation,
15    Numeric,
16    FigureCode,
17    Method,
18    Reproducibility,
19    Source,
20    Other,
21}
22
23#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum ResearchReviewSeverityV1 {
26    Info,
27    Warning,
28    Error,
29    Blocker,
30}
31
32#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum ResearchReviewStatusV1 {
35    Open,
36    Resolved,
37    Waived,
38}
39
40impl ResearchReviewStatusV1 {
41    pub const fn as_str(self) -> &'static str {
42        match self {
43            Self::Open => "open",
44            Self::Resolved => "resolved",
45            Self::Waived => "waived",
46        }
47    }
48}
49
50/// Bounded location of a reviewer observation in an artifact or source.
51#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
52#[serde(rename_all = "camelCase", deny_unknown_fields)]
53pub struct ResearchReviewLocationV1 {
54    pub anchor: String,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub line: Option<u32>,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub column: Option<u32>,
59}
60
61impl ResearchReviewLocationV1 {
62    pub fn new(anchor: impl Into<String>) -> Result<Self, ResearchContractError> {
63        let location = Self {
64            anchor: anchor.into(),
65            line: None,
66            column: None,
67        };
68        validate_text("location.anchor", &location.anchor, 512)?;
69        Ok(location)
70    }
71
72    pub fn with_line(mut self, line: u32, column: Option<u32>) -> Self {
73        self.line = Some(line);
74        self.column = column;
75        self
76    }
77
78    fn validate(&self) -> Result<(), ResearchContractError> {
79        validate_text("location.anchor", &self.anchor, 512)?;
80        if self.line.is_some_and(|line| line == 0) {
81            return Err(ResearchContractError::InvalidField("location.line"));
82        }
83        if self.column.is_some_and(|column| column == 0) {
84            return Err(ResearchContractError::InvalidField("location.column"));
85        }
86        if self.column.is_some() && self.line.is_none() {
87            return Err(ResearchContractError::InvalidField("location.column"));
88        }
89        Ok(())
90    }
91}
92
93/// One host-produced scientific review observation bound to exact evidence.
94///
95/// Code validates identity and lifecycle shape only. A host or Use package
96/// supplies the rubric, model, thresholds, and final approval decision.
97#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
98#[serde(rename_all = "camelCase", deny_unknown_fields)]
99pub struct ResearchReviewFindingV1 {
100    pub schema: String,
101    pub finding_id: String,
102    pub project_id: String,
103    pub run_id: String,
104    pub artifact_digest: String,
105    pub category: ResearchReviewCategoryV1,
106    pub severity: ResearchReviewSeverityV1,
107    pub status: ResearchReviewStatusV1,
108    pub message: String,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub location: Option<ResearchReviewLocationV1>,
111    pub evidence_digests: Vec<String>,
112    pub evaluator_id: String,
113    /// Optional digest of the immutable generic evaluation record that
114    /// produced this finding.  It is optional for compatibility with
115    /// findings created before evaluator-result binding was available.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub evaluation_record_digest: Option<String>,
118    /// Optional digest of the immutable provenance receipt for the reviewed
119    /// artifact.  It is optional for compatibility with findings created
120    /// before provenance binding was available.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub provenance_receipt_digest: Option<String>,
123    pub observed_at_ms: u64,
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub resolution_digest: Option<String>,
126    pub finding_digest: String,
127}
128
129impl ResearchReviewFindingV1 {
130    #[allow(clippy::too_many_arguments)]
131    pub fn new(
132        finding_id: impl Into<String>,
133        project_id: impl Into<String>,
134        run_id: impl Into<String>,
135        artifact_digest: impl Into<String>,
136        category: ResearchReviewCategoryV1,
137        severity: ResearchReviewSeverityV1,
138        message: impl Into<String>,
139        location: Option<ResearchReviewLocationV1>,
140        mut evidence_digests: Vec<String>,
141        evaluator_id: impl Into<String>,
142        observed_at_ms: u64,
143    ) -> Result<Self, ResearchContractError> {
144        evidence_digests.sort();
145        evidence_digests.dedup();
146        let mut finding = Self {
147            schema: RESEARCH_REVIEW_FINDING_SCHEMA_V1.to_owned(),
148            finding_id: finding_id.into(),
149            project_id: project_id.into(),
150            run_id: run_id.into(),
151            artifact_digest: artifact_digest.into(),
152            category,
153            severity,
154            status: ResearchReviewStatusV1::Open,
155            message: message.into(),
156            location,
157            evidence_digests,
158            evaluator_id: evaluator_id.into(),
159            evaluation_record_digest: None,
160            provenance_receipt_digest: None,
161            observed_at_ms,
162            resolution_digest: None,
163            finding_digest: String::new(),
164        };
165        finding.validate_without_digest()?;
166        finding.finding_digest = finding.expected_digest()?;
167        Ok(finding)
168    }
169
170    pub fn validate(&self) -> Result<(), ResearchContractError> {
171        self.validate_without_digest()?;
172        validate_digest_field("findingDigest", &self.finding_digest)?;
173        if self.finding_digest != self.expected_digest()? {
174            return Err(ResearchContractError::DigestMismatch("findingDigest"));
175        }
176        Ok(())
177    }
178
179    /// Decode a bounded JSON finding and validate its digest before returning
180    /// it to a caller at a process boundary.
181    pub fn from_slice(bytes: &[u8]) -> Result<Self, ResearchContractError> {
182        let finding: Self = super::decode_json_slice(bytes)?;
183        finding.validate()?;
184        Ok(finding)
185    }
186
187    /// Encode a validated finding for a process boundary.
188    pub fn to_vec(&self) -> Result<Vec<u8>, ResearchContractError> {
189        self.validate()?;
190        super::encode_json(self)
191    }
192
193    /// Bind this finding to the exact generic evaluation record that produced
194    /// it. The host still owns the rubric and finding projection, while Code
195    /// verifies that the evaluator, Run, and evidence identity cannot drift.
196    /// Replaying the same record is idempotent; replacing an established
197    /// binding is rejected so a published observation remains immutable.
198    ///
199    /// The method consumes and returns the finding so callers cannot observe a
200    /// partially rebound value if validation fails.
201    pub fn bind_evaluation_record(
202        mut self,
203        record: &crate::evaluation::EvaluationRecordV1,
204    ) -> Result<Self, ResearchContractError> {
205        self.validate()?;
206        record
207            .validate()
208            .map_err(|_| ResearchContractError::InvalidField("evaluationRecord"))?;
209        if record.result.target.run_id != self.run_id {
210            return Err(ResearchContractError::InvalidField(
211                "evaluationRecord.target",
212            ));
213        }
214        if record.result.evaluator_id != self.evaluator_id {
215            return Err(ResearchContractError::InvalidField(
216                "evaluationRecord.evaluatorId",
217            ));
218        }
219        if self
220            .evidence_digests
221            .binary_search(&record.result.evidence_digest)
222            .is_err()
223        {
224            return Err(ResearchContractError::InvalidField(
225                "evaluationRecord.evidenceDigest",
226            ));
227        }
228        if let Some(bound_digest) = &self.evaluation_record_digest {
229            if bound_digest != &record.record_digest {
230                return Err(ResearchContractError::InvalidField(
231                    "evaluationRecordDigest",
232                ));
233            }
234        }
235        self.evaluation_record_digest = Some(record.record_digest.clone());
236        self.finding_digest = self.expected_digest()?;
237        self.validate()?;
238        Ok(self)
239    }
240
241    /// Bind this finding to an evaluator record and the exact research Run
242    /// admission that it reviewed.
243    ///
244    /// [`bind_evaluation_record`](Self::bind_evaluation_record) remains
245    /// available for compatibility with callers that only have the finding
246    /// and record. New reviewer pipelines should pass the admitted Run as
247    /// well, because an evaluation record carries a Run id but not the
248    /// project identity that namespaces it.
249    pub fn bind_evaluation_record_for_run(
250        self,
251        record: &crate::evaluation::EvaluationRecordV1,
252        run: &crate::research::ResearchRunV1,
253    ) -> Result<Self, ResearchContractError> {
254        self.validate()?;
255        run.validate_reviewable()?;
256        if run.project_id != self.project_id {
257            return Err(ResearchContractError::InvalidField("researchRun.projectId"));
258        }
259        if run.run_id != self.run_id {
260            return Err(ResearchContractError::InvalidField("researchRun.runId"));
261        }
262        if record.result.evidence_digest != run.evidence_snapshot_digest {
263            return Err(ResearchContractError::InvalidField(
264                "evaluationRecord.evidenceDigest",
265            ));
266        }
267        self.bind_evaluation_record(record)
268    }
269
270    /// Bind this finding to the exact provenance receipt for its artifact.
271    ///
272    /// A provenance receipt is host-produced, but Code can still reject an
273    /// artifact/project/Run mismatch and require that the finding retain one
274    /// of the receipt's input evidence digests. Replaying the same receipt is
275    /// idempotent; replacing an established binding is rejected. This keeps
276    /// reviewer policy outside Core while preventing a valid receipt from
277    /// being attached to a different scientific object.
278    pub fn bind_provenance_receipt(
279        mut self,
280        receipt: &crate::research::ResearchProvenanceReceiptV1,
281    ) -> Result<Self, ResearchContractError> {
282        self.validate()?;
283        receipt
284            .validate()
285            .map_err(|_| ResearchContractError::InvalidField("provenanceReceipt"))?;
286        if receipt.project_id != self.project_id {
287            return Err(ResearchContractError::InvalidField(
288                "provenanceReceipt.projectId",
289            ));
290        }
291        if receipt.run_id != self.run_id {
292            return Err(ResearchContractError::InvalidField(
293                "provenanceReceipt.runId",
294            ));
295        }
296        if receipt.artifact_digest != self.artifact_digest {
297            return Err(ResearchContractError::InvalidField(
298                "provenanceReceipt.artifactDigest",
299            ));
300        }
301        if !receipt
302            .input_digests
303            .iter()
304            .any(|digest| self.evidence_digests.binary_search(digest).is_ok())
305        {
306            return Err(ResearchContractError::InvalidField(
307                "provenanceReceipt.inputDigests",
308            ));
309        }
310        if let Some(bound_digest) = &self.provenance_receipt_digest {
311            if bound_digest != &receipt.receipt_digest {
312                return Err(ResearchContractError::InvalidField(
313                    "provenanceReceiptDigest",
314                ));
315            }
316        }
317        self.provenance_receipt_digest = Some(receipt.receipt_digest.clone());
318        self.finding_digest = self.expected_digest()?;
319        self.validate()?;
320        Ok(self)
321    }
322
323    /// Bind this finding to a provenance receipt and the exact research Run
324    /// admission that produced it.
325    ///
326    /// [`bind_provenance_receipt`](Self::bind_provenance_receipt) remains
327    /// available for compatibility with callers that only have the finding
328    /// and receipt.  New reviewer pipelines should pass the admitted Run as
329    /// well so Code can reject a receipt from another project revision.
330    pub fn bind_provenance_receipt_for_run(
331        self,
332        receipt: &crate::research::ResearchProvenanceReceiptV1,
333        run: &crate::research::ResearchRunV1,
334    ) -> Result<Self, ResearchContractError> {
335        self.validate()?;
336        run.validate_reviewable()?;
337        if run.project_id != self.project_id {
338            return Err(ResearchContractError::InvalidField("researchRun.projectId"));
339        }
340        if run.run_id != self.run_id {
341            return Err(ResearchContractError::InvalidField("researchRun.runId"));
342        }
343        receipt
344            .validate()
345            .map_err(|_| ResearchContractError::InvalidField("provenanceReceipt"))?;
346        if receipt.project_revision != run.project_revision {
347            return Err(ResearchContractError::InvalidField(
348                "provenanceReceipt.projectRevision",
349            ));
350        }
351        if receipt.provider_id != run.provider_id {
352            return Err(ResearchContractError::InvalidField(
353                "provenanceReceipt.providerId",
354            ));
355        }
356        if receipt.random_seed != run.random_seed {
357            return Err(ResearchContractError::InvalidField(
358                "provenanceReceipt.randomSeed",
359            ));
360        }
361        self.bind_provenance_receipt(receipt)
362    }
363
364    pub fn resolve(
365        &mut self,
366        resolution_digest: impl Into<String>,
367    ) -> Result<(), ResearchContractError> {
368        self.validate()?;
369        if !matches!(self.status, ResearchReviewStatusV1::Open) {
370            return Err(ResearchContractError::InvalidTransition {
371                from: self.status.as_str(),
372                to: ResearchReviewStatusV1::Resolved.as_str(),
373            });
374        }
375        let resolution_digest = resolution_digest.into();
376        validate_digest_field("resolutionDigest", &resolution_digest)?;
377        self.status = ResearchReviewStatusV1::Resolved;
378        self.resolution_digest = Some(resolution_digest);
379        self.finding_digest = self.expected_digest()?;
380        Ok(())
381    }
382
383    pub fn waive(
384        &mut self,
385        resolution_digest: impl Into<String>,
386    ) -> Result<(), ResearchContractError> {
387        self.validate()?;
388        if !matches!(self.status, ResearchReviewStatusV1::Open) {
389            return Err(ResearchContractError::InvalidTransition {
390                from: self.status.as_str(),
391                to: ResearchReviewStatusV1::Waived.as_str(),
392            });
393        }
394        let resolution_digest = resolution_digest.into();
395        validate_digest_field("resolutionDigest", &resolution_digest)?;
396        self.status = ResearchReviewStatusV1::Waived;
397        self.resolution_digest = Some(resolution_digest);
398        self.finding_digest = self.expected_digest()?;
399        Ok(())
400    }
401
402    fn validate_without_digest(&self) -> Result<(), ResearchContractError> {
403        if self.schema != RESEARCH_REVIEW_FINDING_SCHEMA_V1 {
404            return Err(ResearchContractError::UnsupportedSchema);
405        }
406        validate_id("findingId", &self.finding_id)?;
407        validate_id("projectId", &self.project_id)?;
408        validate_id("runId", &self.run_id)?;
409        validate_digest_field("artifactDigest", &self.artifact_digest)?;
410        validate_text("message", &self.message, RESEARCH_MAX_TEXT_BYTES)?;
411        if let Some(location) = &self.location {
412            location.validate()?;
413        }
414        if self.evidence_digests.is_empty() || self.evidence_digests.len() > RESEARCH_MAX_DIGESTS {
415            return Err(ResearchContractError::InvalidField("evidenceDigests"));
416        }
417        for pair in self.evidence_digests.windows(2) {
418            if pair[0] >= pair[1] {
419                return Err(ResearchContractError::InvalidField("evidenceDigests"));
420            }
421        }
422        for digest in &self.evidence_digests {
423            validate_digest_field("evidenceDigests", digest)?;
424        }
425        validate_id("evaluatorId", &self.evaluator_id)?;
426        if self.observed_at_ms == 0 {
427            return Err(ResearchContractError::InvalidField("observedAtMs"));
428        }
429        if matches!(self.status, ResearchReviewStatusV1::Open) && self.resolution_digest.is_some() {
430            return Err(ResearchContractError::InvalidField("resolutionDigest"));
431        }
432        if !matches!(self.status, ResearchReviewStatusV1::Open) && self.resolution_digest.is_none()
433        {
434            return Err(ResearchContractError::InvalidField("resolutionDigest"));
435        }
436        if let Some(resolution_digest) = &self.resolution_digest {
437            validate_digest_field("resolutionDigest", resolution_digest)?;
438        }
439        if let Some(evaluation_record_digest) = &self.evaluation_record_digest {
440            validate_digest_field("evaluationRecordDigest", evaluation_record_digest)?;
441        }
442        if let Some(provenance_receipt_digest) = &self.provenance_receipt_digest {
443            validate_digest_field("provenanceReceiptDigest", provenance_receipt_digest)?;
444        }
445        Ok(())
446    }
447
448    fn expected_digest(&self) -> Result<String, ResearchContractError> {
449        #[derive(Serialize)]
450        struct LegacyIdentity<'a> {
451            schema: &'a str,
452            finding_id: &'a str,
453            project_id: &'a str,
454            run_id: &'a str,
455            artifact_digest: &'a str,
456            category: ResearchReviewCategoryV1,
457            severity: ResearchReviewSeverityV1,
458            status: ResearchReviewStatusV1,
459            message: &'a str,
460            location: &'a Option<ResearchReviewLocationV1>,
461            evidence_digests: &'a [String],
462            evaluator_id: &'a str,
463            observed_at_ms: u64,
464            resolution_digest: Option<&'a str>,
465        }
466        let Some(evaluation_record_digest) = self.evaluation_record_digest.as_deref() else {
467            if let Some(provenance_receipt_digest) = self.provenance_receipt_digest.as_deref() {
468                #[derive(Serialize)]
469                struct ProvenanceBoundIdentity<'a> {
470                    schema: &'a str,
471                    finding_id: &'a str,
472                    project_id: &'a str,
473                    run_id: &'a str,
474                    artifact_digest: &'a str,
475                    category: ResearchReviewCategoryV1,
476                    severity: ResearchReviewSeverityV1,
477                    status: ResearchReviewStatusV1,
478                    message: &'a str,
479                    location: &'a Option<ResearchReviewLocationV1>,
480                    evidence_digests: &'a [String],
481                    evaluator_id: &'a str,
482                    provenance_receipt_digest: &'a str,
483                    observed_at_ms: u64,
484                    resolution_digest: Option<&'a str>,
485                }
486                return digest(
487                    RESEARCH_REVIEW_FINDING_DIGEST_DOMAIN,
488                    &ProvenanceBoundIdentity {
489                        schema: &self.schema,
490                        finding_id: &self.finding_id,
491                        project_id: &self.project_id,
492                        run_id: &self.run_id,
493                        artifact_digest: &self.artifact_digest,
494                        category: self.category,
495                        severity: self.severity,
496                        status: self.status,
497                        message: &self.message,
498                        location: &self.location,
499                        evidence_digests: &self.evidence_digests,
500                        evaluator_id: &self.evaluator_id,
501                        provenance_receipt_digest,
502                        observed_at_ms: self.observed_at_ms,
503                        resolution_digest: self.resolution_digest.as_deref(),
504                    },
505                );
506            }
507            return digest(
508                RESEARCH_REVIEW_FINDING_DIGEST_DOMAIN,
509                &LegacyIdentity {
510                    schema: &self.schema,
511                    finding_id: &self.finding_id,
512                    project_id: &self.project_id,
513                    run_id: &self.run_id,
514                    artifact_digest: &self.artifact_digest,
515                    category: self.category,
516                    severity: self.severity,
517                    status: self.status,
518                    message: &self.message,
519                    location: &self.location,
520                    evidence_digests: &self.evidence_digests,
521                    evaluator_id: &self.evaluator_id,
522                    observed_at_ms: self.observed_at_ms,
523                    resolution_digest: self.resolution_digest.as_deref(),
524                },
525            );
526        };
527        let Some(provenance_receipt_digest) = self.provenance_receipt_digest.as_deref() else {
528            #[derive(Serialize)]
529            struct BoundIdentity<'a> {
530                schema: &'a str,
531                finding_id: &'a str,
532                project_id: &'a str,
533                run_id: &'a str,
534                artifact_digest: &'a str,
535                category: ResearchReviewCategoryV1,
536                severity: ResearchReviewSeverityV1,
537                status: ResearchReviewStatusV1,
538                message: &'a str,
539                location: &'a Option<ResearchReviewLocationV1>,
540                evidence_digests: &'a [String],
541                evaluator_id: &'a str,
542                evaluation_record_digest: &'a str,
543                observed_at_ms: u64,
544                resolution_digest: Option<&'a str>,
545            }
546            return digest(
547                RESEARCH_REVIEW_FINDING_DIGEST_DOMAIN,
548                &BoundIdentity {
549                    schema: &self.schema,
550                    finding_id: &self.finding_id,
551                    project_id: &self.project_id,
552                    run_id: &self.run_id,
553                    artifact_digest: &self.artifact_digest,
554                    category: self.category,
555                    severity: self.severity,
556                    status: self.status,
557                    message: &self.message,
558                    location: &self.location,
559                    evidence_digests: &self.evidence_digests,
560                    evaluator_id: &self.evaluator_id,
561                    evaluation_record_digest,
562                    observed_at_ms: self.observed_at_ms,
563                    resolution_digest: self.resolution_digest.as_deref(),
564                },
565            );
566        };
567
568        #[derive(Serialize)]
569        struct BoundIdentity<'a> {
570            schema: &'a str,
571            finding_id: &'a str,
572            project_id: &'a str,
573            run_id: &'a str,
574            artifact_digest: &'a str,
575            category: ResearchReviewCategoryV1,
576            severity: ResearchReviewSeverityV1,
577            status: ResearchReviewStatusV1,
578            message: &'a str,
579            location: &'a Option<ResearchReviewLocationV1>,
580            evidence_digests: &'a [String],
581            evaluator_id: &'a str,
582            evaluation_record_digest: &'a str,
583            provenance_receipt_digest: &'a str,
584            observed_at_ms: u64,
585            resolution_digest: Option<&'a str>,
586        }
587        digest(
588            RESEARCH_REVIEW_FINDING_DIGEST_DOMAIN,
589            &BoundIdentity {
590                schema: &self.schema,
591                finding_id: &self.finding_id,
592                project_id: &self.project_id,
593                run_id: &self.run_id,
594                artifact_digest: &self.artifact_digest,
595                category: self.category,
596                severity: self.severity,
597                status: self.status,
598                message: &self.message,
599                location: &self.location,
600                evidence_digests: &self.evidence_digests,
601                evaluator_id: &self.evaluator_id,
602                evaluation_record_digest,
603                provenance_receipt_digest,
604                observed_at_ms: self.observed_at_ms,
605                resolution_digest: self.resolution_digest.as_deref(),
606            },
607        )
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614
615    fn digest(ch: char) -> String {
616        format!("sha256:{}", ch.to_string().repeat(64))
617    }
618
619    #[test]
620    fn finding_is_bound_to_evidence_and_resolution() {
621        let mut finding = ResearchReviewFindingV1::new(
622            "finding-1",
623            "project-1",
624            "run-1",
625            digest('a'),
626            ResearchReviewCategoryV1::Citation,
627            ResearchReviewSeverityV1::Warning,
628            "Citation does not support the claim.",
629            Some(
630                ResearchReviewLocationV1::new("report.md")
631                    .unwrap()
632                    .with_line(4, None),
633            ),
634            vec![digest('c'), digest('b')],
635            "citation-reviewer",
636            1,
637        )
638        .unwrap();
639        assert_eq!(finding.evidence_digests, vec![digest('b'), digest('c')]);
640        let open_digest = finding.finding_digest.clone();
641        finding.resolve(digest('d')).unwrap();
642        assert_ne!(open_digest, finding.finding_digest);
643        assert!(finding.validate().is_ok());
644    }
645
646    #[test]
647    fn finding_rejects_open_resolution_without_digest() {
648        let mut finding = ResearchReviewFindingV1::new(
649            "finding-1",
650            "project-1",
651            "run-1",
652            digest('a'),
653            ResearchReviewCategoryV1::Numeric,
654            ResearchReviewSeverityV1::Error,
655            "Numbers are not traceable.",
656            None,
657            vec![digest('b')],
658            "numeric-reviewer",
659            1,
660        )
661        .unwrap();
662        finding.status = ResearchReviewStatusV1::Resolved;
663        assert_eq!(
664            finding.validate(),
665            Err(ResearchContractError::InvalidField("resolutionDigest"))
666        );
667    }
668
669    #[test]
670    fn location_coordinates_are_one_based_and_line_bound() {
671        let zero_line = ResearchReviewLocationV1::new("report.md")
672            .unwrap()
673            .with_line(0, None);
674        assert_eq!(
675            ResearchReviewFindingV1::new(
676                "finding-zero-line",
677                "project-1",
678                "run-1",
679                digest('a'),
680                ResearchReviewCategoryV1::Citation,
681                ResearchReviewSeverityV1::Warning,
682                "invalid location",
683                Some(zero_line),
684                vec![digest('b')],
685                "reviewer",
686                1,
687            ),
688            Err(ResearchContractError::InvalidField("location.line"))
689        );
690
691        let orphaned_column = ResearchReviewLocationV1 {
692            anchor: "report.md".to_owned(),
693            line: None,
694            column: Some(3),
695        };
696        assert_eq!(
697            ResearchReviewFindingV1::new(
698                "finding-orphaned-column",
699                "project-1",
700                "run-1",
701                digest('a'),
702                ResearchReviewCategoryV1::Citation,
703                ResearchReviewSeverityV1::Warning,
704                "invalid location",
705                Some(orphaned_column),
706                vec![digest('b')],
707                "reviewer",
708                1,
709            ),
710            Err(ResearchContractError::InvalidField("location.column"))
711        );
712
713        let zero_column = ResearchReviewLocationV1::new("report.md")
714            .unwrap()
715            .with_line(2, Some(0));
716        assert_eq!(
717            ResearchReviewFindingV1::new(
718                "finding-zero-column",
719                "project-1",
720                "run-1",
721                digest('a'),
722                ResearchReviewCategoryV1::Citation,
723                ResearchReviewSeverityV1::Warning,
724                "invalid location",
725                Some(zero_column),
726                vec![digest('b')],
727                "reviewer",
728                1,
729            ),
730            Err(ResearchContractError::InvalidField("location.column"))
731        );
732    }
733
734    #[test]
735    fn finding_binds_the_exact_evaluation_record_without_importing_a_rubric() {
736        let evidence_digest = digest('b');
737        let result = crate::evaluation::EvaluationResultV1::new(
738            "citation-reviewer",
739            crate::evaluation::ExecutionTargetV1::new("session-1", "run-1"),
740            "aux-1",
741            "observed",
742            serde_json::json!({"finding_count": 1}),
743            evidence_digest.clone(),
744        )
745        .unwrap();
746        let record = crate::evaluation::EvaluationRecordV1::new(result, 2).unwrap();
747        let finding = ResearchReviewFindingV1::new(
748            "finding-1",
749            "project-1",
750            "run-1",
751            digest('a'),
752            ResearchReviewCategoryV1::Citation,
753            ResearchReviewSeverityV1::Warning,
754            "Citation does not support the claim.",
755            None,
756            vec![evidence_digest],
757            "citation-reviewer",
758            3,
759        )
760        .unwrap()
761        .bind_evaluation_record(&record)
762        .unwrap();
763
764        assert_eq!(
765            finding.evaluation_record_digest.as_deref(),
766            Some(record.record_digest.as_str())
767        );
768        assert!(finding.validate().is_ok());
769        let encoded = finding.to_vec().unwrap();
770        let reopened = ResearchReviewFindingV1::from_slice(&encoded).unwrap();
771        assert_eq!(reopened, finding);
772        let mut tampered = finding;
773        tampered.evaluation_record_digest = Some(digest('f'));
774        assert_eq!(
775            tampered.validate(),
776            Err(ResearchContractError::DigestMismatch("findingDigest"))
777        );
778        let tampered_wire = serde_json::to_vec(&tampered).unwrap();
779        assert_eq!(
780            ResearchReviewFindingV1::from_slice(&tampered_wire),
781            Err(ResearchContractError::DigestMismatch("findingDigest"))
782        );
783    }
784
785    #[test]
786    fn evaluation_binding_is_idempotent_but_cannot_drift() {
787        let evidence_digest = digest('b');
788        let result = crate::evaluation::EvaluationResultV1::new(
789            "citation-reviewer",
790            crate::evaluation::ExecutionTargetV1::new("session-1", "run-1"),
791            "aux-1",
792            "observed",
793            serde_json::json!({"finding_count": 1}),
794            evidence_digest.clone(),
795        )
796        .unwrap();
797        let record = crate::evaluation::EvaluationRecordV1::new(result.clone(), 2).unwrap();
798        let different_record = crate::evaluation::EvaluationRecordV1::new(result, 3).unwrap();
799        assert_ne!(record.record_digest, different_record.record_digest);
800
801        let finding = ResearchReviewFindingV1::new(
802            "finding-1",
803            "project-1",
804            "run-1",
805            digest('a'),
806            ResearchReviewCategoryV1::Citation,
807            ResearchReviewSeverityV1::Warning,
808            "Citation does not support the claim.",
809            None,
810            vec![evidence_digest],
811            "citation-reviewer",
812            3,
813        )
814        .unwrap()
815        .bind_evaluation_record(&record)
816        .unwrap();
817        let replayed = finding.clone().bind_evaluation_record(&record).unwrap();
818        assert_eq!(replayed, finding);
819        assert_eq!(
820            finding.bind_evaluation_record(&different_record),
821            Err(ResearchContractError::InvalidField(
822                "evaluationRecordDigest"
823            ))
824        );
825    }
826
827    #[test]
828    fn finding_binds_the_exact_artifact_provenance_without_importing_policy() {
829        let evidence_digest = digest('b');
830        let receipt = crate::research::ResearchProvenanceReceiptV1::new(
831            "project-1",
832            4,
833            "run-1",
834            "figure-1",
835            crate::research::ResearchArtifactKindV1::Figure,
836            digest('a'),
837            vec![evidence_digest.clone(), digest('c')],
838            digest('d'),
839            digest('e'),
840            digest('f'),
841            "fixture-provider",
842            Some(digest('1')),
843            Some(7),
844            Some(digest('2')),
845        )
846        .unwrap();
847        let finding = ResearchReviewFindingV1::new(
848            "finding-1",
849            "project-1",
850            "run-1",
851            digest('a'),
852            ResearchReviewCategoryV1::FigureCode,
853            ResearchReviewSeverityV1::Warning,
854            "Figure provenance must remain reproducible.",
855            None,
856            vec![evidence_digest],
857            "reproducibility-reviewer",
858            8,
859        )
860        .unwrap()
861        .bind_provenance_receipt(&receipt)
862        .unwrap();
863
864        assert_eq!(
865            finding.provenance_receipt_digest.as_deref(),
866            Some(receipt.receipt_digest.as_str())
867        );
868        assert!(finding.validate().is_ok());
869        let mut tampered = finding;
870        tampered.provenance_receipt_digest = Some(digest('9'));
871        assert_eq!(
872            tampered.validate(),
873            Err(ResearchContractError::DigestMismatch("findingDigest"))
874        );
875    }
876
877    #[test]
878    fn provenance_binding_is_idempotent_but_cannot_drift() {
879        let evidence_digest = digest('b');
880        let receipt = crate::research::ResearchProvenanceReceiptV1::new(
881            "project-1",
882            4,
883            "run-1",
884            "figure-1",
885            crate::research::ResearchArtifactKindV1::Figure,
886            digest('a'),
887            vec![evidence_digest.clone()],
888            digest('c'),
889            digest('d'),
890            digest('e'),
891            "fixture-provider",
892            None,
893            None,
894            None,
895        )
896        .unwrap();
897        let different_receipt = crate::research::ResearchProvenanceReceiptV1::new(
898            "project-1",
899            4,
900            "run-1",
901            "figure-1",
902            crate::research::ResearchArtifactKindV1::Figure,
903            digest('a'),
904            vec![evidence_digest.clone()],
905            digest('c'),
906            digest('d'),
907            digest('e'),
908            "fixture-provider",
909            None,
910            None,
911            Some(digest('f')),
912        )
913        .unwrap();
914        assert_ne!(receipt.receipt_digest, different_receipt.receipt_digest);
915
916        let finding = ResearchReviewFindingV1::new(
917            "finding-1",
918            "project-1",
919            "run-1",
920            digest('a'),
921            ResearchReviewCategoryV1::FigureCode,
922            ResearchReviewSeverityV1::Warning,
923            "Figure provenance must remain reproducible.",
924            None,
925            vec![evidence_digest],
926            "reproducibility-reviewer",
927            8,
928        )
929        .unwrap()
930        .bind_provenance_receipt(&receipt)
931        .unwrap();
932        let replayed = finding.clone().bind_provenance_receipt(&receipt).unwrap();
933        assert_eq!(replayed, finding);
934        assert_eq!(
935            finding.bind_provenance_receipt(&different_receipt),
936            Err(ResearchContractError::InvalidField(
937                "provenanceReceiptDigest"
938            ))
939        );
940    }
941
942    #[test]
943    fn finding_rejects_provenance_from_another_artifact_or_evidence_window() {
944        let receipt = crate::research::ResearchProvenanceReceiptV1::new(
945            "project-1",
946            4,
947            "run-1",
948            "figure-1",
949            crate::research::ResearchArtifactKindV1::Figure,
950            digest('a'),
951            vec![digest('b')],
952            digest('c'),
953            digest('d'),
954            digest('e'),
955            "fixture-provider",
956            None,
957            None,
958            None,
959        )
960        .unwrap();
961        let other_artifact = ResearchReviewFindingV1::new(
962            "finding-1",
963            "project-1",
964            "run-1",
965            digest('f'),
966            ResearchReviewCategoryV1::FigureCode,
967            ResearchReviewSeverityV1::Warning,
968            "wrong artifact",
969            None,
970            vec![digest('b')],
971            "reviewer",
972            8,
973        )
974        .unwrap();
975        assert_eq!(
976            other_artifact.bind_provenance_receipt(&receipt),
977            Err(ResearchContractError::InvalidField(
978                "provenanceReceipt.artifactDigest"
979            ))
980        );
981
982        let other_evidence = ResearchReviewFindingV1::new(
983            "finding-2",
984            "project-1",
985            "run-1",
986            digest('a'),
987            ResearchReviewCategoryV1::FigureCode,
988            ResearchReviewSeverityV1::Warning,
989            "wrong evidence",
990            None,
991            vec![digest('f')],
992            "reviewer",
993            8,
994        )
995        .unwrap();
996        assert_eq!(
997            other_evidence.bind_provenance_receipt(&receipt),
998            Err(ResearchContractError::InvalidField(
999                "provenanceReceipt.inputDigests"
1000            ))
1001        );
1002    }
1003
1004    #[test]
1005    fn finding_rejects_a_record_from_another_run_or_evidence_window() {
1006        let record = crate::evaluation::EvaluationRecordV1::new(
1007            crate::evaluation::EvaluationResultV1::new(
1008                "numeric-reviewer",
1009                crate::evaluation::ExecutionTargetV1::new("session-1", "run-2"),
1010                "aux-2",
1011                "observed",
1012                serde_json::json!({"finding_count": 1}),
1013                digest('b'),
1014            )
1015            .unwrap(),
1016            2,
1017        )
1018        .unwrap();
1019        let finding = ResearchReviewFindingV1::new(
1020            "finding-1",
1021            "project-1",
1022            "run-1",
1023            digest('a'),
1024            ResearchReviewCategoryV1::Numeric,
1025            ResearchReviewSeverityV1::Error,
1026            "The value is not traceable.",
1027            None,
1028            vec![digest('b')],
1029            "numeric-reviewer",
1030            3,
1031        )
1032        .unwrap();
1033        assert_eq!(
1034            finding.clone().bind_evaluation_record(&record),
1035            Err(ResearchContractError::InvalidField(
1036                "evaluationRecord.target"
1037            ))
1038        );
1039
1040        let same_run = crate::evaluation::EvaluationRecordV1::new(
1041            crate::evaluation::EvaluationResultV1::new(
1042                "numeric-reviewer",
1043                crate::evaluation::ExecutionTargetV1::new("session-1", "run-1"),
1044                "aux-3",
1045                "observed",
1046                serde_json::json!({"finding_count": 1}),
1047                digest('c'),
1048            )
1049            .unwrap(),
1050            2,
1051        )
1052        .unwrap();
1053        assert_eq!(
1054            finding.bind_evaluation_record(&same_run),
1055            Err(ResearchContractError::InvalidField(
1056                "evaluationRecord.evidenceDigest"
1057            ))
1058        );
1059    }
1060
1061    #[test]
1062    fn resolution_rejects_a_tampered_finding_before_rebinding_identity() {
1063        let mut finding = ResearchReviewFindingV1::new(
1064            "finding-1",
1065            "project-1",
1066            "run-1",
1067            digest('a'),
1068            ResearchReviewCategoryV1::Method,
1069            ResearchReviewSeverityV1::Warning,
1070            "The method needs a bounded description.",
1071            None,
1072            vec![digest('b')],
1073            "method-reviewer",
1074            1,
1075        )
1076        .unwrap();
1077        finding.message = "tampered".to_owned();
1078        assert_eq!(
1079            finding.resolve(digest('c')),
1080            Err(ResearchContractError::DigestMismatch("findingDigest"))
1081        );
1082    }
1083
1084    #[test]
1085    fn a_closed_finding_cannot_be_resolved_or_waived_again() {
1086        let mut finding = ResearchReviewFindingV1::new(
1087            "finding-1",
1088            "project-1",
1089            "run-1",
1090            digest('a'),
1091            ResearchReviewCategoryV1::Reproducibility,
1092            ResearchReviewSeverityV1::Blocker,
1093            "The environment receipt is missing.",
1094            None,
1095            vec![digest('b')],
1096            "reproducibility-reviewer",
1097            1,
1098        )
1099        .unwrap();
1100        finding.resolve(digest('c')).unwrap();
1101        assert_eq!(
1102            finding.resolve(digest('d')),
1103            Err(ResearchContractError::InvalidTransition {
1104                from: "resolved",
1105                to: "resolved"
1106            })
1107        );
1108        assert_eq!(
1109            finding.waive(digest('e')),
1110            Err(ResearchContractError::InvalidTransition {
1111                from: "resolved",
1112                to: "waived"
1113            })
1114        );
1115    }
1116}