1use 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
68pub const ANNOTATION_SCHEMA_VERSION: u32 = 1;
71
72pub const ANNOTATIONS_SIDECAR_SUFFIX: &str = ".annotations.jsonl";
75
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80pub struct AnnotationHeader {
81 pub schema_version: u32,
82 #[serde(default)]
86 pub tape_path: Option<String>,
87 #[serde(default)]
91 pub tape_content_hash: Option<String>,
92 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
111pub struct Annotation {
112 #[serde(default)]
116 pub id: String,
117 pub event_id: u64,
119 pub kind: AnnotationKind,
120 #[serde(default)]
123 pub evidence: Option<String>,
124 #[serde(default)]
128 pub suggested_fix: Option<serde_json::Value>,
129 #[serde(default)]
130 pub author: Option<AnnotationAuthor>,
131 #[serde(default)]
134 pub timestamp: Option<String>,
135 #[serde(default)]
139 pub span: Option<AnnotationSpan>,
140 #[serde(default)]
142 pub hypothesis_status: Option<HypothesisStatus>,
143 #[serde(default)]
147 pub friction_kind: Option<String>,
148 #[serde(default)]
152 pub links: Vec<AnnotationLink>,
153 #[serde(default)]
157 pub metadata: BTreeMap<String, serde_json::Value>,
158}
159
160#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
164#[serde(rename_all = "snake_case")]
165pub enum AnnotationKind {
166 Correct,
168 Incorrect,
170 Alternative,
173 Note,
175 Marker,
178 Mute,
181 Hypothesis,
183 Friction,
186 CrystallizeHere,
189 #[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 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#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
233#[serde(rename_all = "snake_case")]
234pub enum HypothesisStatus {
235 Active,
237 Verifying,
239 Confirmed,
241 Disproven,
243 Stale,
245}
246
247#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
260pub struct AnnotationAuthor {
261 #[serde(default)]
264 pub id: Option<String>,
265 pub kind: AuthorKind,
267 #[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 pub reference: Option<String>,
290}
291
292#[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 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 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 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 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 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
454#[serde(tag = "type", rename_all = "snake_case")]
455enum AnnotationLine {
456 Header(AnnotationHeader),
457 Annotation(Annotation),
458}
459
460#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
463#[serde(tag = "code", rename_all = "snake_case")]
464pub enum AnnotationProblem {
465 Schema {
469 annotation_id: String,
470 message: String,
471 },
472 UnknownEventId {
474 annotation_id: String,
475 event_id: u64,
476 },
477 HypothesisStatusMissing { annotation_id: String },
479 HypothesisStatusUnexpected { annotation_id: String },
481 FrictionKindMissing { annotation_id: String },
483 FrictionKindUnexpected { annotation_id: String },
485 FrictionKindUnknown {
488 annotation_id: String,
489 friction_kind: String,
490 },
491 InvalidSpan {
494 annotation_id: String,
495 message: String,
496 },
497 DuplicateId { annotation_id: String },
499 TapeDigestMismatch { expected: String, actual: String },
503 UnknownKind { annotation_id: String },
506}
507
508#[derive(Debug, Clone, Default, Serialize, Deserialize)]
510pub struct AnnotationValidationReport {
511 pub annotations_checked: usize,
512 pub problems: Vec<AnnotationProblem>,
513 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
523pub 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
662pub 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
676pub 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
689pub 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 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}