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#[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#[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub evaluation_record_digest: Option<String>,
118 #[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 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 pub fn to_vec(&self) -> Result<Vec<u8>, ResearchContractError> {
189 self.validate()?;
190 super::encode_json(self)
191 }
192
193 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 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 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 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}