1use super::{
4 digest, validate_digest_field, validate_id, ResearchContractError, ResearchReviewFindingV1,
5 ResearchReviewStatusV1,
6};
7use serde::{Deserialize, Serialize};
8
9pub const RESEARCH_REVIEW_BATCH_SCHEMA_V1: &str = "a3s.code.review-batch.v1";
10pub const RESEARCH_MAX_REVIEW_FINDINGS: usize = 512;
11const RESEARCH_REVIEW_BATCH_DIGEST_DOMAIN: &str = "a3s.code.review-batch.identity.v1";
12
13#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase", deny_unknown_fields)]
23pub struct ResearchReviewBatchV1 {
24 pub schema: String,
25 pub batch_id: String,
26 pub project_id: String,
27 pub run_id: String,
28 pub evaluation_record_digest: String,
29 pub evidence_digest: String,
30 pub findings: Vec<ResearchReviewFindingV1>,
31 pub batch_digest: String,
32}
33
34impl ResearchReviewBatchV1 {
35 pub fn new_for_run(
45 batch_id: impl Into<String>,
46 run: &crate::research::ResearchRunV1,
47 record: &crate::evaluation::EvaluationRecordV1,
48 evidence_digest: impl Into<String>,
49 findings: Vec<ResearchReviewFindingV1>,
50 ) -> Result<Self, ResearchContractError> {
51 let batch = Self::new(
52 batch_id,
53 run.project_id.clone(),
54 run.run_id.clone(),
55 record.record_digest.clone(),
56 evidence_digest,
57 findings,
58 )?;
59 batch.validate_for_run(run, record)?;
60 if batch
61 .findings
62 .iter()
63 .any(|finding| !matches!(finding.status, ResearchReviewStatusV1::Open))
64 {
65 return Err(ResearchContractError::InvalidField("finding.status"));
66 }
67 Ok(batch)
68 }
69
70 pub fn new(
71 batch_id: impl Into<String>,
72 project_id: impl Into<String>,
73 run_id: impl Into<String>,
74 evaluation_record_digest: impl Into<String>,
75 evidence_digest: impl Into<String>,
76 mut findings: Vec<ResearchReviewFindingV1>,
77 ) -> Result<Self, ResearchContractError> {
78 findings.sort_unstable_by(|left, right| left.finding_id.cmp(&right.finding_id));
79 let mut batch = Self {
80 schema: RESEARCH_REVIEW_BATCH_SCHEMA_V1.to_owned(),
81 batch_id: batch_id.into(),
82 project_id: project_id.into(),
83 run_id: run_id.into(),
84 evaluation_record_digest: evaluation_record_digest.into(),
85 evidence_digest: evidence_digest.into(),
86 findings,
87 batch_digest: String::new(),
88 };
89 batch.validate_without_digest()?;
90 batch.batch_digest = batch.expected_digest()?;
91 Ok(batch)
92 }
93
94 pub fn validate(&self) -> Result<(), ResearchContractError> {
95 self.validate_without_digest()?;
96 validate_digest_field("batchDigest", &self.batch_digest)?;
97 if self.batch_digest != self.expected_digest()? {
98 return Err(ResearchContractError::DigestMismatch("batchDigest"));
99 }
100 Ok(())
101 }
102
103 pub fn from_slice(bytes: &[u8]) -> Result<Self, ResearchContractError> {
106 let batch: Self = super::decode_json_slice(bytes)?;
107 batch.validate()?;
108 Ok(batch)
109 }
110
111 pub fn to_vec(&self) -> Result<Vec<u8>, ResearchContractError> {
113 self.validate()?;
114 super::encode_json(self)
115 }
116
117 pub fn validate_for_run(
120 &self,
121 run: &crate::research::ResearchRunV1,
122 record: &crate::evaluation::EvaluationRecordV1,
123 ) -> Result<(), ResearchContractError> {
124 self.validate()?;
125 run.validate_reviewable()?;
126 record
127 .validate()
128 .map_err(|_| ResearchContractError::InvalidField("evaluationRecord"))?;
129 if run.project_id != self.project_id {
130 return Err(ResearchContractError::InvalidField("researchRun.projectId"));
131 }
132 if run.run_id != self.run_id {
133 return Err(ResearchContractError::InvalidField("researchRun.runId"));
134 }
135 if record.record_digest != self.evaluation_record_digest {
136 return Err(ResearchContractError::InvalidField(
137 "evaluationRecord.recordDigest",
138 ));
139 }
140 if record.result.target.run_id != run.run_id {
141 return Err(ResearchContractError::InvalidField(
142 "evaluationRecord.target.runId",
143 ));
144 }
145 if record.result.evidence_digest != run.evidence_snapshot_digest {
146 return Err(ResearchContractError::InvalidField(
147 "evaluationRecord.evidenceDigest",
148 ));
149 }
150 if record.result.evidence_digest != self.evidence_digest {
151 return Err(ResearchContractError::InvalidField(
152 "evaluationRecord.evidenceDigest",
153 ));
154 }
155 for finding in &self.findings {
156 if finding.evaluator_id != record.result.evaluator_id {
157 return Err(ResearchContractError::InvalidField("finding.evaluatorId"));
158 }
159 }
160 Ok(())
161 }
162
163 pub fn resolve_finding(
165 &mut self,
166 finding_id: &str,
167 resolution_digest: impl Into<String>,
168 ) -> Result<(), ResearchContractError> {
169 self.validate()?;
170 let finding = self
171 .findings
172 .iter_mut()
173 .find(|finding| finding.finding_id == finding_id)
174 .ok_or(ResearchContractError::InvalidField("findingId"))?;
175 finding.resolve(resolution_digest)?;
176 self.batch_digest = self.expected_digest()?;
177 self.validate()
178 }
179
180 pub fn waive_finding(
182 &mut self,
183 finding_id: &str,
184 resolution_digest: impl Into<String>,
185 ) -> Result<(), ResearchContractError> {
186 self.validate()?;
187 let finding = self
188 .findings
189 .iter_mut()
190 .find(|finding| finding.finding_id == finding_id)
191 .ok_or(ResearchContractError::InvalidField("findingId"))?;
192 finding.waive(resolution_digest)?;
193 self.batch_digest = self.expected_digest()?;
194 self.validate()
195 }
196
197 fn validate_without_digest(&self) -> Result<(), ResearchContractError> {
198 if self.schema != RESEARCH_REVIEW_BATCH_SCHEMA_V1 {
199 return Err(ResearchContractError::UnsupportedSchema);
200 }
201 validate_id("batchId", &self.batch_id)?;
202 validate_id("projectId", &self.project_id)?;
203 validate_id("runId", &self.run_id)?;
204 validate_digest_field("evaluationRecordDigest", &self.evaluation_record_digest)?;
205 validate_digest_field("evidenceDigest", &self.evidence_digest)?;
206 if self.findings.len() > RESEARCH_MAX_REVIEW_FINDINGS {
207 return Err(ResearchContractError::InvalidField("findings"));
208 }
209 for pair in self.findings.windows(2) {
210 if pair[0].finding_id >= pair[1].finding_id {
211 return Err(ResearchContractError::InvalidField("findings"));
212 }
213 }
214 for finding in &self.findings {
215 finding.validate()?;
216 if finding.project_id != self.project_id || finding.run_id != self.run_id {
217 return Err(ResearchContractError::InvalidField("finding.identity"));
218 }
219 if finding.evaluation_record_digest.as_deref()
220 != Some(self.evaluation_record_digest.as_str())
221 {
222 return Err(ResearchContractError::InvalidField(
223 "finding.evaluationRecordDigest",
224 ));
225 }
226 if finding
227 .evidence_digests
228 .binary_search(&self.evidence_digest)
229 .is_err()
230 {
231 return Err(ResearchContractError::InvalidField(
232 "finding.evidenceDigest",
233 ));
234 }
235 }
236 Ok(())
237 }
238
239 fn expected_digest(&self) -> Result<String, ResearchContractError> {
240 #[derive(Serialize)]
241 struct Identity<'a> {
242 schema: &'a str,
243 batch_id: &'a str,
244 project_id: &'a str,
245 run_id: &'a str,
246 evaluation_record_digest: &'a str,
247 evidence_digest: &'a str,
248 findings: &'a [ResearchReviewFindingV1],
249 }
250 digest(
251 RESEARCH_REVIEW_BATCH_DIGEST_DOMAIN,
252 &Identity {
253 schema: &self.schema,
254 batch_id: &self.batch_id,
255 project_id: &self.project_id,
256 run_id: &self.run_id,
257 evaluation_record_digest: &self.evaluation_record_digest,
258 evidence_digest: &self.evidence_digest,
259 findings: &self.findings,
260 },
261 )
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use crate::evaluation::{EvaluationRecordV1, EvaluationResultV1, ExecutionTargetV1};
269 use crate::research::{
270 ResearchReviewCategoryV1, ResearchReviewSeverityV1, ResearchReviewStatusV1,
271 };
272
273 fn digest(ch: char) -> String {
274 format!("sha256:{}", ch.to_string().repeat(64))
275 }
276
277 fn finding(id: &str, record: &EvaluationRecordV1) -> ResearchReviewFindingV1 {
278 ResearchReviewFindingV1::new(
279 id,
280 "project-1",
281 "run-1",
282 digest('a'),
283 ResearchReviewCategoryV1::Citation,
284 ResearchReviewSeverityV1::Warning,
285 "citation needs review",
286 None,
287 vec![record.result.evidence_digest.clone()],
288 record.result.evaluator_id.clone(),
289 3,
290 )
291 .unwrap()
292 .bind_evaluation_record(record)
293 .unwrap()
294 }
295
296 fn record() -> EvaluationRecordV1 {
297 EvaluationRecordV1::new(
298 EvaluationResultV1::new(
299 "reviewer",
300 ExecutionTargetV1::new("session-1", "run-1"),
301 "aux-1",
302 "needs_review",
303 serde_json::json!({"finding_count": 2}),
304 digest('b'),
305 )
306 .unwrap(),
307 2,
308 )
309 .unwrap()
310 }
311
312 #[test]
313 fn batch_sorts_findings_and_keeps_human_decisions_explicit() {
314 let record = record();
315 let mut batch = ResearchReviewBatchV1::new(
316 "batch-1",
317 "project-1",
318 "run-1",
319 record.record_digest.clone(),
320 record.result.evidence_digest.clone(),
321 vec![finding("finding-2", &record), finding("finding-1", &record)],
322 )
323 .unwrap();
324 assert_eq!(batch.findings[0].finding_id, "finding-1");
325 assert!(batch
326 .findings
327 .iter()
328 .all(|finding| finding.status == ResearchReviewStatusV1::Open));
329 let before = batch.batch_digest.clone();
330 batch.resolve_finding("finding-1", digest('c')).unwrap();
331 assert_ne!(before, batch.batch_digest);
332 assert!(batch.validate().is_ok());
333 }
334
335 #[test]
336 fn batch_rejects_mixed_run_or_evidence_and_tampering() {
337 let record = record();
338 let other_run_record = EvaluationRecordV1::new(
339 EvaluationResultV1::new(
340 "reviewer",
341 ExecutionTargetV1::new("session-2", "run-2"),
342 "aux-2",
343 "needs_review",
344 serde_json::json!({"finding_count": 1}),
345 digest('b'),
346 )
347 .unwrap(),
348 2,
349 )
350 .unwrap();
351 let mixed = ResearchReviewFindingV1::new(
352 "finding-1",
353 "project-1",
354 "run-2",
355 digest('a'),
356 ResearchReviewCategoryV1::Citation,
357 ResearchReviewSeverityV1::Warning,
358 "citation needs review",
359 None,
360 vec![other_run_record.result.evidence_digest.clone()],
361 "reviewer",
362 3,
363 )
364 .unwrap()
365 .bind_evaluation_record(&other_run_record)
366 .unwrap();
367 assert_eq!(
368 ResearchReviewBatchV1::new(
369 "batch-1",
370 "project-1",
371 "run-1",
372 record.record_digest.clone(),
373 record.result.evidence_digest.clone(),
374 vec![mixed],
375 ),
376 Err(ResearchContractError::InvalidField("finding.identity"))
377 );
378
379 let mut other = finding("finding-1", &record);
380 other.run_id = "run-2".to_owned();
381 assert!(matches!(
382 ResearchReviewBatchV1::new(
383 "batch-1",
384 "project-1",
385 "run-1",
386 record.record_digest.clone(),
387 record.result.evidence_digest.clone(),
388 vec![other],
389 ),
390 Err(ResearchContractError::DigestMismatch("findingDigest"))
391 ));
392
393 let mut batch = ResearchReviewBatchV1::new(
394 "batch-1",
395 "project-1",
396 "run-1",
397 record.record_digest.clone(),
398 record.result.evidence_digest.clone(),
399 vec![finding("finding-1", &record)],
400 )
401 .unwrap();
402 batch.findings[0].message = "tampered".to_owned();
403 assert_eq!(
404 batch.validate(),
405 Err(ResearchContractError::DigestMismatch("findingDigest"))
406 );
407 }
408
409 #[test]
410 fn empty_batch_represents_a_clean_review_result() {
411 let record = record();
412 let batch = ResearchReviewBatchV1::new(
413 "clean-batch",
414 "project-1",
415 "run-1",
416 record.record_digest.clone(),
417 record.result.evidence_digest.clone(),
418 Vec::new(),
419 )
420 .unwrap();
421
422 assert!(batch.findings.is_empty());
423 assert!(batch.validate().is_ok());
424 }
425
426 #[test]
427 fn batch_round_trip_is_strict_and_tamper_evident() {
428 let record = record();
429 let batch = ResearchReviewBatchV1::new(
430 "wire-batch",
431 "project-1",
432 "run-1",
433 record.record_digest.clone(),
434 record.result.evidence_digest.clone(),
435 vec![finding("finding-1", &record)],
436 )
437 .unwrap();
438
439 let encoded = batch.to_vec().unwrap();
440 let reopened = ResearchReviewBatchV1::from_slice(&encoded).unwrap();
441 assert_eq!(reopened, batch);
442 assert!(reopened.validate().is_ok());
443
444 let mut with_unknown_field: serde_json::Value = serde_json::from_slice(&encoded).unwrap();
445 with_unknown_field["unexpected"] = serde_json::Value::Bool(true);
446 let with_unknown_field = serde_json::to_vec(&with_unknown_field).unwrap();
447 assert!(ResearchReviewBatchV1::from_slice(&with_unknown_field).is_err());
448
449 let mut tampered = reopened;
450 tampered.findings[0].message = "changed after publication".to_owned();
451 assert_eq!(
452 tampered.validate(),
453 Err(ResearchContractError::DigestMismatch("findingDigest"))
454 );
455 }
456
457 #[test]
458 fn closed_batch_round_trip_preserves_terminal_finding_state() {
459 let record = record();
460 let mut resolved = ResearchReviewBatchV1::new(
461 "resolved-wire-batch",
462 "project-1",
463 "run-1",
464 record.record_digest.clone(),
465 record.result.evidence_digest.clone(),
466 vec![finding("finding-1", &record)],
467 )
468 .unwrap();
469 resolved.resolve_finding("finding-1", digest('c')).unwrap();
470 let reopened: ResearchReviewBatchV1 =
471 serde_json::from_slice(&serde_json::to_vec(&resolved).unwrap()).unwrap();
472 assert_eq!(reopened, resolved);
473 assert_eq!(
474 reopened.findings[0].status,
475 ResearchReviewStatusV1::Resolved
476 );
477 assert!(reopened.validate().is_ok());
478 assert_eq!(
479 reopened.clone().resolve_finding("finding-1", digest('d')),
480 Err(ResearchContractError::InvalidTransition {
481 from: "resolved",
482 to: "resolved"
483 })
484 );
485 assert_eq!(
486 reopened.clone().waive_finding("finding-1", digest('e')),
487 Err(ResearchContractError::InvalidTransition {
488 from: "resolved",
489 to: "waived"
490 })
491 );
492
493 let mut waived = ResearchReviewBatchV1::new(
494 "waived-wire-batch",
495 "project-1",
496 "run-1",
497 record.record_digest.clone(),
498 record.result.evidence_digest.clone(),
499 vec![finding("finding-2", &record)],
500 )
501 .unwrap();
502 waived.waive_finding("finding-2", digest('f')).unwrap();
503 let mut reopened: ResearchReviewBatchV1 =
504 serde_json::from_slice(&serde_json::to_vec(&waived).unwrap()).unwrap();
505 assert_eq!(reopened, waived);
506 assert_eq!(reopened.findings[0].status, ResearchReviewStatusV1::Waived);
507 assert!(reopened.validate().is_ok());
508 assert_eq!(
509 reopened.resolve_finding("finding-2", digest('1')),
510 Err(ResearchContractError::InvalidTransition {
511 from: "waived",
512 to: "resolved"
513 })
514 );
515 }
516}