1use harn_clock::{Clock, RealClock};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use sha2::{Digest, Sha256};
7use std::path::PathBuf;
8
9use crate::llm::{execute_llm_call, extract_llm_options, vm_value_to_json};
10use crate::value::{VmError, VmValue};
11
12use super::{
13 build_run_report, read_checked_run_report_bytes, validate_run_report, RunReport,
14 RunReportRequest, ViewProducer,
15};
16
17pub const RUN_REVIEW_SCHEMA: &str = "harn.run_review.v1";
18pub const RUN_REVIEW_SCHEMA_VERSION: u32 = 1;
19pub const RUN_REVIEW_EVIDENCE_SCHEMA: &str = "harn.run_review_evidence.v1";
20pub const MAX_RUN_REVIEW_INPUT_TOKENS: i64 = 48_000;
21const MAX_PROJECTED_ARRAY_ITEMS: usize = 32;
22const MAX_PROJECTED_STRING_BYTES: usize = 2_048;
23pub const DEFAULT_RUN_REVIEW_RUBRIC: &str = "Assess the run using only the supplied run report. Judge whether the run completed its stated work, coordinated reliably, exposed material failures, and preserved enough evidence to support the verdict. Prefer a limitation over an unsupported claim.";
24
25#[derive(Clone, Debug)]
26pub enum RunReviewInput {
27 Report {
28 path: PathBuf,
29 allowed_roots: Vec<PathBuf>,
32 },
33 RunRecord(RunReportRequest),
34}
35
36#[derive(Clone, Debug)]
37pub struct RunReviewRequest {
38 pub input: RunReviewInput,
39 pub rubric: String,
40 pub model: Option<String>,
41}
42
43#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
44#[serde(rename_all = "snake_case")]
45pub enum RunReviewState {
46 Located,
47 Projected,
48 Validated,
49 Reviewing,
50 Reviewed,
51 Invalid,
52 Failed,
53}
54
55#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
56pub struct RunReviewLifecycleReceipt {
57 pub state: RunReviewState,
58 pub at_ms: u64,
59}
60
61#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
62pub struct RunReviewLifecycle {
63 pub state: RunReviewState,
64 pub receipts: Vec<RunReviewLifecycleReceipt>,
65}
66
67#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
68pub struct RunReviewProvenance {
69 pub report_hash: String,
70 pub rubric_hash: String,
71 pub model_route: RunReviewModelRoute,
72 pub evidence_projection: RunReviewEvidenceProjection,
73}
74
75#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
76pub struct RunReviewEvidenceProjection {
77 pub schema: String,
78 pub hash: String,
79 pub source_bytes: usize,
80 pub projected_bytes: usize,
81 pub omissions: Vec<RunReviewEvidenceOmission>,
82}
83
84#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
85pub struct RunReviewEvidenceOmission {
86 pub report_pointer: String,
87 pub kind: String,
88 pub original_units: usize,
89 pub included_units: usize,
90 pub omitted_units: usize,
91 pub omitted_hash: String,
92}
93
94#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
95pub struct RunReviewModelRoute {
96 pub selector: String,
97 pub provider: String,
98 pub model: String,
99 pub tier: String,
100}
101
102#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
103#[serde(rename_all = "snake_case")]
104pub enum RunReviewVerdict {
105 Pass,
106 Concerns,
107 Fail,
108}
109
110#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
111pub struct RunReviewFinding {
112 pub severity: String,
113 pub title: String,
114 pub detail: String,
115 pub evidence_pointers: Vec<String>,
116}
117
118#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
119pub struct RunReviewAction {
120 pub priority: String,
121 pub action: String,
122 #[serde(default)]
123 pub evidence_pointers: Vec<String>,
124}
125
126#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
127pub struct RunReviewLimitation {
128 pub code: String,
129 pub message: String,
130 pub evidence_pointer: String,
131}
132
133#[derive(Clone, Debug, Default, Serialize, PartialEq)]
134pub struct RunReviewUsage {
135 pub duration_ms: u64,
136 pub input_tokens: i64,
137 pub output_tokens: i64,
138 pub cache_read_tokens: i64,
139 pub cache_write_tokens: i64,
140 pub cache_hit_ratio: Option<f64>,
141 pub cache_visibility: Option<String>,
142 pub cost_usd: Option<f64>,
143}
144
145#[derive(Clone, Debug, Serialize, PartialEq)]
146pub struct RunReview {
147 pub schema: String,
148 pub schema_version: u32,
149 pub producer: ViewProducer,
150 pub idempotency_key: String,
151 pub provenance: RunReviewProvenance,
152 pub lifecycle: RunReviewLifecycle,
153 pub verdict: RunReviewVerdict,
154 pub confidence: f64,
155 pub summary: String,
156 pub findings: Vec<RunReviewFinding>,
157 pub limitations: Vec<RunReviewLimitation>,
158 pub actions: Vec<RunReviewAction>,
159 pub usage: RunReviewUsage,
160}
161
162#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
163pub struct RunReviewError {
164 pub schema: String,
165 pub message: String,
166 pub lifecycle: RunReviewLifecycle,
167}
168
169impl std::fmt::Display for RunReviewError {
170 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171 formatter.write_str(&self.message)
172 }
173}
174
175impl std::error::Error for RunReviewError {}
176
177#[derive(Clone, Debug, Deserialize)]
178struct ModelRunReview {
179 verdict: RunReviewVerdict,
180 confidence: f64,
181 summary: String,
182 findings: Vec<RunReviewFinding>,
183 actions: Vec<RunReviewAction>,
184}
185
186pub async fn review_run_report(request: RunReviewRequest) -> Result<RunReview, RunReviewError> {
187 review_run_report_with_clock(request, &RealClock::new()).await
188}
189
190async fn review_run_report_with_clock(
191 request: RunReviewRequest,
192 clock: &dyn Clock,
193) -> Result<RunReview, RunReviewError> {
194 let mut lifecycle = LifecycleRecorder::new(clock);
195 let report = match request.input {
196 RunReviewInput::Report {
197 path,
198 allowed_roots,
199 } => {
200 let bytes = read_checked_run_report_bytes(&path, &allowed_roots)
201 .map_err(|error| lifecycle.invalid(error.to_string()))?;
202 lifecycle.advance(RunReviewState::Located);
203 serde_json::from_slice::<RunReport>(&bytes)
204 .map_err(|error| lifecycle.invalid(format!("parse run report: {error}")))?
205 }
206 RunReviewInput::RunRecord(report_request) => {
207 let report = build_run_report(report_request)
208 .await
209 .map_err(|error| lifecycle.invalid(format!("build run report: {error}")))?;
210 lifecycle.advance(RunReviewState::Located);
211 report
212 }
213 };
214 lifecycle.advance(RunReviewState::Projected);
215 validate_run_report(&report)
216 .map_err(|error| lifecycle.invalid(format!("validate run report: {error}")))?;
217 lifecycle.advance(RunReviewState::Validated);
218
219 let report_value = serde_json::to_value(&report)
220 .map_err(|error| lifecycle.invalid(format!("encode run report: {error}")))?;
221 let rubric = request.rubric;
222 if rubric.trim().is_empty() {
223 return Err(lifecycle.invalid("run review rubric must not be empty".to_string()));
224 }
225 let report_hash = report.projection.hash.clone();
226 let rubric_hash = sha256_prefixed(rubric.as_bytes());
227 let source_bytes = crate::canonical_json::to_vec(&report_value).len();
228 let (evidence, evidence_projection) = build_evidence_projection(&report_value, source_bytes);
229 let prompt = build_prompt(&evidence, &evidence_projection, &rubric);
230 let system = build_system_prompt();
231 let estimated_input_tokens = crate::llm::estimate_text_tokens(&prompt)
232 .saturating_add(crate::llm::estimate_text_tokens(&system));
233 if estimated_input_tokens > MAX_RUN_REVIEW_INPUT_TOKENS {
234 return Err(lifecycle.invalid(
235 format!(
236 "the deterministic run review projection is estimated at {estimated_input_tokens} tokens, above the {MAX_RUN_REVIEW_INPUT_TOKENS}-token limit; the report cannot be reviewed within the bounded one-call budget"
237 ),
238 ));
239 }
240 let options = build_llm_options(request.model.as_deref());
241 let options_dict = options
242 .as_dict()
243 .cloned()
244 .ok_or_else(|| lifecycle.invalid("invalid run review LLM options".to_string()))?;
245 let extracted = extract_llm_options(&[
246 VmValue::string(prompt),
247 VmValue::string(system),
248 options.clone(),
249 ])
250 .map_err(|error| lifecycle.failed(vm_error_message(error)))?;
251 let selector = request.model.unwrap_or_else(|| "small".to_string());
252 let route = RunReviewModelRoute {
253 selector,
254 provider: extracted.provider.clone(),
255 model: extracted.model.clone(),
256 tier: crate::llm_config::model_tier(&extracted.model),
257 };
258 let idempotency_key = idempotency_key(&report_hash, &rubric_hash, &route);
259
260 lifecycle.advance(RunReviewState::Reviewing);
261 let started_ms = clock.monotonic_ms();
262 let response = execute_llm_call(None, extracted, Some(options_dict), None, None)
263 .await
264 .map_err(|error| lifecycle.failed(vm_error_message(error)))?;
265 let response_dict = response.as_dict().ok_or_else(|| {
266 lifecycle.failed("run review model response was not an object".to_string())
267 })?;
268 let data = response_dict.get("data").ok_or_else(|| {
269 lifecycle.failed("run review model response did not contain structured data".to_string())
270 })?;
271 let mut review: ModelRunReview = serde_json::from_value(vm_value_to_json(data))
272 .map_err(|error| lifecycle.failed(format!("decode run review model response: {error}")))?;
273 normalize_model_review(&mut review, &report_value)
274 .map_err(|message| lifecycle.failed(message))?;
275 let mut limitations = report_limitations(&report);
276 limitations.extend(projection_limitations(&evidence_projection));
277 let duration_ms = clock.monotonic_ms().saturating_sub(started_ms).max(0) as u64;
278 let usage = usage_from_response(response_dict, duration_ms);
279 let lifecycle = lifecycle.finish(RunReviewState::Reviewed);
280
281 Ok(RunReview {
282 schema: RUN_REVIEW_SCHEMA.to_string(),
283 schema_version: RUN_REVIEW_SCHEMA_VERSION,
284 producer: ViewProducer::default(),
285 idempotency_key,
286 provenance: RunReviewProvenance {
287 report_hash,
288 rubric_hash,
289 model_route: route,
290 evidence_projection,
291 },
292 lifecycle,
293 verdict: review.verdict,
294 confidence: review.confidence,
295 summary: review.summary,
296 findings: review.findings,
297 limitations,
298 actions: review.actions,
299 usage,
300 })
301}
302
303fn build_llm_options(model: Option<&str>) -> VmValue {
304 let mut options = serde_json::json!({
305 "provider": "auto",
306 "model_tier": "small",
307 "temperature": 0.0,
308 "max_tokens": 2048,
309 "output": {"schema": model_review_schema(), "validation": "error"},
310 "schema_retries": 0
311 });
312 if let Some(model) = model {
313 options
314 .as_object_mut()
315 .expect("object")
316 .remove("model_tier");
317 options["model"] = Value::String(model.to_string());
318 }
319 crate::stdlib::json_to_vm_value(&options)
320}
321
322fn model_review_schema() -> Value {
323 serde_json::json!({
324 "type": "object",
325 "additionalProperties": false,
326 "required": ["verdict", "confidence", "summary", "findings", "actions"],
327 "properties": {
328 "verdict": {"type": "string", "enum": ["pass", "concerns", "fail"]},
329 "confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
330 "summary": {"type": "string"},
331 "findings": {
332 "type": "array",
333 "items": {
334 "type": "object",
335 "additionalProperties": false,
336 "required": ["severity", "title", "detail", "evidence_pointers"],
337 "properties": {
338 "severity": {"type": "string", "enum": ["blocking", "warning", "info"]},
339 "title": {"type": "string"},
340 "detail": {"type": "string"},
341 "evidence_pointers": {"type": "array", "minItems": 1, "items": {"type": "string"}}
342 }
343 }
344 },
345 "actions": {
346 "type": "array",
347 "items": {
348 "type": "object",
349 "additionalProperties": false,
350 "required": ["priority", "action", "evidence_pointers"],
351 "properties": {
352 "priority": {"type": "string", "enum": ["now", "next", "later"]},
353 "action": {"type": "string"},
354 "evidence_pointers": {"type": "array", "items": {"type": "string"}}
355 }
356 }
357 }
358 }
359 })
360}
361
362fn build_evidence_projection(
363 report: &Value,
364 source_bytes: usize,
365) -> (Value, RunReviewEvidenceProjection) {
366 let mut omissions = Vec::new();
367 let evidence = project_evidence_value(report, "", &mut omissions);
368 let encoded = crate::canonical_json::to_vec(&evidence);
369 let receipt = RunReviewEvidenceProjection {
370 schema: RUN_REVIEW_EVIDENCE_SCHEMA.to_string(),
371 hash: sha256_prefixed(&encoded),
372 source_bytes,
373 projected_bytes: encoded.len(),
374 omissions,
375 };
376 (evidence, receipt)
377}
378
379fn project_evidence_value(
380 value: &Value,
381 report_pointer: &str,
382 omissions: &mut Vec<RunReviewEvidenceOmission>,
383) -> Value {
384 match value {
385 Value::Array(items) if items.len() > MAX_PROJECTED_ARRAY_ITEMS => {
386 project_evidence_array(items, report_pointer, omissions)
387 }
388 Value::Array(items) => Value::Array(
389 items
390 .iter()
391 .enumerate()
392 .map(|(index, item)| {
393 project_evidence_value(
394 item,
395 &child_pointer(report_pointer, &index.to_string()),
396 omissions,
397 )
398 })
399 .collect(),
400 ),
401 Value::Object(fields) => Value::Object(
402 fields
403 .iter()
404 .map(|(key, value)| {
405 (
406 key.clone(),
407 project_evidence_value(
408 value,
409 &child_pointer(report_pointer, key),
410 omissions,
411 ),
412 )
413 })
414 .collect(),
415 ),
416 Value::String(text) if text.len() > MAX_PROJECTED_STRING_BYTES => {
417 project_evidence_string(text, report_pointer, omissions)
418 }
419 _ => value.clone(),
420 }
421}
422
423fn project_evidence_array(
424 items: &[Value],
425 report_pointer: &str,
426 omissions: &mut Vec<RunReviewEvidenceOmission>,
427) -> Value {
428 let edge = MAX_PROJECTED_ARRAY_ITEMS / 2;
429 let selected: Vec<usize> = (0..edge)
430 .chain(items.len().saturating_sub(edge)..items.len())
431 .collect();
432 let omitted = &items[edge..items.len() - edge];
433 let omission = RunReviewEvidenceOmission {
434 report_pointer: report_pointer.to_string(),
435 kind: "array_items".to_string(),
436 original_units: items.len(),
437 included_units: selected.len(),
438 omitted_units: omitted.len(),
439 omitted_hash: sha256_prefixed(&crate::canonical_json::to_vec(&Value::Array(
440 omitted.to_vec(),
441 ))),
442 };
443 omissions.push(omission.clone());
444 let projected = selected
445 .into_iter()
446 .map(|index| {
447 let pointer = child_pointer(report_pointer, &index.to_string());
448 serde_json::json!({
449 "report_pointer": pointer,
450 "value": project_evidence_value(&items[index], &pointer, omissions),
451 })
452 })
453 .collect::<Vec<_>>();
454 serde_json::json!({
455 "_harn_review_projection": omission,
456 "items": projected,
457 })
458}
459
460#[expect(
461 clippy::string_slice,
462 reason = "prefix_end/suffix_start are snapped to char boundaries above the slices"
463)]
464fn project_evidence_string(
465 text: &str,
466 report_pointer: &str,
467 omissions: &mut Vec<RunReviewEvidenceOmission>,
468) -> Value {
469 let edge = MAX_PROJECTED_STRING_BYTES / 2;
470 let mut prefix_end = edge.min(text.len());
471 while !text.is_char_boundary(prefix_end) {
472 prefix_end = prefix_end.saturating_sub(1);
473 }
474 let mut suffix_start = text.len().saturating_sub(edge);
475 while !text.is_char_boundary(suffix_start) {
476 suffix_start += 1;
477 }
478 let omitted = &text.as_bytes()[prefix_end..suffix_start];
479 let omission = RunReviewEvidenceOmission {
480 report_pointer: report_pointer.to_string(),
481 kind: "string_bytes".to_string(),
482 original_units: text.len(),
483 included_units: prefix_end + text.len().saturating_sub(suffix_start),
484 omitted_units: omitted.len(),
485 omitted_hash: sha256_prefixed(omitted),
486 };
487 omissions.push(omission.clone());
488 serde_json::json!({
489 "_harn_review_projection": omission,
490 "prefix": &text[..prefix_end],
491 "suffix": &text[suffix_start..],
492 })
493}
494
495fn child_pointer(parent: &str, segment: &str) -> String {
496 let escaped = segment.replace('~', "~0").replace('/', "~1");
497 format!("{parent}/{escaped}")
498}
499
500fn build_system_prompt() -> String {
501 "You review one Harn run report through a deterministic, provenance-bound evidence projection. Use only the supplied JSON evidence. Return only JSON matching the requested schema. Every finding must cite one or more exact RFC 6901 JSON Pointers into the run report evidence document, whose paths mirror the original report. For bounded arrays, cite the report_pointer attached to an included item; never cite projection-only fields. Do not infer omitted evidence, missing private prompts, reasoning, host presentation, or unreported events. Treat projection omissions and checks that mark evidence unavailable, incomplete, or truncated as limits on confidence, not as evidence of failure.".to_string()
502}
503
504fn build_prompt(
505 evidence: &Value,
506 projection: &RunReviewEvidenceProjection,
507 rubric: &str,
508) -> String {
509 let projection_json = crate::canonical_json::to_string(&serde_json::json!(projection));
515 let evidence_json = crate::canonical_json::to_string(evidence);
516 format!(
517 "Rubric:\n{rubric}\n\nProjection receipt (how the evidence below was bounded; not citable):\n{projection_json}\n\nRun report evidence (the only evidence; JSON Pointers are rooted at this document):\n{evidence_json}\n\nReturn a concise verdict, findings, and actions. Cite exact JSON Pointers into the run report evidence document."
518 )
519}
520
521fn normalize_model_review(review: &mut ModelRunReview, report: &Value) -> Result<(), String> {
524 if !review.confidence.is_finite() || !(0.0..=1.0).contains(&review.confidence) {
525 return Err("run review confidence must be between 0 and 1".to_string());
526 }
527 if review.summary.trim().is_empty() {
528 return Err("run review summary must not be empty".to_string());
529 }
530 for (index, finding) in review.findings.iter_mut().enumerate() {
531 if finding.evidence_pointers.is_empty() {
532 return Err(format!(
533 "run review finding {index} has no evidence pointers"
534 ));
535 }
536 normalize_pointers(
537 report,
538 &mut finding.evidence_pointers,
539 &format!("finding {index}"),
540 )?;
541 }
542 for (index, action) in review.actions.iter_mut().enumerate() {
543 normalize_pointers(
544 report,
545 &mut action.evidence_pointers,
546 &format!("action {index}"),
547 )?;
548 }
549 Ok(())
550}
551
552const LEGACY_EVIDENCE_POINTER_PREFIX: &str = "/evidence";
556
557fn normalize_pointers(report: &Value, pointers: &mut [String], owner: &str) -> Result<(), String> {
563 for pointer in pointers.iter_mut() {
564 match normalize_pointer(report, pointer) {
565 Some(canonical) => *pointer = canonical,
566 None => {
567 return Err(format!(
568 "run review {owner} cites invalid report JSON Pointer {pointer:?}"
569 ))
570 }
571 }
572 }
573 Ok(())
574}
575
576fn normalize_pointer(report: &Value, pointer: &str) -> Option<String> {
577 if pointer.is_empty() || !pointer.starts_with('/') {
578 return None;
579 }
580 if report.pointer(pointer).is_some() {
581 return Some(pointer.to_string());
582 }
583 let stripped = pointer.strip_prefix(LEGACY_EVIDENCE_POINTER_PREFIX)?;
584 if !stripped.is_empty() && !stripped.starts_with('/') {
587 return None;
588 }
589 report
590 .pointer(stripped)
591 .is_some()
592 .then(|| stripped.to_string())
593}
594
595fn report_limitations(report: &RunReport) -> Vec<RunReviewLimitation> {
596 report
597 .checks
598 .iter()
599 .enumerate()
600 .filter(|(_, check)| {
601 check.status == "unavailable"
602 || check.code.contains("coverage")
603 || check.code.contains("truncat")
604 || check.code.contains("incomplete")
605 })
606 .map(|(index, check)| RunReviewLimitation {
607 code: check.code.clone(),
608 message: check.message.clone(),
609 evidence_pointer: format!("/checks/{index}"),
610 })
611 .collect()
612}
613
614fn projection_limitations(projection: &RunReviewEvidenceProjection) -> Vec<RunReviewLimitation> {
615 projection
616 .omissions
617 .iter()
618 .map(|omission| RunReviewLimitation {
619 code: "review_evidence_omitted".to_string(),
620 message: format!(
621 "bounded review projection retained {} of {} {}; omitted {} with hash {}",
622 omission.included_units,
623 omission.original_units,
624 omission.kind,
625 omission.omitted_units,
626 omission.omitted_hash
627 ),
628 evidence_pointer: omission.report_pointer.clone(),
629 })
630 .collect()
631}
632
633fn usage_from_response(response: &crate::value::DictMap, duration_ms: u64) -> RunReviewUsage {
634 let usage = response.get("usage").and_then(VmValue::as_dict);
635 RunReviewUsage {
636 duration_ms,
637 input_tokens: usage
638 .and_then(|value| value.get("input_tokens"))
639 .and_then(VmValue::as_int)
640 .unwrap_or_default(),
641 output_tokens: usage
642 .and_then(|value| value.get("output_tokens"))
643 .and_then(VmValue::as_int)
644 .unwrap_or_default(),
645 cache_read_tokens: usage
646 .and_then(|value| value.get("cache_read_tokens"))
647 .and_then(VmValue::as_int)
648 .unwrap_or_default(),
649 cache_write_tokens: usage
650 .and_then(|value| value.get("cache_write_tokens"))
651 .and_then(VmValue::as_int)
652 .unwrap_or_default(),
653 cache_hit_ratio: usage
654 .and_then(|value| value.get("cache_hit_ratio"))
655 .and_then(vm_number),
656 cache_visibility: usage
657 .and_then(|value| value.get("cache_visibility"))
658 .and_then(vm_optional_string),
659 cost_usd: usage
660 .and_then(|value| value.get("cost_usd"))
661 .and_then(vm_number),
662 }
663}
664
665fn vm_number(value: &VmValue) -> Option<f64> {
666 match value {
667 VmValue::Float(number) => Some(*number),
668 VmValue::Int(number) => Some(*number as f64),
669 _ => None,
670 }
671}
672
673fn vm_optional_string(value: &VmValue) -> Option<String> {
674 (!matches!(value, VmValue::Nil)).then(|| value.display())
675}
676
677fn idempotency_key(report_hash: &str, rubric_hash: &str, route: &RunReviewModelRoute) -> String {
678 let value = serde_json::json!({
679 "report_hash": report_hash,
680 "rubric_hash": rubric_hash,
681 "provider": route.provider,
682 "model": route.model,
683 });
684 sha256_prefixed(&crate::canonical_json::to_vec(&value))
685}
686
687fn sha256_prefixed(bytes: &[u8]) -> String {
688 format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
689}
690
691struct LifecycleRecorder<'a> {
692 clock: &'a dyn Clock,
693 receipts: Vec<RunReviewLifecycleReceipt>,
694}
695
696impl<'a> LifecycleRecorder<'a> {
697 fn new(clock: &'a dyn Clock) -> Self {
698 Self {
699 clock,
700 receipts: Vec::new(),
701 }
702 }
703
704 fn advance(&mut self, state: RunReviewState) {
705 self.receipts.push(RunReviewLifecycleReceipt {
706 state,
707 at_ms: harn_clock::now_wall_ms(self.clock).max(0) as u64,
708 });
709 }
710
711 fn invalid(&self, message: String) -> RunReviewError {
712 self.terminal(RunReviewState::Invalid, message)
713 }
714
715 fn failed(&self, message: String) -> RunReviewError {
716 self.terminal(RunReviewState::Failed, message)
717 }
718
719 fn finish(mut self, state: RunReviewState) -> RunReviewLifecycle {
720 self.advance(state);
721 RunReviewLifecycle {
722 state,
723 receipts: self.receipts,
724 }
725 }
726
727 fn terminal(&self, state: RunReviewState, message: String) -> RunReviewError {
728 let mut receipts = self.receipts.clone();
729 receipts.push(RunReviewLifecycleReceipt {
730 state,
731 at_ms: harn_clock::now_wall_ms(self.clock).max(0) as u64,
732 });
733 RunReviewError {
734 schema: "harn.run_review_error.v1".to_string(),
735 message,
736 lifecycle: RunReviewLifecycle { state, receipts },
737 }
738 }
739}
740
741fn vm_error_message(error: VmError) -> String {
742 format!("run review model call failed: {error}")
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748 use crate::orchestration::{
749 run_report_projection_hash, RunReportCheck, RunReportProjection, RUN_REPORT_SCHEMA,
750 RUN_REPORT_SCHEMA_VERSION,
751 };
752
753 fn report_file(checks: Vec<RunReportCheck>) -> (tempfile::TempDir, PathBuf) {
754 let dir = tempfile::tempdir().expect("tempdir");
755 let path = dir.path().join("run-report.json");
756 let mut report = RunReport {
757 schema: RUN_REPORT_SCHEMA.to_string(),
758 schema_version: RUN_REPORT_SCHEMA_VERSION,
759 projection: RunReportProjection {
760 id: "run_report:root".to_string(),
761 hash: String::new(),
762 },
763 root_run_id: "root".to_string(),
764 checks,
765 ..RunReport::default()
766 };
767 report.projection.hash = run_report_projection_hash(&report).expect("report hash");
768 std::fs::write(
769 &path,
770 serde_json::to_vec_pretty(&report).expect("report JSON"),
771 )
772 .expect("write report");
773 (dir, path)
774 }
775
776 fn install_mock(text: &str) {
777 let fixture = crate::llm::parse_llm_mocks_jsonl(
778 &serde_json::json!({
779 "text": text,
780 "provider": "openai",
781 "model": "gpt-5.6-luna",
782 "input_tokens": 120,
783 "output_tokens": 40,
784 "cache_read_tokens": 20,
785 "cache_write_tokens": 10
786 })
787 .to_string(),
788 )
789 .expect("mock fixture");
790 crate::llm::install_cli_llm_mock_fixture(fixture);
791 }
792
793 fn request(path: PathBuf) -> RunReviewRequest {
794 RunReviewRequest {
795 input: RunReviewInput::Report {
796 path,
797 allowed_roots: Vec::new(),
798 },
799 rubric: "Judge coordination and evidence coverage.".to_string(),
800 model: Some("gpt-5.6-luna".to_string()),
801 }
802 }
803
804 #[test]
805 fn default_options_select_one_small_tier_call() {
806 let value = vm_value_to_json(&build_llm_options(None));
807 assert_eq!(value["provider"], "auto");
808 assert_eq!(value["model_tier"], "small");
809 assert_eq!(value["schema_retries"], 0);
810 assert!(value.get("model").is_none());
811 }
812
813 #[test]
814 fn large_import_evidence_is_bounded_with_explicit_omission_provenance() {
815 let import_nodes = (0..1_024)
816 .map(|index| {
817 serde_json::json!({
818 "path": format!("lib/generated/import_{index:04}.rb"),
819 "symbol": format!("GeneratedImport{index:04}"),
820 "detail": "deterministic low-value import edge ".repeat(6),
821 })
822 })
823 .collect::<Vec<_>>();
824 let report = serde_json::json!({
825 "schema": "harn.run_report.v1",
826 "projection": {"hash": "sha256:source"},
827 "agents": [{"agent_id": "run:root", "status": "completed"}],
828 "delegations": [{"parent_agent_id": "run:root", "child_agent_id": "run:child"}],
829 "llm_calls": [{"agent_id": "run:root", "input_tokens": 120}],
830 "coordination": {"spawned": 1, "terminal": 1, "open": 0},
831 "checks": [{"code": "timeline_coverage_incomplete", "status": "unavailable"}],
832 "timelines": [{
833 "nodes": [{
834 "category": "tool",
835 "kind": "tool_result",
836 "name": "look",
837 "status": "completed",
838 "attributes": {
839 "nodes": import_nodes,
840 "returned": 1_024,
841 "total": 1_934,
842 "truncated": true,
843 },
844 }],
845 }],
846 });
847 let source_bytes = crate::canonical_json::to_vec(&report).len();
848 assert!(
849 source_bytes > 192 * 1_024,
850 "fixture must model the observed report class"
851 );
852
853 let (evidence, projection) = build_evidence_projection(&report, source_bytes);
854 let prompt = build_prompt(&evidence, &projection, "Judge coordination and coverage.");
855 let estimated_tokens = crate::llm::estimate_text_tokens(&prompt)
856 + crate::llm::estimate_text_tokens(&build_system_prompt());
857
858 assert!(estimated_tokens < MAX_RUN_REVIEW_INPUT_TOKENS);
859 assert!(projection.projected_bytes < source_bytes / 4);
860 assert_eq!(projection.source_bytes, source_bytes);
861 let omission = projection
862 .omissions
863 .iter()
864 .find(|omission| omission.report_pointer == "/timelines/0/nodes/0/attributes/nodes")
865 .expect("import-array omission");
866 assert_eq!(omission.original_units, 1_024);
867 assert_eq!(omission.included_units, MAX_PROJECTED_ARRAY_ITEMS);
868 assert_eq!(omission.omitted_units, 992);
869 assert!(omission.omitted_hash.starts_with("sha256:"));
870 let limitation = projection_limitations(&projection)
871 .into_iter()
872 .find(|limitation| {
873 limitation.evidence_pointer == "/timelines/0/nodes/0/attributes/nodes"
874 })
875 .expect("projection limitation");
876 assert!(limitation.message.contains("omitted 992"));
877 assert_eq!(evidence["agents"][0]["agent_id"], "run:root");
878 assert_eq!(evidence["coordination"]["terminal"], 1);
879 assert_eq!(
880 evidence["timelines"][0]["nodes"][0]["attributes"]["nodes"]["items"][0]
881 ["report_pointer"],
882 "/timelines/0/nodes/0/attributes/nodes/0"
883 );
884 let (_, repeated) = build_evidence_projection(&report, source_bytes);
885 assert_eq!(projection.hash, repeated.hash);
886 assert_eq!(projection.omissions, repeated.omissions);
887 }
888
889 #[tokio::test(flavor = "current_thread")]
890 #[allow(clippy::await_holding_lock)]
891 async fn review_binds_provenance_validates_pointers_and_propagates_coverage() {
892 let _env = crate::llm::env_guard();
893 crate::llm::clear_cli_llm_mock_mode();
894 let (_dir, path) = report_file(vec![RunReportCheck {
895 code: "timeline_coverage_incomplete".to_string(),
896 severity: "info".to_string(),
897 status: "unavailable".to_string(),
898 agent_id: Some("run:root".to_string()),
899 message: "the timeline omitted events after its bounded query".to_string(),
900 }]);
901 install_mock(
902 r#"{"verdict":"concerns","confidence":0.75,"summary":"The run is structurally sound but timeline coverage is limited.","findings":[{"severity":"warning","title":"Timeline evidence is incomplete","detail":"The report explicitly marks timeline coverage unavailable.","evidence_pointers":["/checks/0"]}],"actions":[{"priority":"next","action":"Inspect the continuation before drawing timing conclusions.","evidence_pointers":["/checks/0"]}]}"#,
903 );
904
905 let review = review_run_report(request(path)).await.expect("review");
906 crate::llm::clear_cli_llm_mock_mode();
907
908 assert_eq!(review.schema, RUN_REVIEW_SCHEMA);
909 assert_eq!(review.lifecycle.state, RunReviewState::Reviewed);
910 assert_eq!(
911 review
912 .lifecycle
913 .receipts
914 .iter()
915 .map(|receipt| receipt.state)
916 .collect::<Vec<_>>(),
917 vec![
918 RunReviewState::Located,
919 RunReviewState::Projected,
920 RunReviewState::Validated,
921 RunReviewState::Reviewing,
922 RunReviewState::Reviewed,
923 ]
924 );
925 assert!(review.provenance.report_hash.starts_with("sha256:"));
926 assert!(review.provenance.rubric_hash.starts_with("sha256:"));
927 assert_eq!(review.provenance.model_route.provider, "openai");
928 assert_eq!(review.provenance.model_route.model, "gpt-5.6-luna");
929 assert_eq!(
930 review.provenance.evidence_projection.schema,
931 RUN_REVIEW_EVIDENCE_SCHEMA
932 );
933 assert!(review
934 .provenance
935 .evidence_projection
936 .hash
937 .starts_with("sha256:"));
938 assert!(review.provenance.evidence_projection.omissions.is_empty());
939 assert!(review.idempotency_key.starts_with("sha256:"));
940 assert_eq!(review.findings[0].evidence_pointers, ["/checks/0"]);
941 assert_eq!(review.limitations[0].code, "timeline_coverage_incomplete");
942 assert_eq!(review.limitations[0].evidence_pointer, "/checks/0");
943 assert_eq!(review.usage.input_tokens, 120);
944 assert_eq!(review.usage.cache_read_tokens, 20);
945 assert!(review.usage.cost_usd.is_some_and(|cost| cost > 0.0));
946 }
947
948 #[tokio::test(flavor = "current_thread")]
949 #[allow(clippy::await_holding_lock)]
950 async fn run_record_input_has_identical_review_provenance_to_materialized_report() {
951 let _env = crate::llm::env_guard();
952 crate::llm::clear_cli_llm_mock_mode();
953 let dir = tempfile::tempdir().expect("tempdir");
954 let run_path = dir.path().join("root.json");
955 let report_path = dir.path().join("report.json");
956 let run = crate::orchestration::RunRecord {
957 type_name: "workflow_run".to_string(),
958 id: "root".to_string(),
959 workflow_id: "workflow".to_string(),
960 status: "completed".to_string(),
961 root_run_id: Some("root".to_string()),
962 ..crate::orchestration::RunRecord::default()
963 };
964 crate::orchestration::save_run_record(&run, Some(run_path.to_str().expect("UTF-8 path")))
965 .expect("save run");
966 let report_request = RunReportRequest {
967 run_record_path: run_path.clone(),
968 source_root: run_path.parent().map(std::path::Path::to_path_buf),
969 ..RunReportRequest::default()
970 };
971 let report = build_run_report(report_request.clone())
972 .await
973 .expect("build report");
974 std::fs::write(
975 &report_path,
976 serde_json::to_vec_pretty(&report).expect("report JSON"),
977 )
978 .expect("write report");
979 let response = r#"{"verdict":"pass","confidence":0.9,"summary":"The evidence is sufficient.","findings":[],"actions":[]}"#;
980
981 install_mock(response);
982 let from_report = review_run_report(request(report_path))
983 .await
984 .expect("review report");
985 crate::llm::clear_cli_llm_mock_mode();
986
987 install_mock(response);
988 let from_run = review_run_report(RunReviewRequest {
989 input: RunReviewInput::RunRecord(report_request),
990 rubric: "Judge coordination and evidence coverage.".to_string(),
991 model: Some("gpt-5.6-luna".to_string()),
992 })
993 .await
994 .expect("review run record");
995 crate::llm::clear_cli_llm_mock_mode();
996
997 assert_eq!(from_run.provenance, from_report.provenance);
998 assert_eq!(from_run.idempotency_key, from_report.idempotency_key);
999 assert_eq!(from_run.findings, from_report.findings);
1000 assert_eq!(from_run.limitations, from_report.limitations);
1001 }
1002
1003 #[tokio::test(flavor = "current_thread")]
1004 #[allow(clippy::await_holding_lock)]
1005 async fn review_fails_closed_on_unknown_evidence_pointer() {
1006 let _env = crate::llm::env_guard();
1007 crate::llm::clear_cli_llm_mock_mode();
1008 let (_dir, path) = report_file(Vec::new());
1009 install_mock(
1010 r#"{"verdict":"pass","confidence":0.9,"summary":"Looks good.","findings":[{"severity":"info","title":"Unsupported","detail":"This is not in the report.","evidence_pointers":["/missing"]}],"actions":[]}"#,
1011 );
1012
1013 let error = review_run_report(request(path))
1014 .await
1015 .expect_err("invalid pointer must fail");
1016 crate::llm::clear_cli_llm_mock_mode();
1017 assert_eq!(error.lifecycle.state, RunReviewState::Failed);
1018 assert!(error.message.contains("invalid report JSON Pointer"));
1019 }
1020
1021 #[tokio::test(flavor = "current_thread")]
1022 #[allow(clippy::await_holding_lock)]
1023 async fn review_normalizes_projection_rooted_pointers_to_the_report() {
1024 let _env = crate::llm::env_guard();
1025 crate::llm::clear_cli_llm_mock_mode();
1026 let (_dir, path) = report_file(vec![RunReportCheck {
1027 code: "timeline_coverage_incomplete".to_string(),
1028 severity: "info".to_string(),
1029 status: "unavailable".to_string(),
1030 agent_id: Some("run:root".to_string()),
1031 message: "the timeline omitted events after its bounded query".to_string(),
1032 }]);
1033 install_mock(
1037 r#"{"verdict":"concerns","confidence":0.6,"summary":"Coverage is limited.","findings":[{"severity":"warning","title":"Timeline evidence is incomplete","detail":"Coverage is marked unavailable.","evidence_pointers":["/evidence/checks/0"]}],"actions":[{"priority":"next","action":"Inspect the continuation.","evidence_pointers":["/evidence/root_run_id"]}]}"#,
1038 );
1039
1040 let review = review_run_report(request(path)).await.expect("review");
1041 crate::llm::clear_cli_llm_mock_mode();
1042
1043 assert_eq!(review.findings[0].evidence_pointers, ["/checks/0"]);
1044 assert_eq!(review.actions[0].evidence_pointers, ["/root_run_id"]);
1045 }
1046
1047 #[tokio::test(flavor = "current_thread")]
1048 #[allow(clippy::await_holding_lock)]
1049 async fn review_fails_closed_on_projection_rooted_pointer_that_resolves_nowhere() {
1050 let _env = crate::llm::env_guard();
1051 crate::llm::clear_cli_llm_mock_mode();
1052 let (_dir, path) = report_file(Vec::new());
1053 install_mock(
1056 r#"{"verdict":"pass","confidence":0.9,"summary":"Looks good.","findings":[{"severity":"info","title":"Unsupported","detail":"Not in the report.","evidence_pointers":["/evidence/missing"]}],"actions":[]}"#,
1057 );
1058
1059 let error = review_run_report(request(path))
1060 .await
1061 .expect_err("unresolvable pointer must fail");
1062 crate::llm::clear_cli_llm_mock_mode();
1063 assert_eq!(error.lifecycle.state, RunReviewState::Failed);
1064 assert!(error.message.contains("invalid report JSON Pointer"));
1065 }
1066
1067 #[test]
1068 fn pointer_normalization_only_strips_a_whole_wrapper_segment() {
1069 let report = serde_json::json!({
1070 "checks": [{"code": "example"}],
1071 "evidenced": ["not the wrapper"],
1072 });
1073 assert_eq!(
1074 normalize_pointer(&report, "/checks/0/code").as_deref(),
1075 Some("/checks/0/code")
1076 );
1077 assert_eq!(
1078 normalize_pointer(&report, "/evidence/checks/0/code").as_deref(),
1079 Some("/checks/0/code")
1080 );
1081 assert_eq!(
1083 normalize_pointer(&report, "/evidenced/0").as_deref(),
1084 Some("/evidenced/0")
1085 );
1086 assert_eq!(normalize_pointer(&report, "/evidence/nope"), None);
1087 assert_eq!(normalize_pointer(&report, "checks/0"), None);
1088 assert_eq!(normalize_pointer(&report, ""), None);
1089 }
1090
1091 #[test]
1092 fn prompt_presents_evidence_as_its_own_rooted_document() {
1093 let evidence = serde_json::json!({"checks": [{"code": "example"}]});
1094 let projection = RunReviewEvidenceProjection {
1095 schema: RUN_REVIEW_EVIDENCE_SCHEMA.to_string(),
1096 hash: "sha256:abc".to_string(),
1097 source_bytes: 10,
1098 projected_bytes: 10,
1099 omissions: Vec::new(),
1100 };
1101 let prompt = build_prompt(&evidence, &projection, "Judge it.");
1102 assert!(!prompt.contains(r#""evidence":{"#));
1105 assert!(prompt.contains("JSON Pointers are rooted at this document"));
1106 assert!(prompt.contains(r#"{"checks":[{"code":"example"}]}"#));
1107 }
1108
1109 #[tokio::test]
1110 async fn review_rejects_wrong_report_schema_before_model_call() {
1111 let dir = tempfile::tempdir().expect("tempdir");
1112 let path = dir.path().join("not-a-report.json");
1113 std::fs::write(&path, r#"{"schema":"other.v1"}"#).expect("fixture");
1114
1115 let error = review_run_report(request(path))
1116 .await
1117 .expect_err("wrong schema must fail");
1118 assert_eq!(error.lifecycle.state, RunReviewState::Invalid);
1119 assert!(error.message.contains("expected harn.run_report.v1"));
1120 assert!(!error
1121 .lifecycle
1122 .receipts
1123 .iter()
1124 .any(|receipt| receipt.state == RunReviewState::Reviewing));
1125 }
1126
1127 #[tokio::test]
1128 async fn review_rejects_tampered_report_hash_before_model_call() {
1129 let (_dir, path) = report_file(Vec::new());
1130 let mut value: Value =
1131 serde_json::from_slice(&std::fs::read(&path).expect("report")).expect("JSON");
1132 value["root_run_id"] = Value::String("tampered".to_string());
1133 std::fs::write(&path, serde_json::to_vec_pretty(&value).expect("JSON"))
1134 .expect("tamper report");
1135
1136 let error = review_run_report(request(path))
1137 .await
1138 .expect_err("tampered hash must fail");
1139 assert_eq!(error.lifecycle.state, RunReviewState::Invalid);
1140 assert!(error.message.contains("projection hash mismatch"));
1141 assert!(!error
1142 .lifecycle
1143 .receipts
1144 .iter()
1145 .any(|receipt| receipt.state == RunReviewState::Reviewing));
1146 }
1147
1148 #[tokio::test]
1149 async fn review_rejects_oversized_input_after_validation_before_model_call() {
1150 let (_dir, path) = report_file(Vec::new());
1151 let mut oversized = request(path);
1152 oversized.rubric = "x".repeat(220_000);
1153
1154 let error = review_run_report(oversized)
1155 .await
1156 .expect_err("oversized review must fail");
1157 assert_eq!(error.lifecycle.state, RunReviewState::Invalid);
1158 assert!(error.message.contains("48000-token limit"));
1159 assert!(error
1160 .lifecycle
1161 .receipts
1162 .iter()
1163 .any(|receipt| receipt.state == RunReviewState::Validated));
1164 assert!(!error
1165 .lifecycle
1166 .receipts
1167 .iter()
1168 .any(|receipt| receipt.state == RunReviewState::Reviewing));
1169 }
1170}