1use serde::{Deserialize, Serialize};
62use std::collections::BTreeSet;
63use std::fs;
64use std::io::{self, Write};
65use std::path::{Path, PathBuf};
66use std::time::{SystemTime, UNIX_EPOCH};
67
68use crate::canonical;
69use crate::intent::IntentId;
70use crate::operation::{OpId, StageId};
71
72pub type AttestationId = String;
76
77pub type SpecId = String;
82
83pub type ContentHash = String;
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(tag = "kind", rename_all = "snake_case")]
92pub enum AttestationKind {
93 Examples {
96 file_hash: ContentHash,
97 count: usize,
98 },
99 Spec {
102 spec_id: SpecId,
103 method: SpecMethod,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 trials: Option<usize>,
106 },
107 DiffBody {
110 other_body_hash: ContentHash,
111 input_count: usize,
112 },
113 TypeCheck,
117 EffectAudit,
121 SandboxRun {
125 effects: BTreeSet<String>,
126 },
127 Override {
140 actor: String,
141 reason: String,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 target_attestation_id: Option<AttestationId>,
144 },
145 Defer {
150 actor: String,
151 reason: String,
152 },
153 Block {
159 actor: String,
160 reason: String,
161 },
162 Unblock {
167 actor: String,
168 reason: String,
169 },
170}
171
172pub fn is_stage_blocked(attestations: &[Attestation]) -> bool {
182 let mut latest: Option<&Attestation> = None;
183 for a in attestations {
184 if !matches!(a.kind, AttestationKind::Block { .. } | AttestationKind::Unblock { .. }) {
185 continue;
186 }
187 match latest {
188 None => latest = Some(a),
189 Some(prev) if a.timestamp > prev.timestamp => latest = Some(a),
190 Some(prev) if a.timestamp == prev.timestamp
191 && matches!(a.kind, AttestationKind::Unblock { .. }) =>
192 {
193 latest = Some(a);
194 }
195 _ => {}
196 }
197 }
198 matches!(latest.map(|a| &a.kind), Some(AttestationKind::Block { .. }))
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(rename_all = "snake_case")]
206pub enum SpecMethod {
207 Exhaustive,
209 Random,
211 Symbolic,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(tag = "result", rename_all = "snake_case")]
221pub enum AttestationResult {
222 Passed,
223 Failed { detail: String },
224 Inconclusive { detail: String },
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235pub struct ProducerDescriptor {
236 pub tool: String,
237 pub version: String,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub model: Option<String>,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub struct Cost {
247 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub tokens_in: Option<u64>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub tokens_out: Option<u64>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub usd_cents: Option<u64>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub wall_time_ms: Option<u64>,
256}
257
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261pub struct Signature {
262 pub public_key: String,
264 pub signature: String,
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271pub struct Attestation {
272 pub attestation_id: AttestationId,
273 pub stage_id: StageId,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub op_id: Option<OpId>,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub intent_id: Option<IntentId>,
278 pub kind: AttestationKind,
279 pub result: AttestationResult,
280 pub produced_by: ProducerDescriptor,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub cost: Option<Cost>,
283 pub timestamp: u64,
287 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub signature: Option<Signature>,
289}
290
291impl Attestation {
292 #[allow(clippy::too_many_arguments)]
296 pub fn new(
297 stage_id: impl Into<StageId>,
298 op_id: Option<OpId>,
299 intent_id: Option<IntentId>,
300 kind: AttestationKind,
301 result: AttestationResult,
302 produced_by: ProducerDescriptor,
303 cost: Option<Cost>,
304 ) -> Self {
305 let now = SystemTime::now()
306 .duration_since(UNIX_EPOCH)
307 .map(|d| d.as_secs())
308 .unwrap_or(0);
309 Self::with_timestamp(stage_id, op_id, intent_id, kind, result, produced_by, cost, now)
310 }
311
312 #[allow(clippy::too_many_arguments)]
315 pub fn with_timestamp(
316 stage_id: impl Into<StageId>,
317 op_id: Option<OpId>,
318 intent_id: Option<IntentId>,
319 kind: AttestationKind,
320 result: AttestationResult,
321 produced_by: ProducerDescriptor,
322 cost: Option<Cost>,
323 timestamp: u64,
324 ) -> Self {
325 let stage_id = stage_id.into();
326 let attestation_id = compute_attestation_id(
327 &stage_id,
328 op_id.as_deref(),
329 intent_id.as_deref(),
330 &kind,
331 &result,
332 &produced_by,
333 );
334 Self {
335 attestation_id,
336 stage_id,
337 op_id,
338 intent_id,
339 kind,
340 result,
341 produced_by,
342 cost,
343 timestamp,
344 signature: None,
345 }
346 }
347
348 pub fn with_signature(mut self, signature: Signature) -> Self {
354 self.signature = Some(signature);
355 self
356 }
357}
358
359fn compute_attestation_id(
360 stage_id: &str,
361 op_id: Option<&str>,
362 intent_id: Option<&str>,
363 kind: &AttestationKind,
364 result: &AttestationResult,
365 produced_by: &ProducerDescriptor,
366) -> AttestationId {
367 let view = CanonicalAttestationView {
368 stage_id,
369 op_id,
370 intent_id,
371 kind,
372 result,
373 produced_by,
374 };
375 canonical::hash(&view)
376}
377
378#[derive(Serialize)]
382struct CanonicalAttestationView<'a> {
383 stage_id: &'a str,
384 #[serde(skip_serializing_if = "Option::is_none")]
385 op_id: Option<&'a str>,
386 #[serde(skip_serializing_if = "Option::is_none")]
387 intent_id: Option<&'a str>,
388 kind: &'a AttestationKind,
389 result: &'a AttestationResult,
390 produced_by: &'a ProducerDescriptor,
391}
392
393pub struct AttestationLog {
403 dir: PathBuf,
404 by_stage: PathBuf,
405}
406
407impl AttestationLog {
408 pub fn open(root: &Path) -> io::Result<Self> {
409 let dir = root.join("attestations");
410 let by_stage = dir.join("by-stage");
411 fs::create_dir_all(&by_stage)?;
412 Ok(Self { dir, by_stage })
413 }
414
415 fn primary_path(&self, id: &AttestationId) -> PathBuf {
416 self.dir.join(format!("{id}.json"))
417 }
418
419 pub fn put(&self, attestation: &Attestation) -> io::Result<()> {
424 let primary = self.primary_path(&attestation.attestation_id);
425 if !primary.exists() {
426 let bytes = serde_json::to_vec(attestation)
427 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
428 let tmp = primary.with_extension("json.tmp");
429 let mut f = fs::File::create(&tmp)?;
430 f.write_all(&bytes)?;
431 f.sync_all()?;
432 fs::rename(&tmp, &primary)?;
433 }
434 let stage_dir = self.by_stage.join(&attestation.stage_id);
438 fs::create_dir_all(&stage_dir)?;
439 let idx = stage_dir.join(&attestation.attestation_id);
440 if !idx.exists() {
441 fs::File::create(&idx)?;
442 }
443 Ok(())
444 }
445
446 pub fn get(&self, id: &AttestationId) -> io::Result<Option<Attestation>> {
447 let path = self.primary_path(id);
448 if !path.exists() {
449 return Ok(None);
450 }
451 let bytes = fs::read(&path)?;
452 let attestation: Attestation = serde_json::from_slice(&bytes)
453 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
454 Ok(Some(attestation))
455 }
456
457 pub fn list_all(&self) -> io::Result<Vec<Attestation>> {
464 let mut out = Vec::new();
465 if !self.dir.exists() {
466 return Ok(out);
467 }
468 for entry in fs::read_dir(&self.dir)? {
469 let entry = entry?;
470 let p = entry.path();
471 if p.is_dir() {
474 continue;
475 }
476 if p.extension().is_none_or(|e| e != "json") {
477 continue;
478 }
479 let bytes = fs::read(&p)?;
480 match serde_json::from_slice::<Attestation>(&bytes) {
483 Ok(att) => out.push(att),
484 Err(e) => eprintln!(
485 "warning: skipping unreadable attestation {}: {e}",
486 p.display()
487 ),
488 }
489 }
490 Ok(out)
491 }
492
493 pub fn list_for_stage(&self, stage_id: &StageId) -> io::Result<Vec<Attestation>> {
498 let stage_dir = self.by_stage.join(stage_id);
499 if !stage_dir.exists() {
500 return Ok(Vec::new());
501 }
502 let mut out = Vec::new();
503 for entry in fs::read_dir(&stage_dir)? {
504 let entry = entry?;
505 let id = match entry.file_name().into_string() {
506 Ok(s) => s,
507 Err(_) => continue,
508 };
509 if let Some(att) = self.get(&id)? {
510 out.push(att);
511 }
512 }
513 Ok(out)
514 }
515
516}
517
518#[cfg(test)]
521mod tests {
522 use super::*;
523
524 fn ci_runner() -> ProducerDescriptor {
525 ProducerDescriptor {
526 tool: "lex check".into(),
527 version: "0.1.0".into(),
528 model: None,
529 }
530 }
531
532 fn typecheck_passed() -> Attestation {
533 Attestation::with_timestamp(
534 "stage-abc",
535 Some("op-123".into()),
536 None,
537 AttestationKind::TypeCheck,
538 AttestationResult::Passed,
539 ci_runner(),
540 None,
541 1000,
542 )
543 }
544
545 #[test]
546 fn same_logical_verification_hashes_equal() {
547 let a = typecheck_passed();
551 let b = Attestation::with_timestamp(
552 "stage-abc",
553 Some("op-123".into()),
554 None,
555 AttestationKind::TypeCheck,
556 AttestationResult::Passed,
557 ci_runner(),
558 Some(Cost {
559 tokens_in: Some(0),
560 tokens_out: Some(0),
561 usd_cents: Some(0),
562 wall_time_ms: Some(42),
563 }),
564 99999,
565 );
566 assert_eq!(a.attestation_id, b.attestation_id);
567 }
568
569 #[test]
570 fn different_stages_hash_differently() {
571 let a = typecheck_passed();
572 let b = Attestation::with_timestamp(
573 "stage-XYZ",
574 Some("op-123".into()),
575 None,
576 AttestationKind::TypeCheck,
577 AttestationResult::Passed,
578 ci_runner(),
579 None,
580 1000,
581 );
582 assert_ne!(a.attestation_id, b.attestation_id);
583 }
584
585 #[test]
586 fn different_op_ids_hash_differently() {
587 let a = typecheck_passed();
588 let b = Attestation::with_timestamp(
589 "stage-abc",
590 Some("op-XYZ".into()),
591 None,
592 AttestationKind::TypeCheck,
593 AttestationResult::Passed,
594 ci_runner(),
595 None,
596 1000,
597 );
598 assert_ne!(a.attestation_id, b.attestation_id);
599 }
600
601 #[test]
602 fn different_intents_hash_differently() {
603 let a = Attestation::with_timestamp(
604 "stage-abc", None,
605 Some("intent-A".into()),
606 AttestationKind::TypeCheck, AttestationResult::Passed,
607 ci_runner(), None, 1000,
608 );
609 let b = Attestation::with_timestamp(
610 "stage-abc", None,
611 Some("intent-B".into()),
612 AttestationKind::TypeCheck, AttestationResult::Passed,
613 ci_runner(), None, 1000,
614 );
615 assert_ne!(a.attestation_id, b.attestation_id);
616 }
617
618 #[test]
619 fn different_kinds_hash_differently() {
620 let a = typecheck_passed();
621 let b = Attestation::with_timestamp(
622 "stage-abc",
623 Some("op-123".into()),
624 None,
625 AttestationKind::EffectAudit,
626 AttestationResult::Passed,
627 ci_runner(),
628 None,
629 1000,
630 );
631 assert_ne!(a.attestation_id, b.attestation_id);
632 }
633
634 #[test]
635 fn passed_vs_failed_hash_differently() {
636 let a = typecheck_passed();
641 let b = Attestation::with_timestamp(
642 "stage-abc",
643 Some("op-123".into()),
644 None,
645 AttestationKind::TypeCheck,
646 AttestationResult::Failed { detail: "arity mismatch".into() },
647 ci_runner(),
648 None,
649 1000,
650 );
651 assert_ne!(a.attestation_id, b.attestation_id);
652 }
653
654 #[test]
655 fn different_producers_hash_differently() {
656 let a = typecheck_passed();
657 let mut other = ci_runner();
658 other.tool = "third-party-runner".into();
659 let b = Attestation::with_timestamp(
660 "stage-abc",
661 Some("op-123".into()),
662 None,
663 AttestationKind::TypeCheck,
664 AttestationResult::Passed,
665 other,
666 None,
667 1000,
668 );
669 assert_ne!(
670 a.attestation_id, b.attestation_id,
671 "an attestation from a different producer is a different fact",
672 );
673 }
674
675 #[test]
676 fn signature_is_excluded_from_hash() {
677 let a = typecheck_passed();
681 let b = typecheck_passed().with_signature(Signature {
682 public_key: "ed25519:fffe".into(),
683 signature: "0xabcd".into(),
684 });
685 assert_eq!(a.attestation_id, b.attestation_id);
686 }
687
688 #[test]
689 fn attestation_id_is_64_char_lowercase_hex() {
690 let a = typecheck_passed();
691 assert_eq!(a.attestation_id.len(), 64);
692 assert!(a
693 .attestation_id
694 .chars()
695 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)));
696 }
697
698 #[test]
699 fn round_trip_through_serde_json() {
700 let a = Attestation::with_timestamp(
701 "stage-abc",
702 Some("op-123".into()),
703 Some("intent-A".into()),
704 AttestationKind::Spec {
705 spec_id: "clamp.spec".into(),
706 method: SpecMethod::Random,
707 trials: Some(1000),
708 },
709 AttestationResult::Passed,
710 ProducerDescriptor {
711 tool: "lex agent-tool".into(),
712 version: "0.1.0".into(),
713 model: Some("claude-opus-4-7".into()),
714 },
715 Some(Cost {
716 tokens_in: Some(1234),
717 tokens_out: Some(567),
718 usd_cents: Some(2),
719 wall_time_ms: Some(3400),
720 }),
721 99,
722 )
723 .with_signature(Signature {
724 public_key: "ed25519:abc".into(),
725 signature: "0x1234".into(),
726 });
727 let json = serde_json::to_string(&a).unwrap();
728 let back: Attestation = serde_json::from_str(&json).unwrap();
729 assert_eq!(a, back);
730 }
731
732 #[test]
737 fn canonical_form_is_stable_for_a_known_input() {
738 let a = Attestation::with_timestamp(
739 "stage-abc",
740 Some("op-123".into()),
741 None,
742 AttestationKind::TypeCheck,
743 AttestationResult::Passed,
744 ProducerDescriptor {
745 tool: "lex check".into(),
746 version: "0.1.0".into(),
747 model: None,
748 },
749 None,
750 0,
751 );
752 assert_eq!(
753 a.attestation_id,
754 "a4ef921f7bb0db70779c5b698cda1744d49165a4a56aa8414bdbafc85bcbc16b",
755 "canonical-form regression: the AttestationId for a known input changed",
756 );
757 }
758
759 #[test]
762 fn log_round_trips_through_disk() {
763 let tmp = tempfile::tempdir().unwrap();
764 let log = AttestationLog::open(tmp.path()).unwrap();
765 let a = typecheck_passed();
766 log.put(&a).unwrap();
767 let read_back = log.get(&a.attestation_id).unwrap().unwrap();
768 assert_eq!(a, read_back);
769 }
770
771 #[test]
772 fn log_get_unknown_returns_none() {
773 let tmp = tempfile::tempdir().unwrap();
774 let log = AttestationLog::open(tmp.path()).unwrap();
775 assert!(log
776 .get(&"nonexistent".to_string())
777 .unwrap()
778 .is_none());
779 }
780
781 #[test]
782 fn log_put_is_idempotent() {
783 let tmp = tempfile::tempdir().unwrap();
784 let log = AttestationLog::open(tmp.path()).unwrap();
785 let a = typecheck_passed();
786 log.put(&a).unwrap();
787 log.put(&a).unwrap();
788 let read_back = log.get(&a.attestation_id).unwrap().unwrap();
789 assert_eq!(a, read_back);
790 }
791
792 #[test]
793 fn list_for_stage_returns_only_that_stage() {
794 let tmp = tempfile::tempdir().unwrap();
795 let log = AttestationLog::open(tmp.path()).unwrap();
796
797 let on_abc_1 = typecheck_passed();
798 let on_abc_2 = Attestation::with_timestamp(
799 "stage-abc",
800 Some("op-123".into()),
801 None,
802 AttestationKind::EffectAudit,
803 AttestationResult::Passed,
804 ci_runner(),
805 None,
806 2000,
807 );
808 let on_xyz = Attestation::with_timestamp(
809 "stage-xyz",
810 Some("op-456".into()),
811 None,
812 AttestationKind::TypeCheck,
813 AttestationResult::Passed,
814 ci_runner(),
815 None,
816 1000,
817 );
818
819 log.put(&on_abc_1).unwrap();
820 log.put(&on_abc_2).unwrap();
821 log.put(&on_xyz).unwrap();
822
823 let mut on_abc = log.list_for_stage(&"stage-abc".to_string()).unwrap();
824 on_abc.sort_by_key(|a| a.timestamp);
825 assert_eq!(on_abc.len(), 2);
826 assert_eq!(on_abc[0], on_abc_1);
827 assert_eq!(on_abc[1], on_abc_2);
828
829 let on_xyz_listed = log.list_for_stage(&"stage-xyz".to_string()).unwrap();
830 assert_eq!(on_xyz_listed.len(), 1);
831 assert_eq!(on_xyz_listed[0], on_xyz);
832 }
833
834 #[test]
835 fn list_for_unknown_stage_is_empty() {
836 let tmp = tempfile::tempdir().unwrap();
837 let log = AttestationLog::open(tmp.path()).unwrap();
838 let v = log.list_for_stage(&"never-attested".to_string()).unwrap();
839 assert!(v.is_empty());
840 }
841
842 #[test]
843 fn list_all_returns_every_persisted_attestation() {
844 let tmp = tempfile::tempdir().unwrap();
849 let log = AttestationLog::open(tmp.path()).unwrap();
850 let on_abc = typecheck_passed();
851 let on_xyz = Attestation::with_timestamp(
852 "stage-xyz",
853 Some("op-456".into()),
854 None,
855 AttestationKind::TypeCheck,
856 AttestationResult::Passed,
857 ci_runner(),
858 None,
859 2000,
860 );
861 log.put(&on_abc).unwrap();
862 log.put(&on_xyz).unwrap();
863 let mut all = log.list_all().unwrap();
864 all.sort_by_key(|a| a.attestation_id.clone());
865 assert_eq!(all.len(), 2);
866 let ids: BTreeSet<_> = all.iter().map(|a| a.attestation_id.clone()).collect();
867 assert!(ids.contains(&on_abc.attestation_id));
868 assert!(ids.contains(&on_xyz.attestation_id));
869 }
870
871 #[test]
872 fn list_all_on_empty_log_is_empty() {
873 let tmp = tempfile::tempdir().unwrap();
874 let log = AttestationLog::open(tmp.path()).unwrap();
875 let v = log.list_all().unwrap();
876 assert!(v.is_empty());
877 }
878
879 #[test]
880 fn passed_and_failed_for_same_stage_both_persist() {
881 let tmp = tempfile::tempdir().unwrap();
886 let log = AttestationLog::open(tmp.path()).unwrap();
887
888 let passed = typecheck_passed();
889 let failed = Attestation::with_timestamp(
890 "stage-abc",
891 Some("op-123".into()),
892 None,
893 AttestationKind::TypeCheck,
894 AttestationResult::Failed { detail: "arity mismatch".into() },
895 ci_runner(),
896 None,
897 500,
898 );
899
900 log.put(&failed).unwrap();
901 log.put(&passed).unwrap();
902
903 let listing = log.list_for_stage(&"stage-abc".to_string()).unwrap();
904 assert_eq!(listing.len(), 2, "both passing and failing evidence must persist");
905 }
906
907 fn human_decision(kind: AttestationKind, ts: u64) -> Attestation {
908 Attestation::with_timestamp(
909 "stage-abc",
910 None, None,
911 kind,
912 AttestationResult::Passed,
913 ProducerDescriptor {
914 tool: "lex stage".into(),
915 version: "0.1.0".into(),
916 model: None,
917 },
918 None,
919 ts,
920 )
921 }
922
923 #[test]
924 fn is_stage_blocked_empty_log_is_false() {
925 assert!(!is_stage_blocked(&[]));
926 }
927
928 #[test]
929 fn is_stage_blocked_only_unrelated_attestations() {
930 let attestations = vec![
933 typecheck_passed(),
934 human_decision(
935 AttestationKind::Override {
936 actor: "alice".into(),
937 reason: "ship".into(),
938 target_attestation_id: None,
939 },
940 500,
941 ),
942 ];
943 assert!(!is_stage_blocked(&attestations));
944 }
945
946 #[test]
947 fn is_stage_blocked_block_alone_blocks() {
948 let attestations = vec![human_decision(
949 AttestationKind::Block { actor: "alice".into(), reason: "x".into() },
950 500,
951 )];
952 assert!(is_stage_blocked(&attestations));
953 }
954
955 #[test]
956 fn is_stage_blocked_later_unblock_clears_block() {
957 let attestations = vec![
958 human_decision(
959 AttestationKind::Block { actor: "alice".into(), reason: "x".into() },
960 500,
961 ),
962 human_decision(
963 AttestationKind::Unblock { actor: "alice".into(), reason: "ok".into() },
964 600,
965 ),
966 ];
967 assert!(!is_stage_blocked(&attestations));
968 }
969
970 #[test]
971 fn is_stage_blocked_later_block_re_blocks() {
972 let attestations = vec![
973 human_decision(
974 AttestationKind::Block { actor: "a".into(), reason: "1".into() },
975 500,
976 ),
977 human_decision(
978 AttestationKind::Unblock { actor: "a".into(), reason: "2".into() },
979 600,
980 ),
981 human_decision(
982 AttestationKind::Block { actor: "a".into(), reason: "3".into() },
983 700,
984 ),
985 ];
986 assert!(is_stage_blocked(&attestations));
987 }
988
989 #[test]
990 fn is_stage_blocked_unblock_wins_at_same_timestamp() {
991 let attestations = vec![
994 human_decision(
995 AttestationKind::Block { actor: "a".into(), reason: "1".into() },
996 500,
997 ),
998 human_decision(
999 AttestationKind::Unblock { actor: "a".into(), reason: "2".into() },
1000 500,
1001 ),
1002 ];
1003 assert!(!is_stage_blocked(&attestations));
1004 }
1005}