Skip to main content

harn_vm/testbench/
annotations.rs

1//! Annotation sidecar for testbench tapes.
2//!
3//! An annotation file (`<tape>.annotations.jsonl`) is the durable form of
4//! human judgment over a recorded run. Each annotation references a tape
5//! event by its immutable [`TapeRecord::seq`] number and carries a
6//! structured kind + evidence + author so downstream pipelines (eval
7//! rubrics, friction roll-ups, crystallization candidate detection,
8//! replay-for-teaching) can read the same artifact.
9//!
10//! ## File layout
11//!
12//! ```text
13//! run.tape                    # the unified event tape
14//! run.tape.annotations.jsonl  # annotations sidecar (this format)
15//! ```
16//!
17//! Like the tape itself, the annotations file is line-delimited JSON. The
18//! first line is a header; every subsequent line is one annotation. Empty
19//! lines and lines starting with `#` are tolerated so external tools can
20//! emit comments without breaking interop.
21//!
22//! ## Schema
23//!
24//! - **Header** (one line, always first):
25//!
26//!   ```json
27//!   {
28//!     "type": "header",
29//!     "schema_version": 1,
30//!     "tape_path": "run.tape",
31//!     "tape_content_hash": "<blake3>",
32//!     "harn_version": "0.8.6"
33//!   }
34//!   ```
35//!
36//! - **Annotation** (zero or more, after the header):
37//!
38//!   ```json
39//!   {
40//!     "type": "annotation",
41//!     "id": "ann_001",
42//!     "event_id": 42,
43//!     "kind": "hypothesis",
44//!     "evidence": "checkout incident — see runbook",
45//!     "author": {"id": "alice", "kind": "human", "surface": "burin-code"},
46//!     "timestamp": "2026-05-10T17:00:00Z",
47//!     "hypothesis_status": "active"
48//!   }
49//!   ```
50//!
51//! Optional fields default to their `None` / empty form so older readers
52//! roll forward when newer fields appear. Unknown [`AnnotationKind`]
53//! values surface as [`AnnotationKind::Unknown`] so a validator can
54//! still report on the rest.
55//!
56//! [`TapeRecord::seq`]: super::tape::TapeRecord::seq
57
58use std::collections::{BTreeMap, BTreeSet};
59use std::path::Path;
60
61use serde::{Deserialize, Serialize};
62
63use super::tape::{EventTape, TapeRecord};
64use crate::orchestration::{
65    friction_kind_allowed, FrictionEvent, FrictionLink, FRICTION_SCHEMA_VERSION,
66};
67
68/// Format version of the annotations sidecar. Bump on any breaking
69/// change. Loaders refuse files with a higher version.
70pub const ANNOTATION_SCHEMA_VERSION: u32 = 1;
71
72/// Conventional sidecar suffix appended to a tape path. `run.tape`
73/// pairs with `run.tape.annotations.jsonl`.
74pub const ANNOTATIONS_SIDECAR_SUFFIX: &str = ".annotations.jsonl";
75
76/// Header record at the top of every annotations file. Captures the
77/// schema version and a back-reference to the tape so a validator can
78/// detect mismatched bundles.
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80pub struct AnnotationHeader {
81    pub schema_version: u32,
82    /// Tape this annotation set targets. Stored as the path the
83    /// annotation author saw — the validator resolves it relative to the
84    /// annotations file.
85    #[serde(default)]
86    pub tape_path: Option<String>,
87    /// BLAKE3 hex digest of the tape's NDJSON body when the annotations
88    /// were written. The validator uses this to spot tape edits that
89    /// invalidate event_id references.
90    #[serde(default)]
91    pub tape_content_hash: Option<String>,
92    /// `harn-vm` `CARGO_PKG_VERSION` of the producer. Informational.
93    #[serde(default)]
94    pub harn_version: Option<String>,
95}
96
97impl AnnotationHeader {
98    pub fn current(tape_path: Option<String>, tape_content_hash: Option<String>) -> Self {
99        Self {
100            schema_version: ANNOTATION_SCHEMA_VERSION,
101            tape_path,
102            tape_content_hash,
103            harn_version: Some(env!("CARGO_PKG_VERSION").to_string()),
104        }
105    }
106}
107
108/// One annotation record on a tape event. Every field except `event_id`
109/// and `kind` is optional so authoring tools can emit minimal records.
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
111pub struct Annotation {
112    /// Stable id for the annotation. Defaults to `ann_{event_id}_{seq}`
113    /// when authors don't pick one — the only requirement is uniqueness
114    /// within a file.
115    #[serde(default)]
116    pub id: String,
117    /// Tape event the annotation targets. Matches [`TapeRecord::seq`].
118    pub event_id: u64,
119    pub kind: AnnotationKind,
120    /// Free-text evidence (markdown allowed). Authors may also pass
121    /// structured links via [`Annotation::links`].
122    #[serde(default)]
123    pub evidence: Option<String>,
124    /// Optional structured fix suggestion. Free-form JSON so a candidate
125    /// edit, a missing context-pack entry, or a tool-call patch can ride
126    /// on the same record without inventing per-kind shapes.
127    #[serde(default)]
128    pub suggested_fix: Option<serde_json::Value>,
129    #[serde(default)]
130    pub author: Option<AnnotationAuthor>,
131    /// RFC-3339 timestamp. Defaults to the moment the annotation was
132    /// authored; downstream pipelines treat missing values as "unknown".
133    #[serde(default)]
134    pub timestamp: Option<String>,
135    /// Span-style annotations cover a range of events. `start_event_id`
136    /// must equal `event_id`; the wrapping is intentional so single-event
137    /// annotations and span annotations share a row shape.
138    #[serde(default)]
139    pub span: Option<AnnotationSpan>,
140    /// Required when `kind == hypothesis`; ignored otherwise.
141    #[serde(default)]
142    pub hypothesis_status: Option<HypothesisStatus>,
143    /// Required when `kind == friction`; matches the friction-event
144    /// taxonomy in [`crate::orchestration::friction`] so a bag of
145    /// annotations + a bag of FrictionEvents are interchangeable.
146    #[serde(default)]
147    pub friction_kind: Option<String>,
148    /// Optional structured links to runbooks, dashboards, tickets, or
149    /// upstream incidents. Authors who only have prose put it in
150    /// `evidence`.
151    #[serde(default)]
152    pub links: Vec<AnnotationLink>,
153    /// Free-form metadata for downstream consumers. Kept open-ended on
154    /// purpose — the eval rubric, persona quality dashboard, and
155    /// crystallization detector each tag annotations differently.
156    #[serde(default)]
157    pub metadata: BTreeMap<String, serde_json::Value>,
158}
159
160/// Closed taxonomy of annotation kinds. New kinds must be added here so
161/// the validator can reason about them; older readers receive
162/// [`AnnotationKind::Unknown`] for kinds they don't recognize.
163#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
164#[serde(rename_all = "snake_case")]
165pub enum AnnotationKind {
166    /// "This event was correct." Eval rubric ground truth.
167    Correct,
168    /// "This event was wrong." Eval rubric ground truth.
169    Incorrect,
170    /// "Here is a better way to handle this turn." Pairs with a
171    /// `suggested_fix` payload.
172    Alternative,
173    /// Free-text commentary with no judgment baked in.
174    Note,
175    /// Anchor for replay-for-teaching playback. Presenter mode pauses on
176    /// markers and surfaces the evidence.
177    Marker,
178    /// Suppress a downstream consumer from acting on this event (e.g.
179    /// silence a known-flake in a dashboard).
180    Mute,
181    /// Human prior to verify. Carries a `hypothesis_status`.
182    Hypothesis,
183    /// Operational learning signal. Carries a `friction_kind` matching
184    /// the friction-event taxonomy.
185    Friction,
186    /// "This sequence is a candidate workflow to crystallize." Surfaced
187    /// directly to the candidate-detection pipeline.
188    CrystallizeHere,
189    /// Catch-all for kinds emitted by a newer producer.
190    #[serde(other)]
191    Unknown,
192}
193
194impl AnnotationKind {
195    pub fn as_str(&self) -> &'static str {
196        match self {
197            Self::Correct => "correct",
198            Self::Incorrect => "incorrect",
199            Self::Alternative => "alternative",
200            Self::Note => "note",
201            Self::Marker => "marker",
202            Self::Mute => "mute",
203            Self::Hypothesis => "hypothesis",
204            Self::Friction => "friction",
205            Self::CrystallizeHere => "crystallize_here",
206            Self::Unknown => "unknown",
207        }
208    }
209
210    /// Parse a CLI-style kind name. Accepts the snake_case spellings the
211    /// schema serializes to.
212    pub fn parse_cli(input: &str) -> Result<Self, String> {
213        match input {
214            "correct" => Ok(Self::Correct),
215            "incorrect" => Ok(Self::Incorrect),
216            "alternative" => Ok(Self::Alternative),
217            "note" => Ok(Self::Note),
218            "marker" => Ok(Self::Marker),
219            "mute" => Ok(Self::Mute),
220            "hypothesis" => Ok(Self::Hypothesis),
221            "friction" => Ok(Self::Friction),
222            "crystallize_here" => Ok(Self::CrystallizeHere),
223            other => Err(format!(
224                "unknown annotation kind '{other}' (expected one of correct, incorrect, alternative, note, marker, mute, hypothesis, friction, crystallize_here)"
225            )),
226        }
227    }
228}
229
230/// Lifecycle of a hypothesis-kind annotation. Mirrors the
231/// human-hypothesis loop in harn-cloud#54 / burin-code#277.
232#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
233#[serde(rename_all = "snake_case")]
234pub enum HypothesisStatus {
235    /// Author posed the hypothesis; no verification yet.
236    Active,
237    /// An agent is gathering evidence.
238    Verifying,
239    /// Evidence supports the hypothesis.
240    Confirmed,
241    /// Evidence rules the hypothesis out.
242    Disproven,
243    /// Hypothesis aged out without a resolution.
244    Stale,
245}
246
247/// Span over a contiguous range of events. `start_event_id` must equal
248/// the wrapping annotation's `event_id`; `end_event_id` must be greater
249/// than or equal to the start. The validator enforces both invariants.
250#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
251pub struct AnnotationSpan {
252    pub start_event_id: u64,
253    pub end_event_id: u64,
254}
255
256/// Provenance for an annotation. Surfaces the difference between a human
257/// who clicked through Burin Code and an agent that auto-flagged a turn
258/// during a self-eval.
259#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
260pub struct AnnotationAuthor {
261    /// Stable identifier — email, agent id, or service slug. The schema
262    /// does not police format; downstream consumers do.
263    #[serde(default)]
264    pub id: Option<String>,
265    /// Where the annotation came from.
266    pub kind: AuthorKind,
267    /// Surface that authored the record — `burin-code`, `harn-cloud`,
268    /// `cli`, etc. Free-form so new surfaces don't need a schema bump.
269    #[serde(default)]
270    pub surface: Option<String>,
271}
272
273#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
274#[serde(rename_all = "snake_case")]
275pub enum AuthorKind {
276    Human,
277    Agent,
278    System,
279}
280
281#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
282#[serde(default)]
283pub struct AnnotationLink {
284    pub label: Option<String>,
285    pub url: Option<String>,
286    /// Optional ticket/issue reference (e.g. `harn#1474`,
287    /// `INGEST-321`). Kept separate from `url` so the cloud surface can
288    /// resolve them uniformly.
289    pub reference: Option<String>,
290}
291
292/// Fully-loaded annotation tape — header plus every record. Built by
293/// [`AnnotationTape::load`] and consumed by the validator, the replay
294/// surface, and the export pipeline.
295#[derive(Debug, Clone)]
296pub struct AnnotationTape {
297    pub header: AnnotationHeader,
298    pub annotations: Vec<Annotation>,
299}
300
301impl AnnotationTape {
302    pub fn new(header: AnnotationHeader) -> Self {
303        Self {
304            header,
305            annotations: Vec::new(),
306        }
307    }
308
309    /// Persist the tape as `path.annotations.jsonl`-style NDJSON.
310    pub fn persist(&self, path: &Path) -> Result<(), String> {
311        if let Some(parent) = path.parent() {
312            if !parent.as_os_str().is_empty() {
313                std::fs::create_dir_all(parent)
314                    .map_err(|err| format!("mkdir {}: {err}", parent.display()))?;
315            }
316        }
317        let mut body = String::new();
318        body.push_str(
319            &serde_json::to_string(&AnnotationLine::Header(self.header.clone()))
320                .map_err(|err| format!("serialize annotation header: {err}"))?,
321        );
322        body.push('\n');
323        for annotation in &self.annotations {
324            body.push_str(
325                &serde_json::to_string(&AnnotationLine::Annotation(annotation.clone()))
326                    .map_err(|err| format!("serialize annotation: {err}"))?,
327            );
328            body.push('\n');
329        }
330        std::fs::write(path, body).map_err(|err| format!("write {}: {err}", path.display()))
331    }
332
333    /// Read an annotation file. Empty lines and `#`-prefixed comment
334    /// lines are skipped so authors can group records visually.
335    pub fn load(path: &Path) -> Result<Self, String> {
336        let body = std::fs::read_to_string(path)
337            .map_err(|err| format!("read {}: {err}", path.display()))?;
338        let mut lines = body.lines().enumerate().filter(|(_, line)| {
339            let trimmed = line.trim();
340            !trimmed.is_empty() && !trimmed.starts_with('#')
341        });
342        let (header_idx, header_line) = lines.next().ok_or_else(|| {
343            format!(
344                "empty annotation file: {} (expected a header on line 1)",
345                path.display()
346            )
347        })?;
348        let parsed_header: AnnotationLine =
349            serde_json::from_str(header_line.trim()).map_err(|err| {
350                format!(
351                    "parse annotation header at line {} in {}: {err}",
352                    header_idx + 1,
353                    path.display()
354                )
355            })?;
356        let header = match parsed_header {
357            AnnotationLine::Header(header) => header,
358            AnnotationLine::Annotation(_) => {
359                return Err(format!(
360                    "annotation file {} is missing its header (first non-empty line is a record)",
361                    path.display()
362                ))
363            }
364        };
365        if header.schema_version > ANNOTATION_SCHEMA_VERSION {
366            return Err(format!(
367                "annotation file {} declares schema_version {} but this runtime supports up to {ANNOTATION_SCHEMA_VERSION}",
368                path.display(),
369                header.schema_version
370            ));
371        }
372
373        let mut annotations = Vec::new();
374        for (idx, line) in lines {
375            let parsed: AnnotationLine = serde_json::from_str(line.trim()).map_err(|err| {
376                format!(
377                    "parse annotation at line {} in {}: {err}",
378                    idx + 1,
379                    path.display()
380                )
381            })?;
382            match parsed {
383                AnnotationLine::Annotation(annotation) => annotations.push(annotation),
384                AnnotationLine::Header(_) => {
385                    return Err(format!(
386                        "annotation file {} contains a second header at line {}",
387                        path.display(),
388                        idx + 1
389                    ))
390                }
391            }
392        }
393        Ok(Self {
394            header,
395            annotations,
396        })
397    }
398
399    /// Filter annotations by kind. Used by the export pipeline.
400    pub fn filter_by_kind<'a>(
401        &'a self,
402        kind: AnnotationKind,
403    ) -> impl Iterator<Item = &'a Annotation> + 'a {
404        self.annotations
405            .iter()
406            .filter(move |annotation| annotation.kind == kind)
407    }
408
409    /// Convert friction-kind annotations into [`FrictionEvent`]s so they
410    /// flow into [`crate::orchestration::generate_context_pack_suggestions`]
411    /// and the friction roll-up dashboard alongside natively-emitted
412    /// events.
413    pub fn to_friction_events(&self) -> Vec<FrictionEvent> {
414        self.filter_by_kind(AnnotationKind::Friction)
415            .filter_map(|annotation| annotation_to_friction_event(annotation, &self.header))
416            .collect()
417    }
418
419    /// Anchor seqs flagged by `crystallize_here` annotations. The
420    /// crystallization candidate detector keys on these to bias toward
421    /// human-curated workflow-shaped sequences over inferred ones.
422    pub fn crystallize_anchors(&self) -> Vec<CrystallizeAnchor> {
423        self.filter_by_kind(AnnotationKind::CrystallizeHere)
424            .map(|annotation| CrystallizeAnchor {
425                event_id: annotation.event_id,
426                end_event_id: annotation
427                    .span
428                    .as_ref()
429                    .map(|span| span.end_event_id)
430                    .unwrap_or(annotation.event_id),
431                evidence: annotation.evidence.clone(),
432                author: annotation.author.clone(),
433                metadata: annotation.metadata.clone(),
434            })
435            .collect()
436    }
437}
438
439/// One event the human-judgment loop has flagged as worth crystallizing.
440/// The candidate detector consumes a `Vec<CrystallizeAnchor>` alongside
441/// inferred candidates so the two paths converge into one ranked list.
442#[derive(Debug, Clone, PartialEq, Eq)]
443pub struct CrystallizeAnchor {
444    pub event_id: u64,
445    pub end_event_id: u64,
446    pub evidence: Option<String>,
447    pub author: Option<AnnotationAuthor>,
448    pub metadata: BTreeMap<String, serde_json::Value>,
449}
450
451/// One on-disk line in the annotations file. Tagged-enum dispatch keeps
452/// the file homogeneous JSONL.
453#[derive(Debug, Clone, Serialize, Deserialize)]
454#[serde(tag = "type", rename_all = "snake_case")]
455enum AnnotationLine {
456    Header(AnnotationHeader),
457    Annotation(Annotation),
458}
459
460/// Validation problem detected by [`validate_against_tape`]. Each variant
461/// carries enough context for a CLI report or a CI annotation comment.
462#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
463#[serde(tag = "code", rename_all = "snake_case")]
464pub enum AnnotationProblem {
465    /// Schema-level error (missing required field, malformed enum). The
466    /// loader rejects most of these, but a few only surface once we
467    /// cross-reference with the tape (e.g. `event_id` out of range).
468    Schema {
469        annotation_id: String,
470        message: String,
471    },
472    /// `event_id` does not match any record in the tape.
473    UnknownEventId {
474        annotation_id: String,
475        event_id: u64,
476    },
477    /// `kind == hypothesis` but `hypothesis_status` is missing.
478    HypothesisStatusMissing { annotation_id: String },
479    /// `kind != hypothesis` but `hypothesis_status` is set.
480    HypothesisStatusUnexpected { annotation_id: String },
481    /// `kind == friction` but `friction_kind` is missing.
482    FrictionKindMissing { annotation_id: String },
483    /// `kind != friction` but `friction_kind` is set.
484    FrictionKindUnexpected { annotation_id: String },
485    /// `friction_kind` is set but does not match the
486    /// [`friction::FRICTION_KINDS`] taxonomy.
487    FrictionKindUnknown {
488        annotation_id: String,
489        friction_kind: String,
490    },
491    /// `span` shape is malformed (start != event_id, end < start, or end
492    /// out of range).
493    InvalidSpan {
494        annotation_id: String,
495        message: String,
496    },
497    /// Two annotations share the same `id`.
498    DuplicateId { annotation_id: String },
499    /// Header references a tape whose content hash does not match the
500    /// loaded tape. Indicates the tape was edited after annotations were
501    /// authored — references may be stale.
502    TapeDigestMismatch { expected: String, actual: String },
503    /// `AnnotationKind::Unknown` records were preserved on load but the
504    /// validator can't reason about them.
505    UnknownKind { annotation_id: String },
506}
507
508/// Result of validating annotations against a tape.
509#[derive(Debug, Clone, Default, Serialize, Deserialize)]
510pub struct AnnotationValidationReport {
511    pub annotations_checked: usize,
512    pub problems: Vec<AnnotationProblem>,
513    /// Counts per-kind so reporting can show a quick taxonomy summary.
514    pub kind_counts: BTreeMap<String, usize>,
515}
516
517impl AnnotationValidationReport {
518    pub fn is_ok(&self) -> bool {
519        self.problems.is_empty()
520    }
521}
522
523/// Validate an annotations file against its target tape. Returns a
524/// structured report so the CLI can emit either pretty-printed problems
525/// or a machine-readable JSON payload.
526pub fn validate_against_tape(
527    annotations: &AnnotationTape,
528    tape: &EventTape,
529) -> AnnotationValidationReport {
530    let event_seqs: BTreeSet<u64> = tape.records.iter().map(|record| record.seq).collect();
531    let max_seq = event_seqs.iter().max().copied();
532    let mut problems = Vec::new();
533    let mut seen_ids: BTreeSet<String> = BTreeSet::new();
534    let mut kind_counts: BTreeMap<String, usize> = BTreeMap::new();
535
536    for annotation in &annotations.annotations {
537        let id_for_report = if annotation.id.is_empty() {
538            format!("ann@event_{}", annotation.event_id)
539        } else {
540            annotation.id.clone()
541        };
542        *kind_counts
543            .entry(annotation.kind.as_str().to_string())
544            .or_insert(0) += 1;
545
546        if !annotation.id.is_empty() && !seen_ids.insert(annotation.id.clone()) {
547            problems.push(AnnotationProblem::DuplicateId {
548                annotation_id: id_for_report.clone(),
549            });
550        }
551
552        if !event_seqs.contains(&annotation.event_id) {
553            problems.push(AnnotationProblem::UnknownEventId {
554                annotation_id: id_for_report.clone(),
555                event_id: annotation.event_id,
556            });
557        }
558
559        match annotation.kind {
560            AnnotationKind::Hypothesis => {
561                if annotation.hypothesis_status.is_none() {
562                    problems.push(AnnotationProblem::HypothesisStatusMissing {
563                        annotation_id: id_for_report.clone(),
564                    });
565                }
566                if annotation.friction_kind.is_some() {
567                    problems.push(AnnotationProblem::FrictionKindUnexpected {
568                        annotation_id: id_for_report.clone(),
569                    });
570                }
571            }
572            AnnotationKind::Friction => {
573                if annotation.hypothesis_status.is_some() {
574                    problems.push(AnnotationProblem::HypothesisStatusUnexpected {
575                        annotation_id: id_for_report.clone(),
576                    });
577                }
578                match annotation.friction_kind.as_deref() {
579                    None => problems.push(AnnotationProblem::FrictionKindMissing {
580                        annotation_id: id_for_report.clone(),
581                    }),
582                    Some(kind) if !friction_kind_allowed(kind) => {
583                        problems.push(AnnotationProblem::FrictionKindUnknown {
584                            annotation_id: id_for_report.clone(),
585                            friction_kind: kind.to_string(),
586                        })
587                    }
588                    Some(_) => {}
589                }
590            }
591            AnnotationKind::Unknown => {
592                problems.push(AnnotationProblem::UnknownKind {
593                    annotation_id: id_for_report.clone(),
594                });
595            }
596            _ => {
597                if annotation.hypothesis_status.is_some() {
598                    problems.push(AnnotationProblem::HypothesisStatusUnexpected {
599                        annotation_id: id_for_report.clone(),
600                    });
601                }
602                if annotation.friction_kind.is_some() {
603                    problems.push(AnnotationProblem::FrictionKindUnexpected {
604                        annotation_id: id_for_report.clone(),
605                    });
606                }
607            }
608        }
609
610        if let Some(span) = annotation.span.as_ref() {
611            if span.start_event_id != annotation.event_id {
612                problems.push(AnnotationProblem::InvalidSpan {
613                    annotation_id: id_for_report.clone(),
614                    message: format!(
615                        "span.start_event_id ({}) must equal event_id ({})",
616                        span.start_event_id, annotation.event_id
617                    ),
618                });
619            }
620            if span.end_event_id < span.start_event_id {
621                problems.push(AnnotationProblem::InvalidSpan {
622                    annotation_id: id_for_report.clone(),
623                    message: format!(
624                        "span.end_event_id ({}) is before start_event_id ({})",
625                        span.end_event_id, span.start_event_id
626                    ),
627                });
628            }
629            if let Some(max) = max_seq {
630                if span.end_event_id > max {
631                    problems.push(AnnotationProblem::InvalidSpan {
632                        annotation_id: id_for_report.clone(),
633                        message: format!(
634                            "span.end_event_id ({}) is past the last tape event (seq={max})",
635                            span.end_event_id
636                        ),
637                    });
638                }
639            }
640        }
641    }
642
643    if let (Some(expected), Some(actual)) = (
644        annotations.header.tape_content_hash.as_deref(),
645        compute_tape_content_hash(tape).as_deref(),
646    ) {
647        if expected != actual {
648            problems.push(AnnotationProblem::TapeDigestMismatch {
649                expected: expected.to_string(),
650                actual: actual.to_string(),
651            });
652        }
653    }
654
655    AnnotationValidationReport {
656        annotations_checked: annotations.annotations.len(),
657        problems,
658        kind_counts,
659    }
660}
661
662/// BLAKE3 hex digest of a tape's logical content. Implemented by
663/// hashing the deterministically-serialized record stream so the digest
664/// is stable across runs that produce the same tape — and changes when
665/// any record content does.
666pub fn compute_tape_content_hash(tape: &EventTape) -> Option<String> {
667    let mut hasher = blake3::Hasher::new();
668    for record in &tape.records {
669        let line = serde_json::to_vec(record).ok()?;
670        hasher.update(&line);
671        hasher.update(b"\n");
672    }
673    Some(hasher.finalize().to_hex().to_string())
674}
675
676/// Convenience: pair a tape record with all annotations that reference
677/// its seq. Used by the replay surface and the export pipeline.
678pub fn annotations_for_record<'a>(
679    annotations: &'a AnnotationTape,
680    record: &TapeRecord,
681) -> Vec<&'a Annotation> {
682    annotations
683        .annotations
684        .iter()
685        .filter(|annotation| annotation.event_id == record.seq)
686        .collect()
687}
688
689/// Adapt a friction-kind annotation into a [`FrictionEvent`]. Returns
690/// `None` when the annotation is not a friction record or is missing the
691/// required `friction_kind`.
692pub fn annotation_to_friction_event(
693    annotation: &Annotation,
694    header: &AnnotationHeader,
695) -> Option<FrictionEvent> {
696    if annotation.kind != AnnotationKind::Friction {
697        return None;
698    }
699    let kind = annotation.friction_kind.clone()?;
700    if !friction_kind_allowed(&kind) {
701        return None;
702    }
703    let summary = annotation.evidence.clone().unwrap_or_else(|| {
704        format!(
705            "annotation {} on event {}",
706            annotation.id, annotation.event_id
707        )
708    });
709    let mut links = Vec::new();
710    for link in &annotation.links {
711        links.push(FrictionLink {
712            label: link.label.clone(),
713            url: link.url.clone(),
714            trace_id: link.reference.clone(),
715        });
716    }
717    Some(FrictionEvent {
718        schema_version: FRICTION_SCHEMA_VERSION,
719        id: if annotation.id.is_empty() {
720            format!("annotation_{}", annotation.event_id)
721        } else {
722            annotation.id.clone()
723        },
724        kind,
725        source: header.tape_path.clone(),
726        actor: annotation.author.as_ref().and_then(|a| a.id.clone()),
727        tenant_id: None,
728        task_id: None,
729        run_id: None,
730        workflow_id: None,
731        tool: None,
732        provider: None,
733        redacted_summary: summary,
734        estimated_cost_usd: None,
735        estimated_time_ms: None,
736        recurrence_hints: Vec::new(),
737        trace_id: None,
738        span_id: None,
739        links,
740        human_hypothesis: None,
741        metadata: annotation.metadata.clone(),
742        timestamp: annotation
743            .timestamp
744            .clone()
745            .unwrap_or_else(crate::orchestration::now_rfc3339),
746    })
747}
748
749#[cfg(test)]
750mod tests {
751    use super::*;
752    use crate::testbench::tape::{TapeHeader, TapeRecord, TapeRecordKind};
753    use tempfile::TempDir;
754
755    fn sample_tape() -> EventTape {
756        let mut tape = EventTape::new(TapeHeader::current(
757            Some(1_700_000_000_000),
758            Some("script.harn".into()),
759            Vec::new(),
760        ));
761        for seq in 0..3 {
762            tape.records.push(TapeRecord {
763                seq,
764                virtual_time_ms: 0,
765                monotonic_ms: 0,
766                kind: TapeRecordKind::ClockSleep { duration_ms: 1 },
767            });
768        }
769        tape
770    }
771
772    fn note_annotation(id: &str, event_id: u64) -> Annotation {
773        Annotation {
774            id: id.into(),
775            event_id,
776            kind: AnnotationKind::Note,
777            evidence: Some("looked fine".into()),
778            suggested_fix: None,
779            author: Some(AnnotationAuthor {
780                id: Some("alice".into()),
781                kind: AuthorKind::Human,
782                surface: Some("burin-code".into()),
783            }),
784            timestamp: Some("2026-05-10T17:00:00Z".into()),
785            span: None,
786            hypothesis_status: None,
787            friction_kind: None,
788            links: Vec::new(),
789            metadata: BTreeMap::new(),
790        }
791    }
792
793    #[test]
794    fn round_trip_preserves_records() {
795        let temp = TempDir::new().unwrap();
796        let path = temp.path().join("run.tape.annotations.jsonl");
797        let mut tape = AnnotationTape::new(AnnotationHeader::current(
798            Some("run.tape".into()),
799            Some("deadbeef".into()),
800        ));
801        tape.annotations.push(note_annotation("ann-1", 0));
802        tape.annotations.push(Annotation {
803            kind: AnnotationKind::Hypothesis,
804            hypothesis_status: Some(HypothesisStatus::Active),
805            ..note_annotation("ann-2", 1)
806        });
807        tape.persist(&path).unwrap();
808
809        let loaded = AnnotationTape::load(&path).unwrap();
810        assert_eq!(loaded.header.schema_version, ANNOTATION_SCHEMA_VERSION);
811        assert_eq!(loaded.annotations.len(), 2);
812        assert_eq!(loaded.annotations[0].kind, AnnotationKind::Note);
813        assert_eq!(loaded.annotations[1].kind, AnnotationKind::Hypothesis);
814        assert_eq!(
815            loaded.annotations[1].hypothesis_status,
816            Some(HypothesisStatus::Active)
817        );
818    }
819
820    #[test]
821    fn validator_flags_unknown_event_id_and_missing_status() {
822        let tape = sample_tape();
823        let mut annotations =
824            AnnotationTape::new(AnnotationHeader::current(Some("run.tape".into()), None));
825        annotations.annotations.push(note_annotation("note", 0));
826        annotations.annotations.push(Annotation {
827            event_id: 99,
828            kind: AnnotationKind::Hypothesis,
829            hypothesis_status: None,
830            ..note_annotation("missing", 99)
831        });
832        annotations.annotations.push(Annotation {
833            kind: AnnotationKind::Friction,
834            friction_kind: Some("does_not_exist".into()),
835            ..note_annotation("bad-friction", 1)
836        });
837        annotations.annotations.push(Annotation {
838            kind: AnnotationKind::Friction,
839            friction_kind: None,
840            ..note_annotation("missing-friction", 2)
841        });
842
843        let report = validate_against_tape(&annotations, &tape);
844        assert_eq!(report.annotations_checked, 4);
845        assert!(report
846            .problems
847            .iter()
848            .any(|p| matches!(p, AnnotationProblem::UnknownEventId { event_id: 99, .. })));
849        assert!(report
850            .problems
851            .iter()
852            .any(|p| matches!(p, AnnotationProblem::HypothesisStatusMissing { .. })));
853        assert!(report
854            .problems
855            .iter()
856            .any(|p| matches!(p, AnnotationProblem::FrictionKindUnknown { .. })));
857        assert!(report
858            .problems
859            .iter()
860            .any(|p| matches!(p, AnnotationProblem::FrictionKindMissing { .. })));
861    }
862
863    #[test]
864    fn span_validation_enforces_invariants() {
865        let tape = sample_tape();
866        let mut annotations = AnnotationTape::new(AnnotationHeader::current(None, None));
867        annotations.annotations.push(Annotation {
868            span: Some(AnnotationSpan {
869                start_event_id: 5,
870                end_event_id: 10,
871            }),
872            ..note_annotation("bad-start", 1)
873        });
874        annotations.annotations.push(Annotation {
875            span: Some(AnnotationSpan {
876                start_event_id: 1,
877                end_event_id: 0,
878            }),
879            ..note_annotation("inverted", 1)
880        });
881        annotations.annotations.push(Annotation {
882            span: Some(AnnotationSpan {
883                start_event_id: 1,
884                end_event_id: 99,
885            }),
886            ..note_annotation("past-end", 1)
887        });
888
889        let report = validate_against_tape(&annotations, &tape);
890        // bad-start: start != event_id + end > max ⇒ 2 problems.
891        // inverted: end < start ⇒ 1 problem.
892        // past-end: end > max ⇒ 1 problem.
893        assert_eq!(
894            report
895                .problems
896                .iter()
897                .filter(|p| matches!(p, AnnotationProblem::InvalidSpan { .. }))
898                .count(),
899            4
900        );
901    }
902
903    #[test]
904    fn duplicate_ids_are_flagged() {
905        let tape = sample_tape();
906        let mut annotations = AnnotationTape::new(AnnotationHeader::current(None, None));
907        annotations.annotations.push(note_annotation("dupe", 0));
908        annotations.annotations.push(note_annotation("dupe", 1));
909        let report = validate_against_tape(&annotations, &tape);
910        assert!(report
911            .problems
912            .iter()
913            .any(|p| matches!(p, AnnotationProblem::DuplicateId { .. })));
914    }
915
916    #[test]
917    fn tape_digest_mismatch_flags_stale_annotations() {
918        let tape = sample_tape();
919        let mut annotations = AnnotationTape::new(AnnotationHeader::current(
920            Some("run.tape".into()),
921            Some("not-the-real-hash".into()),
922        ));
923        annotations.annotations.push(note_annotation("note", 0));
924        let report = validate_against_tape(&annotations, &tape);
925        assert!(report
926            .problems
927            .iter()
928            .any(|p| matches!(p, AnnotationProblem::TapeDigestMismatch { .. })));
929    }
930
931    #[test]
932    fn unknown_kind_round_trips_and_validator_flags() {
933        let temp = TempDir::new().unwrap();
934        let path = temp.path().join("future.annotations.jsonl");
935        let body = format!(
936            "{}\n{}\n",
937            serde_json::to_string(&AnnotationLine::Header(AnnotationHeader::current(
938                None, None
939            )))
940            .unwrap(),
941            r#"{"type":"annotation","id":"ann","event_id":0,"kind":"future_kind"}"#
942        );
943        std::fs::write(&path, body).unwrap();
944        let loaded = AnnotationTape::load(&path).unwrap();
945        assert_eq!(loaded.annotations.len(), 1);
946        assert_eq!(loaded.annotations[0].kind, AnnotationKind::Unknown);
947        let report = validate_against_tape(&loaded, &sample_tape());
948        assert!(report
949            .problems
950            .iter()
951            .any(|p| matches!(p, AnnotationProblem::UnknownKind { .. })));
952    }
953
954    #[test]
955    fn rejects_newer_schema_version() {
956        let temp = TempDir::new().unwrap();
957        let path = temp.path().join("future.annotations.jsonl");
958        std::fs::write(
959            &path,
960            r#"{"type":"header","schema_version":99}
961"#,
962        )
963        .unwrap();
964        let err = AnnotationTape::load(&path).unwrap_err();
965        assert!(err.contains("schema_version 99"), "{err}");
966    }
967
968    #[test]
969    fn comments_and_blank_lines_are_skipped() {
970        let temp = TempDir::new().unwrap();
971        let path = temp.path().join("commented.annotations.jsonl");
972        let header = serde_json::to_string(&AnnotationLine::Header(AnnotationHeader::current(
973            None, None,
974        )))
975        .unwrap();
976        let annotation =
977            serde_json::to_string(&AnnotationLine::Annotation(note_annotation("ann", 0))).unwrap();
978        let body = format!("# leading comment\n\n{header}\n\n# spacer\n{annotation}\n");
979        std::fs::write(&path, body).unwrap();
980        let loaded = AnnotationTape::load(&path).unwrap();
981        assert_eq!(loaded.annotations.len(), 1);
982    }
983
984    #[test]
985    fn friction_annotations_round_trip_through_friction_event() {
986        let mut tape =
987            AnnotationTape::new(AnnotationHeader::current(Some("run.tape".into()), None));
988        tape.annotations.push(Annotation {
989            kind: AnnotationKind::Friction,
990            friction_kind: Some("repeated_query".into()),
991            evidence: Some("Splunk lookup repeats every incident".into()),
992            ..note_annotation("friction-1", 2)
993        });
994        let events = tape.to_friction_events();
995        assert_eq!(events.len(), 1);
996        assert_eq!(events[0].kind, "repeated_query");
997        assert_eq!(events[0].schema_version, FRICTION_SCHEMA_VERSION);
998        assert_eq!(
999            events[0].redacted_summary,
1000            "Splunk lookup repeats every incident"
1001        );
1002    }
1003
1004    #[test]
1005    fn crystallize_anchors_surface_event_ids() {
1006        let mut tape = AnnotationTape::new(AnnotationHeader::current(None, None));
1007        tape.annotations.push(Annotation {
1008            kind: AnnotationKind::CrystallizeHere,
1009            span: Some(AnnotationSpan {
1010                start_event_id: 1,
1011                end_event_id: 4,
1012            }),
1013            ..note_annotation("crys-1", 1)
1014        });
1015        let anchors = tape.crystallize_anchors();
1016        assert_eq!(anchors.len(), 1);
1017        assert_eq!(anchors[0].event_id, 1);
1018        assert_eq!(anchors[0].end_event_id, 4);
1019    }
1020}