Skip to main content

harn_vm/orchestration/
run_review.rs

1//! One provenance-bound model review over a validated run report.
2
3use 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        /// Empty for a trusted local CLI call. Remote adapters must provide
30        /// every root from which the review may read.
31        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    // The evidence is serialized as its own top-level document rather than
510    // nested beside the projection receipt. A model cites the document it is
511    // shown, so a wrapper key would invite pointers rooted at that key instead
512    // of at the report. `normalize_pointer` still repairs the wrapped form, but
513    // the prompt should not be the thing creating it.
514    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
521/// Validate the model's review and rewrite its citations into canonical
522/// report-rooted JSON Pointers.
523fn 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
552/// Wrapper key an earlier prompt shape nested the evidence under. Models that
553/// saw it cite `/evidence/...`; the report itself has no such member, so the
554/// prefix is stripped when—and only when—the remainder resolves in the report.
555const LEGACY_EVIDENCE_POINTER_PREFIX: &str = "/evidence";
556
557/// Rewrite each citation to its canonical report-rooted form, rejecting any
558/// pointer that resolves under no accepted rooting. Normalizing here—at the one
559/// boundary that owns the model response shape—keeps every stored review
560/// citable against the full report while leaving hallucinated pointers
561/// fail-closed.
562fn 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    // Only a whole path segment counts: `/evidenced/0` must not become `d/0`.
585    // An exact `/evidence` leaves `""`, the RFC 6901 whole-document pointer.
586    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        // A model that cites the evidence document it was shown, rather than the
1034        // report, must still produce a usable review: the citation resolves, so
1035        // it is rewritten to its canonical report-rooted form.
1036        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        // Stripping the wrapper must not become a way to launder a hallucinated
1054        // citation: `/missing` is absent from the report either way.
1055        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        // `/evidenced/0` resolves in the report as written and must be left alone.
1082        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        // The evidence must not be nested under a key that reads as a pointer
1103        // root; that nesting is what produced `/evidence/...` citations.
1104        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}