Skip to main content

lex_vcs/
attestation.rs

1//! Persistent evidence about a stage (#132).
2//!
3//! [`Operation`](crate::Operation) records *what* changed.
4//! [`Intent`](crate::Intent) records *why*. An [`Attestation`] records
5//! *what we know about the result*: did this stage typecheck, did its
6//! examples pass, did a spec prove it, did `lex agent-tool` run it
7//! cleanly under a sandbox.
8//!
9//! Today every verification (`lex check`, `lex agent-tool --spec ...`,
10//! `lex audit --effect ...`) runs, prints a verdict, and exits. The
11//! evidence is ephemeral — there's no persistent answer to "has this
12//! stage ever been spec-checked?" beyond rerunning. That makes
13//! attestations useless as a CI gate and useless as a trust signal
14//! across sessions.
15//!
16//! This module is the foundational data layer for tier-2's evidence
17//! story. Producers (`lex check` emits `TypeCheck`, `lex agent-tool`
18//! emits `Spec` / `Examples` / `DiffBody` / `SandboxRun`) and
19//! consumers (`lex blame --with-evidence`, `GET /v1/stage/<id>/
20//! attestations`) wire to it in subsequent slices.
21//!
22//! # Identity
23//!
24//! [`AttestationId`] is the lowercase-hex SHA-256 of the canonical
25//! form of `(stage_id, op_id, intent_id, kind, result, produced_by)`.
26//! `cost`, `timestamp`, and `signature` are deliberately *not* in the
27//! hash so two independent runs of the same logical verification —
28//! same stage, same kind, same producer, same outcome — produce the
29//! same `attestation_id`. This is the dedup property the issue calls
30//! out: harnesses can ask "has this exact verification been done?"
31//! by checking for the id without rerunning.
32//!
33//! # Storage
34//!
35//! ```text
36//! <root>/attestations/<AttestationId>.json
37//! <root>/attestations/by-stage/<StageId>/<AttestationId>
38//! ```
39//!
40//! The primary file under `attestations/` is the source of truth.
41//! `by-stage/` is a per-stage index — empty marker files whose names
42//! point at the primary record. Rebuildable from primary records on
43//! demand; we write it eagerly so `lex stage <id> --attestations` is
44//! a directory listing rather than a full scan.
45//!
46//! `by-spec/` (mentioned in the issue) is deferred until a producer
47//! actually emits `Spec` attestations against persisted spec ids.
48//!
49//! # Trust model
50//!
51//! Attestations are claims, not proofs. The store doesn't trust
52//! attestations from outside — it just stores them. A maintainer
53//! choosing to skip CI for a stage that already has a passing spec
54//! attestation from a known producer is a *policy* decision, not a
55//! guarantee the store enforces. The optional Ed25519 signature
56//! field exists so an attestation can be cryptographically tied to
57//! a producer (e.g. a CI runner's public key) and the policy
58//! decision auditable. Verifying signatures is out of scope for the
59//! data layer.
60
61use 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
72/// Content-addressed identity of an attestation. Lowercase-hex
73/// SHA-256 of the canonical form of
74/// `(stage_id, op_id, intent_id, kind, result, produced_by)`.
75pub type AttestationId = String;
76
77/// Reference to a spec file. Free-form string so callers can use
78/// either a content hash or a logical name; the data layer doesn't
79/// care which. Producers should pick one and stick with it for
80/// dedup to work as expected.
81pub type SpecId = String;
82
83/// Content hash of a file (examples list, body source, etc.). Kept
84/// as a string for the same reason as [`OpId`]: we want this crate
85/// to have no view into the hash function used by callers.
86pub type ContentHash = String;
87
88/// What was verified. The variants mirror the verdict surfaces
89/// `lex agent-tool` and the store-write gate already produce.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(tag = "kind", rename_all = "snake_case")]
92pub enum AttestationKind {
93    /// `lex agent-tool --examples FILE` — body was run against
94    /// `{input, expected}` pairs.
95    Examples {
96        file_hash: ContentHash,
97        count: usize,
98    },
99    /// `lex spec check` or `lex agent-tool --spec FILE` — a
100    /// behavioral contract was checked against the body.
101    Spec {
102        spec_id: SpecId,
103        method: SpecMethod,
104        #[serde(default, skip_serializing_if = "Option::is_none")]
105        trials: Option<usize>,
106    },
107    /// `lex agent-tool --diff-body 'src'` — a second body was run on
108    /// the same inputs and the outputs compared.
109    DiffBody {
110        other_body_hash: ContentHash,
111        input_count: usize,
112    },
113    /// Emitted by the store-write gate (#130) on every accepted op.
114    /// The store can answer "the HEAD typechecks" as a queryable
115    /// fact rather than an implicit invariant.
116    TypeCheck,
117    /// Emitted by `lex audit --effect K` when no violations are
118    /// found. Useful as a trust signal that a stage was checked
119    /// against a specific effect-policy revision.
120    EffectAudit,
121    /// Emitted by `lex agent-tool` on a successful sandboxed run.
122    /// `effects` is the set the sandbox actually allowed; useful for
123    /// answering "did this code run under fs_write?" after the fact.
124    SandboxRun {
125        effects: BTreeSet<String>,
126    },
127    /// Human-issued override (lex-tea v3, #172). Records that a
128    /// human took an action that bypassed an automatic verdict
129    /// — e.g. activating a stage despite a `Spec::Failed` or
130    /// `TypeCheck::Failed` attestation. Subject to the same
131    /// trust trail as agent attestations: the audit fact lives
132    /// in the log alongside what it overrode.
133    ///
134    /// `actor` is the human's identifier (today: `LEX_TEA_USER`
135    /// env var or `--actor` flag; v3b adds session auth).
136    /// `target_attestation_id` points at the attestation being
137    /// overridden, when one exists; for unconditional pins
138    /// (e.g. activate-by-default) it can be `None`.
139    Override {
140        actor: String,
141        reason: String,
142        #[serde(default, skip_serializing_if = "Option::is_none")]
143        target_attestation_id: Option<AttestationId>,
144    },
145    /// `lex stage defer` (lex-tea v3b, #172). Records that a human
146    /// looked at the stage and chose to revisit it later. No state
147    /// change — purely an audit/triage signal so dashboards and AI
148    /// reviewers can see "this isn't abandoned, it's snoozed."
149    Defer {
150        actor: String,
151        reason: String,
152    },
153    /// `lex stage block` (lex-tea v3b, #172). Records that a human
154    /// has decided this stage should not activate. `lex stage pin`
155    /// and any other activation path consults the attestation log
156    /// and refuses while a Block is the latest decision for the
157    /// stage. Reversed by [`AttestationKind::Unblock`].
158    Block {
159        actor: String,
160        reason: String,
161    },
162    /// `lex stage unblock` (lex-tea v3b, #172). Counterpart to
163    /// [`AttestationKind::Block`]. The attestation log is append-
164    /// only, so we encode "block lifted" as a separate, later fact
165    /// rather than mutating the original block.
166    Unblock {
167        actor: String,
168        reason: String,
169    },
170}
171
172/// Walk a stage's attestations and return whether the latest
173/// Block/Unblock decision is currently a Block. Used by
174/// activation paths (e.g. `lex stage pin`) to refuse when a
175/// human has signalled the stage shouldn't ship.
176///
177/// "Latest" is defined by `timestamp`, which matches what users
178/// see in `lex stage <id> --attestations`. Ties go to Unblock so
179/// retrying an unblock right after a block (same wall-clock
180/// second) doesn't leave the stage stuck.
181pub 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/// Verification method for [`AttestationKind::Spec`]. Mirrors the
202/// tag the spec checker already uses — kept as a string so the
203/// vcs crate doesn't have to pull `spec-checker` in.
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(rename_all = "snake_case")]
206pub enum SpecMethod {
207    /// Exhaustive search; `trials` is unset.
208    Exhaustive,
209    /// Random sampling; `trials` carries the sample count.
210    Random,
211    /// Symbolic execution.
212    Symbolic,
213}
214
215/// Whether the verification succeeded. `Inconclusive` is its own
216/// state because some checkers (e.g. random-sampling spec checks
217/// over an unbounded input space) can pass within their budget
218/// without proving the contract holds in general.
219#[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/// Who produced this attestation. `tool` is the CLI / harness name
228/// (`"lex check"`, `"lex agent-tool"`, `"ci-runner@v3"`). `version`
229/// pins the tool revision so a regression in the producer is
230/// distinguishable from a regression in the code being verified.
231/// `model` is set when an LLM was the proximate producer — for
232/// `--spec`-style runs the harness is the producer; for `lex
233/// agent-tool` the model is, and we want both recorded.
234#[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/// Optional cost record. Excluded from the attestation hash so
243/// rerunning a verification on a different machine (different
244/// wall-clock, different token pricing) doesn't break dedup.
245#[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    /// USD cents (avoid floating-point in persisted form).
252    #[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/// Optional Ed25519 signature over the attestation hash. Verifying
259/// it is the consumer's job; the data layer just stores the bytes.
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261pub struct Signature {
262    /// Hex-encoded Ed25519 public key.
263    pub public_key: String,
264    /// Hex-encoded signature over the lowercase-hex `attestation_id`.
265    pub signature: String,
266}
267
268/// The persisted attestation. See module docs for what each field
269/// is, what's in the hash, and what isn't.
270#[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    /// Wall-clock seconds since epoch when this attestation was
284    /// produced. Excluded from `attestation_id` so the dedup
285    /// property holds across runs.
286    pub timestamp: u64,
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub signature: Option<Signature>,
289}
290
291impl Attestation {
292    /// Build an attestation against a stage, computing its
293    /// content-addressed id. `timestamp` defaults to the current
294    /// wall clock; pass to [`Attestation::with_timestamp`] in tests.
295    #[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    /// Build an attestation with a caller-controlled `timestamp`.
313    /// Used in tests to keep golden hashes stable.
314    #[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    /// Attach a signature. The signature is not part of the hash;
349    /// the same logical attestation produced by an unsigned harness
350    /// dedupes against a signed one. Callers who *want* signature
351    /// to be part of identity should hash signature into the
352    /// `produced_by.tool` string explicitly.
353    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/// Hashable shadow of [`Attestation`] omitting the fields we
379/// deliberately exclude from identity (`attestation_id`, `cost`,
380/// `timestamp`, `signature`). Lives only as a transient.
381#[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
393// ---- Persistence -------------------------------------------------
394
395/// Persistent log of [`Attestation`] records.
396///
397/// Mirrors [`crate::OpLog`] / [`crate::IntentLog`] in shape: one
398/// canonical-JSON file per attestation, atomic writes via tempfile +
399/// rename, idempotent on re-puts. Adds a `by-stage/` index so "list
400/// every attestation for stage X" is `O(attestations on X)` rather
401/// than `O(all attestations)`.
402pub 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    /// Persist an attestation. Idempotent on existing ids — content
420    /// addressing guarantees the same logical attestation produces
421    /// the same id, so re-putting is a no-op for the primary file.
422    /// The by-stage index is also re-written idempotently.
423    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        // Index entry: empty marker file. Reading the index is a
435        // directory listing; resolving each entry is a primary-file
436        // read by id.
437        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    /// Enumerate every attestation in the log. Walks
458    /// `<root>/attestations/*.json` directly — no per-stage index
459    /// — so cost is `O(total attestations)`. Used by `lex attest
460    /// filter` for CI / dashboard queries that span stages.
461    /// Order is not stable; callers that need stable ordering
462    /// should sort by `timestamp` or `attestation_id`.
463    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            // Skip the by-stage/ subdir and the .tmp staging files
472            // a crashed put might have left behind.
473            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            // A corrupt primary file shouldn't take down a filter
481            // query — log to stderr and skip.
482            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    /// Enumerate attestations for a given stage. Order is not
494    /// stable across calls (it follows directory iteration order).
495    /// Callers that need a stable ordering should sort by
496    /// `timestamp` or `attestation_id`.
497    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// ---- Tests --------------------------------------------------------
519
520#[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        // Dedup invariant: same stage, same kind, same producer,
548        // same outcome → same `attestation_id` regardless of
549        // wall-clock or cost.
550        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        // Critical: a Failed attestation must not collide with a
637        // Passed one for the same logical verification. Otherwise
638        // a flaky producer could overwrite the negative evidence
639        // by re-running and getting Passed.
640        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        // A signed and unsigned attestation of the same logical
678        // fact must dedupe. Otherwise late-signing a record would
679        // create two attestations that say the same thing.
680        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    /// Golden hash. If this changes, the canonical form has shifted
733    /// — every `AttestationId` in every existing store has changed
734    /// too. Update with care; same protective shape as the
735    /// `Operation` and `Intent` golden tests.
736    #[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    // ---- AttestationLog ----
760
761    #[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        // Cross-stage enumeration: `list_all` walks the primary
845        // directory regardless of stage, so a CI / dashboard query
846        // can filter across the whole log without iterating the
847        // by-stage index.
848        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        // Failure attestations are evidence too; they must not be
882        // overwritten by a later passing attestation. The hash
883        // distinction (tested above) plus the by-stage listing
884        // should keep both visible.
885        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        // TypeCheck/Override attestations don't gate activation —
931        // only Block/Unblock do.
932        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        // Tie-break favours Unblock so a hasty re-attempt at the
992        // same wall-clock second can't strand the stage.
993        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}