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    /// `lex run --trace` finalized a [`lex_trace::TraceTree`] (#246).
171    /// Links the trace blob to the stage that was the run's entry
172    /// point. The trace itself stays at
173    /// `<store>/traces/<run_id>/trace.json` (per
174    /// `docs/design/trace-vs-vcs.md`); this attestation is the
175    /// audit-side hook so `lex attest filter --kind trace` and
176    /// cross-store sync can reason about runs without copying the
177    /// trace bytes.
178    ///
179    /// `root_target` is the entry function's `SigId` — the call site
180    /// the user (or agent) typed on the command line. Distinct from
181    /// `Attestation::stage_id`, which records the *content-addressed*
182    /// stage the entry function resolved to; the same `root_target`
183    /// across multiple body edits surfaces as multiple
184    /// `(stage_id, root_target)` rows in the attestation log.
185    Trace {
186        run_id: TraceRunId,
187        root_target: super::operation::SigId,
188    },
189    /// Retroactive producer quarantine (#248). Declares "as of
190    /// `blocked_at`, attestations produced by `tool_id` are no
191    /// longer trusted; the branch advance gate must refuse to move
192    /// past any op whose attestations were produced by this tool
193    /// at or after `blocked_at`."
194    ///
195    /// Distinct from `policy.json`'s `blocked_producers` (#181):
196    /// that is a *forward-going* read-time tag for the activity
197    /// feed; this is a write-time gate on branch advance, retro-
198    /// active to a specific timestamp. The two compose cleanly —
199    /// `blocked_producers` filters what reviewers see; `ProducerBlock`
200    /// stops a compromised tool's history from being promoted past
201    /// a known-bad point.
202    ///
203    /// Stored at the attestation log under `stage_id == tool_id`
204    /// so the by-stage index doubles as a by-tool lookup for these
205    /// records — no schema break, no separate index needed.
206    /// `Attestation::stage_id` carries the `tool_id` for these
207    /// records; the variant payload duplicates it for clarity in
208    /// the JSON.
209    ProducerBlock {
210        tool_id: String,
211        reason: String,
212        blocked_at: u64,
213    },
214    /// Counterpart to [`AttestationKind::ProducerBlock`] (#248). The
215    /// attestation log is append-only, so revoking a producer block
216    /// is a separate, later fact rather than a delete. The branch
217    /// advance gate honors the most recent verdict for each
218    /// `tool_id` by timestamp.
219    ProducerUnblock {
220        tool_id: String,
221        reason: String,
222        unblocked_at: u64,
223    },
224}
225
226/// Walk a tool's `ProducerBlock` / `ProducerUnblock` attestations
227/// and return the active block timestamp, if any (#248). The
228/// attestation log is append-only, so a tool's state is whichever
229/// `ProducerBlock` / `ProducerUnblock` record has the latest
230/// `timestamp`. Returns `Some(blocked_at)` when the latest verdict
231/// is a `ProducerBlock` and `None` when the latest is an unblock or
232/// no verdict exists.
233///
234/// Ties: a `ProducerUnblock` at the same wall-clock second as a
235/// `ProducerBlock` wins, so re-running an unblock immediately after
236/// a block leaves the tool unblocked. Mirrors the tie-breaking in
237/// [`is_stage_blocked`].
238pub fn active_producer_block(
239    attestations: &[Attestation],
240    tool_id: &str,
241) -> Option<u64> {
242    let mut latest: Option<&Attestation> = None;
243    for a in attestations {
244        let matches = match &a.kind {
245            AttestationKind::ProducerBlock { tool_id: tid, .. }
246            | AttestationKind::ProducerUnblock { tool_id: tid, .. } => tid == tool_id,
247            _ => false,
248        };
249        if !matches {
250            continue;
251        }
252        match latest {
253            None => latest = Some(a),
254            Some(prev) if a.timestamp > prev.timestamp => latest = Some(a),
255            Some(prev) if a.timestamp == prev.timestamp
256                && matches!(a.kind, AttestationKind::ProducerUnblock { .. }) =>
257            {
258                latest = Some(a);
259            }
260            _ => {}
261        }
262    }
263    match latest.map(|a| &a.kind) {
264        Some(AttestationKind::ProducerBlock { blocked_at, .. }) => Some(*blocked_at),
265        _ => None,
266    }
267}
268
269/// Stable identifier for a [`lex_trace::TraceTree`]. Mirrors the
270/// `run_id` field on the trace JSON; kept as a `String` so this
271/// crate doesn't pull `lex-trace` in.
272pub type TraceRunId = String;
273
274/// Walk a stage's attestations and return whether the latest
275/// Block/Unblock decision is currently a Block. Used by
276/// activation paths (e.g. `lex stage pin`) to refuse when a
277/// human has signalled the stage shouldn't ship.
278///
279/// "Latest" is defined by `timestamp`, which matches what users
280/// see in `lex stage <id> --attestations`. Ties go to Unblock so
281/// retrying an unblock right after a block (same wall-clock
282/// second) doesn't leave the stage stuck.
283pub fn is_stage_blocked(attestations: &[Attestation]) -> bool {
284    let mut latest: Option<&Attestation> = None;
285    for a in attestations {
286        if !matches!(a.kind, AttestationKind::Block { .. } | AttestationKind::Unblock { .. }) {
287            continue;
288        }
289        match latest {
290            None => latest = Some(a),
291            Some(prev) if a.timestamp > prev.timestamp => latest = Some(a),
292            Some(prev) if a.timestamp == prev.timestamp
293                && matches!(a.kind, AttestationKind::Unblock { .. }) =>
294            {
295                latest = Some(a);
296            }
297            _ => {}
298        }
299    }
300    matches!(latest.map(|a| &a.kind), Some(AttestationKind::Block { .. }))
301}
302
303/// Verification method for [`AttestationKind::Spec`]. Mirrors the
304/// tag the spec checker already uses — kept as a string so the
305/// vcs crate doesn't have to pull `spec-checker` in.
306#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
307#[serde(rename_all = "snake_case")]
308pub enum SpecMethod {
309    /// Exhaustive search; `trials` is unset.
310    Exhaustive,
311    /// Random sampling; `trials` carries the sample count.
312    Random,
313    /// Symbolic execution.
314    Symbolic,
315}
316
317/// Whether the verification succeeded. `Inconclusive` is its own
318/// state because some checkers (e.g. random-sampling spec checks
319/// over an unbounded input space) can pass within their budget
320/// without proving the contract holds in general.
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
322#[serde(tag = "result", rename_all = "snake_case")]
323pub enum AttestationResult {
324    Passed,
325    Failed { detail: String },
326    Inconclusive { detail: String },
327}
328
329/// Who produced this attestation. `tool` is the CLI / harness name
330/// (`"lex check"`, `"lex agent-tool"`, `"ci-runner@v3"`). `version`
331/// pins the tool revision so a regression in the producer is
332/// distinguishable from a regression in the code being verified.
333/// `model` is set when an LLM was the proximate producer — for
334/// `--spec`-style runs the harness is the producer; for `lex
335/// agent-tool` the model is, and we want both recorded.
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
337pub struct ProducerDescriptor {
338    pub tool: String,
339    pub version: String,
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub model: Option<String>,
342}
343
344/// Optional cost record. Excluded from the attestation hash so
345/// rerunning a verification on a different machine (different
346/// wall-clock, different token pricing) doesn't break dedup.
347#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
348pub struct Cost {
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub tokens_in: Option<u64>,
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub tokens_out: Option<u64>,
353    /// USD cents (avoid floating-point in persisted form).
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub usd_cents: Option<u64>,
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub wall_time_ms: Option<u64>,
358}
359
360/// Optional Ed25519 signature over the attestation hash. Verifying
361/// it is the consumer's job; the data layer just stores the bytes.
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363pub struct Signature {
364    /// Hex-encoded Ed25519 public key.
365    pub public_key: String,
366    /// Hex-encoded signature over the lowercase-hex `attestation_id`.
367    pub signature: String,
368}
369
370/// The persisted attestation. See module docs for what each field
371/// is, what's in the hash, and what isn't.
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
373pub struct Attestation {
374    pub attestation_id: AttestationId,
375    pub stage_id: StageId,
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub op_id: Option<OpId>,
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub intent_id: Option<IntentId>,
380    pub kind: AttestationKind,
381    pub result: AttestationResult,
382    pub produced_by: ProducerDescriptor,
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub cost: Option<Cost>,
385    /// Wall-clock seconds since epoch when this attestation was
386    /// produced. Excluded from `attestation_id` so the dedup
387    /// property holds across runs.
388    pub timestamp: u64,
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub signature: Option<Signature>,
391}
392
393impl Attestation {
394    /// Build an attestation against a stage, computing its
395    /// content-addressed id. `timestamp` defaults to the current
396    /// wall clock; pass to [`Attestation::with_timestamp`] in tests.
397    #[allow(clippy::too_many_arguments)]
398    pub fn new(
399        stage_id: impl Into<StageId>,
400        op_id: Option<OpId>,
401        intent_id: Option<IntentId>,
402        kind: AttestationKind,
403        result: AttestationResult,
404        produced_by: ProducerDescriptor,
405        cost: Option<Cost>,
406    ) -> Self {
407        let now = SystemTime::now()
408            .duration_since(UNIX_EPOCH)
409            .map(|d| d.as_secs())
410            .unwrap_or(0);
411        Self::with_timestamp(stage_id, op_id, intent_id, kind, result, produced_by, cost, now)
412    }
413
414    /// Build an attestation with a caller-controlled `timestamp`.
415    /// Used in tests to keep golden hashes stable.
416    #[allow(clippy::too_many_arguments)]
417    pub fn with_timestamp(
418        stage_id: impl Into<StageId>,
419        op_id: Option<OpId>,
420        intent_id: Option<IntentId>,
421        kind: AttestationKind,
422        result: AttestationResult,
423        produced_by: ProducerDescriptor,
424        cost: Option<Cost>,
425        timestamp: u64,
426    ) -> Self {
427        let stage_id = stage_id.into();
428        let attestation_id = compute_attestation_id(
429            &stage_id,
430            op_id.as_deref(),
431            intent_id.as_deref(),
432            &kind,
433            &result,
434            &produced_by,
435        );
436        Self {
437            attestation_id,
438            stage_id,
439            op_id,
440            intent_id,
441            kind,
442            result,
443            produced_by,
444            cost,
445            timestamp,
446            signature: None,
447        }
448    }
449
450    /// Attach a signature. The signature is not part of the hash;
451    /// the same logical attestation produced by an unsigned harness
452    /// dedupes against a signed one. Callers who *want* signature
453    /// to be part of identity should hash signature into the
454    /// `produced_by.tool` string explicitly.
455    pub fn with_signature(mut self, signature: Signature) -> Self {
456        self.signature = Some(signature);
457        self
458    }
459}
460
461fn compute_attestation_id(
462    stage_id: &str,
463    op_id: Option<&str>,
464    intent_id: Option<&str>,
465    kind: &AttestationKind,
466    result: &AttestationResult,
467    produced_by: &ProducerDescriptor,
468) -> AttestationId {
469    let view = CanonicalAttestationView {
470        stage_id,
471        op_id,
472        intent_id,
473        kind,
474        result,
475        produced_by,
476    };
477    canonical::hash(&view)
478}
479
480/// Hashable shadow of [`Attestation`] omitting the fields we
481/// deliberately exclude from identity (`attestation_id`, `cost`,
482/// `timestamp`, `signature`). Lives only as a transient.
483#[derive(Serialize)]
484struct CanonicalAttestationView<'a> {
485    stage_id: &'a str,
486    #[serde(skip_serializing_if = "Option::is_none")]
487    op_id: Option<&'a str>,
488    #[serde(skip_serializing_if = "Option::is_none")]
489    intent_id: Option<&'a str>,
490    kind: &'a AttestationKind,
491    result: &'a AttestationResult,
492    produced_by: &'a ProducerDescriptor,
493}
494
495// ---- Persistence -------------------------------------------------
496
497/// Persistent log of [`Attestation`] records.
498///
499/// Mirrors [`crate::OpLog`] / [`crate::IntentLog`] in shape: one
500/// canonical-JSON file per attestation, atomic writes via tempfile +
501/// rename, idempotent on re-puts. Maintains two secondary indices
502/// for cheap reverse lookups:
503///
504/// * `by-stage/<StageId>/<AttestationId>` — every attestation,
505///   indexed by the stage it records evidence for.
506/// * `by-run/<TraceRunId>/<AttestationId>` (#246) — only
507///   `AttestationKind::Trace` entries are indexed here, so
508///   `list_for_run` is `O(traces of that run)` rather than scanning
509///   the whole log.
510pub struct AttestationLog {
511    dir: PathBuf,
512    by_stage: PathBuf,
513    by_run: PathBuf,
514}
515
516impl AttestationLog {
517    pub fn open(root: &Path) -> io::Result<Self> {
518        let dir = root.join("attestations");
519        let by_stage = dir.join("by-stage");
520        let by_run = dir.join("by-run");
521        fs::create_dir_all(&by_stage)?;
522        fs::create_dir_all(&by_run)?;
523        Ok(Self { dir, by_stage, by_run })
524    }
525
526    fn primary_path(&self, id: &AttestationId) -> PathBuf {
527        self.dir.join(format!("{id}.json"))
528    }
529
530    /// Persist an attestation. Idempotent on existing ids — content
531    /// addressing guarantees the same logical attestation produces
532    /// the same id, so re-putting is a no-op for the primary file.
533    /// The by-stage index is also re-written idempotently.
534    pub fn put(&self, attestation: &Attestation) -> io::Result<()> {
535        let primary = self.primary_path(&attestation.attestation_id);
536        if !primary.exists() {
537            let bytes = serde_json::to_vec(attestation)
538                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
539            let tmp = primary.with_extension("json.tmp");
540            let mut f = fs::File::create(&tmp)?;
541            f.write_all(&bytes)?;
542            f.sync_all()?;
543            fs::rename(&tmp, &primary)?;
544        }
545        // Index entry: empty marker file. Reading the index is a
546        // directory listing; resolving each entry is a primary-file
547        // read by id.
548        let stage_dir = self.by_stage.join(&attestation.stage_id);
549        fs::create_dir_all(&stage_dir)?;
550        let idx = stage_dir.join(&attestation.attestation_id);
551        if !idx.exists() {
552            fs::File::create(&idx)?;
553        }
554        // by-run secondary index for Trace attestations (#246) —
555        // only the variants that carry a `run_id` are indexed; every
556        // other kind skips this directory entirely.
557        if let AttestationKind::Trace { run_id, .. } = &attestation.kind {
558            let run_dir = self.by_run.join(run_id);
559            fs::create_dir_all(&run_dir)?;
560            let idx = run_dir.join(&attestation.attestation_id);
561            if !idx.exists() {
562                fs::File::create(&idx)?;
563            }
564        }
565        Ok(())
566    }
567
568    /// Remove an attestation from the log along with both index
569    /// entries (#258). Idempotent on missing files.
570    ///
571    /// **Not** part of the day-to-day API — the attestation log is
572    /// append-only by design (#132). The only legitimate caller is
573    /// the migration tool, which supervises a destructive,
574    /// `--confirm`-gated batch.
575    pub fn delete(&self, attestation: &Attestation) -> io::Result<()> {
576        let primary = self.primary_path(&attestation.attestation_id);
577        match fs::remove_file(&primary) {
578            Ok(()) | Err(_) => {} // best-effort; missing is fine
579        }
580        let stage_idx = self.by_stage
581            .join(&attestation.stage_id)
582            .join(&attestation.attestation_id);
583        let _ = fs::remove_file(&stage_idx);
584        if let AttestationKind::Trace { run_id, .. } = &attestation.kind {
585            let run_idx = self.by_run.join(run_id).join(&attestation.attestation_id);
586            let _ = fs::remove_file(&run_idx);
587        }
588        Ok(())
589    }
590
591    pub fn get(&self, id: &AttestationId) -> io::Result<Option<Attestation>> {
592        let path = self.primary_path(id);
593        if !path.exists() {
594            return Ok(None);
595        }
596        let bytes = fs::read(&path)?;
597        let attestation: Attestation = serde_json::from_slice(&bytes)
598            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
599        Ok(Some(attestation))
600    }
601
602    /// Enumerate every attestation in the log. Walks
603    /// `<root>/attestations/*.json` directly — no per-stage index
604    /// — so cost is `O(total attestations)`. Used by `lex attest
605    /// filter` for CI / dashboard queries that span stages.
606    /// Order is not stable; callers that need stable ordering
607    /// should sort by `timestamp` or `attestation_id`.
608    pub fn list_all(&self) -> io::Result<Vec<Attestation>> {
609        let mut out = Vec::new();
610        if !self.dir.exists() {
611            return Ok(out);
612        }
613        for entry in fs::read_dir(&self.dir)? {
614            let entry = entry?;
615            let p = entry.path();
616            // Skip the by-stage/ subdir and the .tmp staging files
617            // a crashed put might have left behind.
618            if p.is_dir() {
619                continue;
620            }
621            if p.extension().is_none_or(|e| e != "json") {
622                continue;
623            }
624            let bytes = fs::read(&p)?;
625            // A corrupt primary file shouldn't take down a filter
626            // query — log to stderr and skip.
627            match serde_json::from_slice::<Attestation>(&bytes) {
628                Ok(att) => out.push(att),
629                Err(e) => eprintln!(
630                    "warning: skipping unreadable attestation {}: {e}",
631                    p.display()
632                ),
633            }
634        }
635        Ok(out)
636    }
637
638    /// Enumerate attestations for a given stage. Order is not
639    /// stable across calls (it follows directory iteration order).
640    /// Callers that need a stable ordering should sort by
641    /// `timestamp` or `attestation_id`.
642    pub fn list_for_stage(&self, stage_id: &StageId) -> io::Result<Vec<Attestation>> {
643        let stage_dir = self.by_stage.join(stage_id);
644        if !stage_dir.exists() {
645            return Ok(Vec::new());
646        }
647        let mut out = Vec::new();
648        for entry in fs::read_dir(&stage_dir)? {
649            let entry = entry?;
650            let id = match entry.file_name().into_string() {
651                Ok(s) => s,
652                Err(_) => continue,
653            };
654            if let Some(att) = self.get(&id)? {
655                out.push(att);
656            }
657        }
658        Ok(out)
659    }
660
661    /// Enumerate `AttestationKind::Trace` entries for a given
662    /// `run_id` (#246). Walks the `by-run/<run_id>/` directory; cost
663    /// is `O(trace attestations for that run)`, typically 1.
664    /// Returns an empty vec if the run has no Trace attestations.
665    /// Order is not stable.
666    pub fn list_for_run(&self, run_id: &TraceRunId) -> io::Result<Vec<Attestation>> {
667        let run_dir = self.by_run.join(run_id);
668        if !run_dir.exists() {
669            return Ok(Vec::new());
670        }
671        let mut out = Vec::new();
672        for entry in fs::read_dir(&run_dir)? {
673            let entry = entry?;
674            let id = match entry.file_name().into_string() {
675                Ok(s) => s,
676                Err(_) => continue,
677            };
678            if let Some(att) = self.get(&id)? {
679                out.push(att);
680            }
681        }
682        Ok(out)
683    }
684}
685
686// ---- Tests --------------------------------------------------------
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691
692    fn ci_runner() -> ProducerDescriptor {
693        ProducerDescriptor {
694            tool: "lex check".into(),
695            version: "0.1.0".into(),
696            model: None,
697        }
698    }
699
700    fn typecheck_passed() -> Attestation {
701        Attestation::with_timestamp(
702            "stage-abc",
703            Some("op-123".into()),
704            None,
705            AttestationKind::TypeCheck,
706            AttestationResult::Passed,
707            ci_runner(),
708            None,
709            1000,
710        )
711    }
712
713    #[test]
714    fn same_logical_verification_hashes_equal() {
715        // Dedup invariant: same stage, same kind, same producer,
716        // same outcome → same `attestation_id` regardless of
717        // wall-clock or cost.
718        let a = typecheck_passed();
719        let b = Attestation::with_timestamp(
720            "stage-abc",
721            Some("op-123".into()),
722            None,
723            AttestationKind::TypeCheck,
724            AttestationResult::Passed,
725            ci_runner(),
726            Some(Cost {
727                tokens_in: Some(0),
728                tokens_out: Some(0),
729                usd_cents: Some(0),
730                wall_time_ms: Some(42),
731            }),
732            99999,
733        );
734        assert_eq!(a.attestation_id, b.attestation_id);
735    }
736
737    #[test]
738    fn different_stages_hash_differently() {
739        let a = typecheck_passed();
740        let b = Attestation::with_timestamp(
741            "stage-XYZ",
742            Some("op-123".into()),
743            None,
744            AttestationKind::TypeCheck,
745            AttestationResult::Passed,
746            ci_runner(),
747            None,
748            1000,
749        );
750        assert_ne!(a.attestation_id, b.attestation_id);
751    }
752
753    #[test]
754    fn different_op_ids_hash_differently() {
755        let a = typecheck_passed();
756        let b = Attestation::with_timestamp(
757            "stage-abc",
758            Some("op-XYZ".into()),
759            None,
760            AttestationKind::TypeCheck,
761            AttestationResult::Passed,
762            ci_runner(),
763            None,
764            1000,
765        );
766        assert_ne!(a.attestation_id, b.attestation_id);
767    }
768
769    #[test]
770    fn different_intents_hash_differently() {
771        let a = Attestation::with_timestamp(
772            "stage-abc", None,
773            Some("intent-A".into()),
774            AttestationKind::TypeCheck, AttestationResult::Passed,
775            ci_runner(), None, 1000,
776        );
777        let b = Attestation::with_timestamp(
778            "stage-abc", None,
779            Some("intent-B".into()),
780            AttestationKind::TypeCheck, AttestationResult::Passed,
781            ci_runner(), None, 1000,
782        );
783        assert_ne!(a.attestation_id, b.attestation_id);
784    }
785
786    #[test]
787    fn different_kinds_hash_differently() {
788        let a = typecheck_passed();
789        let b = Attestation::with_timestamp(
790            "stage-abc",
791            Some("op-123".into()),
792            None,
793            AttestationKind::EffectAudit,
794            AttestationResult::Passed,
795            ci_runner(),
796            None,
797            1000,
798        );
799        assert_ne!(a.attestation_id, b.attestation_id);
800    }
801
802    #[test]
803    fn passed_vs_failed_hash_differently() {
804        // Critical: a Failed attestation must not collide with a
805        // Passed one for the same logical verification. Otherwise
806        // a flaky producer could overwrite the negative evidence
807        // by re-running and getting Passed.
808        let a = typecheck_passed();
809        let b = Attestation::with_timestamp(
810            "stage-abc",
811            Some("op-123".into()),
812            None,
813            AttestationKind::TypeCheck,
814            AttestationResult::Failed { detail: "arity mismatch".into() },
815            ci_runner(),
816            None,
817            1000,
818        );
819        assert_ne!(a.attestation_id, b.attestation_id);
820    }
821
822    #[test]
823    fn different_producers_hash_differently() {
824        let a = typecheck_passed();
825        let mut other = ci_runner();
826        other.tool = "third-party-runner".into();
827        let b = Attestation::with_timestamp(
828            "stage-abc",
829            Some("op-123".into()),
830            None,
831            AttestationKind::TypeCheck,
832            AttestationResult::Passed,
833            other,
834            None,
835            1000,
836        );
837        assert_ne!(
838            a.attestation_id, b.attestation_id,
839            "an attestation from a different producer is a different fact",
840        );
841    }
842
843    #[test]
844    fn signature_is_excluded_from_hash() {
845        // A signed and unsigned attestation of the same logical
846        // fact must dedupe. Otherwise late-signing a record would
847        // create two attestations that say the same thing.
848        let a = typecheck_passed();
849        let b = typecheck_passed().with_signature(Signature {
850            public_key: "ed25519:fffe".into(),
851            signature: "0xabcd".into(),
852        });
853        assert_eq!(a.attestation_id, b.attestation_id);
854    }
855
856    #[test]
857    fn attestation_id_is_64_char_lowercase_hex() {
858        let a = typecheck_passed();
859        assert_eq!(a.attestation_id.len(), 64);
860        assert!(a
861            .attestation_id
862            .chars()
863            .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)));
864    }
865
866    #[test]
867    fn round_trip_through_serde_json() {
868        let a = Attestation::with_timestamp(
869            "stage-abc",
870            Some("op-123".into()),
871            Some("intent-A".into()),
872            AttestationKind::Spec {
873                spec_id: "clamp.spec".into(),
874                method: SpecMethod::Random,
875                trials: Some(1000),
876            },
877            AttestationResult::Passed,
878            ProducerDescriptor {
879                tool: "lex agent-tool".into(),
880                version: "0.1.0".into(),
881                model: Some("claude-opus-4-7".into()),
882            },
883            Some(Cost {
884                tokens_in: Some(1234),
885                tokens_out: Some(567),
886                usd_cents: Some(2),
887                wall_time_ms: Some(3400),
888            }),
889            99,
890        )
891        .with_signature(Signature {
892            public_key: "ed25519:abc".into(),
893            signature: "0x1234".into(),
894        });
895        let json = serde_json::to_string(&a).unwrap();
896        let back: Attestation = serde_json::from_str(&json).unwrap();
897        assert_eq!(a, back);
898    }
899
900    /// Golden hash. If this changes, the canonical form has shifted
901    /// — every `AttestationId` in every existing store has changed
902    /// too. Update with care; same protective shape as the
903    /// `Operation` and `Intent` golden tests.
904    #[test]
905    fn canonical_form_is_stable_for_a_known_input() {
906        let a = Attestation::with_timestamp(
907            "stage-abc",
908            Some("op-123".into()),
909            None,
910            AttestationKind::TypeCheck,
911            AttestationResult::Passed,
912            ProducerDescriptor {
913                tool: "lex check".into(),
914                version: "0.1.0".into(),
915                model: None,
916            },
917            None,
918            0,
919        );
920        assert_eq!(
921            a.attestation_id,
922            "a4ef921f7bb0db70779c5b698cda1744d49165a4a56aa8414bdbafc85bcbc16b",
923            "canonical-form regression: the AttestationId for a known input changed",
924        );
925    }
926
927    // ---- AttestationLog ----
928
929    #[test]
930    fn log_round_trips_through_disk() {
931        let tmp = tempfile::tempdir().unwrap();
932        let log = AttestationLog::open(tmp.path()).unwrap();
933        let a = typecheck_passed();
934        log.put(&a).unwrap();
935        let read_back = log.get(&a.attestation_id).unwrap().unwrap();
936        assert_eq!(a, read_back);
937    }
938
939    #[test]
940    fn log_get_unknown_returns_none() {
941        let tmp = tempfile::tempdir().unwrap();
942        let log = AttestationLog::open(tmp.path()).unwrap();
943        assert!(log
944            .get(&"nonexistent".to_string())
945            .unwrap()
946            .is_none());
947    }
948
949    #[test]
950    fn log_put_is_idempotent() {
951        let tmp = tempfile::tempdir().unwrap();
952        let log = AttestationLog::open(tmp.path()).unwrap();
953        let a = typecheck_passed();
954        log.put(&a).unwrap();
955        log.put(&a).unwrap();
956        let read_back = log.get(&a.attestation_id).unwrap().unwrap();
957        assert_eq!(a, read_back);
958    }
959
960    #[test]
961    fn list_for_stage_returns_only_that_stage() {
962        let tmp = tempfile::tempdir().unwrap();
963        let log = AttestationLog::open(tmp.path()).unwrap();
964
965        let on_abc_1 = typecheck_passed();
966        let on_abc_2 = Attestation::with_timestamp(
967            "stage-abc",
968            Some("op-123".into()),
969            None,
970            AttestationKind::EffectAudit,
971            AttestationResult::Passed,
972            ci_runner(),
973            None,
974            2000,
975        );
976        let on_xyz = Attestation::with_timestamp(
977            "stage-xyz",
978            Some("op-456".into()),
979            None,
980            AttestationKind::TypeCheck,
981            AttestationResult::Passed,
982            ci_runner(),
983            None,
984            1000,
985        );
986
987        log.put(&on_abc_1).unwrap();
988        log.put(&on_abc_2).unwrap();
989        log.put(&on_xyz).unwrap();
990
991        let mut on_abc = log.list_for_stage(&"stage-abc".to_string()).unwrap();
992        on_abc.sort_by_key(|a| a.timestamp);
993        assert_eq!(on_abc.len(), 2);
994        assert_eq!(on_abc[0], on_abc_1);
995        assert_eq!(on_abc[1], on_abc_2);
996
997        let on_xyz_listed = log.list_for_stage(&"stage-xyz".to_string()).unwrap();
998        assert_eq!(on_xyz_listed.len(), 1);
999        assert_eq!(on_xyz_listed[0], on_xyz);
1000    }
1001
1002    #[test]
1003    fn list_for_unknown_stage_is_empty() {
1004        let tmp = tempfile::tempdir().unwrap();
1005        let log = AttestationLog::open(tmp.path()).unwrap();
1006        let v = log.list_for_stage(&"never-attested".to_string()).unwrap();
1007        assert!(v.is_empty());
1008    }
1009
1010    #[test]
1011    fn list_all_returns_every_persisted_attestation() {
1012        // Cross-stage enumeration: `list_all` walks the primary
1013        // directory regardless of stage, so a CI / dashboard query
1014        // can filter across the whole log without iterating the
1015        // by-stage index.
1016        let tmp = tempfile::tempdir().unwrap();
1017        let log = AttestationLog::open(tmp.path()).unwrap();
1018        let on_abc = typecheck_passed();
1019        let on_xyz = Attestation::with_timestamp(
1020            "stage-xyz",
1021            Some("op-456".into()),
1022            None,
1023            AttestationKind::TypeCheck,
1024            AttestationResult::Passed,
1025            ci_runner(),
1026            None,
1027            2000,
1028        );
1029        log.put(&on_abc).unwrap();
1030        log.put(&on_xyz).unwrap();
1031        let mut all = log.list_all().unwrap();
1032        all.sort_by_key(|a| a.attestation_id.clone());
1033        assert_eq!(all.len(), 2);
1034        let ids: BTreeSet<_> = all.iter().map(|a| a.attestation_id.clone()).collect();
1035        assert!(ids.contains(&on_abc.attestation_id));
1036        assert!(ids.contains(&on_xyz.attestation_id));
1037    }
1038
1039    #[test]
1040    fn list_all_on_empty_log_is_empty() {
1041        let tmp = tempfile::tempdir().unwrap();
1042        let log = AttestationLog::open(tmp.path()).unwrap();
1043        let v = log.list_all().unwrap();
1044        assert!(v.is_empty());
1045    }
1046
1047    #[test]
1048    fn passed_and_failed_for_same_stage_both_persist() {
1049        // Failure attestations are evidence too; they must not be
1050        // overwritten by a later passing attestation. The hash
1051        // distinction (tested above) plus the by-stage listing
1052        // should keep both visible.
1053        let tmp = tempfile::tempdir().unwrap();
1054        let log = AttestationLog::open(tmp.path()).unwrap();
1055
1056        let passed = typecheck_passed();
1057        let failed = Attestation::with_timestamp(
1058            "stage-abc",
1059            Some("op-123".into()),
1060            None,
1061            AttestationKind::TypeCheck,
1062            AttestationResult::Failed { detail: "arity mismatch".into() },
1063            ci_runner(),
1064            None,
1065            500,
1066        );
1067
1068        log.put(&failed).unwrap();
1069        log.put(&passed).unwrap();
1070
1071        let listing = log.list_for_stage(&"stage-abc".to_string()).unwrap();
1072        assert_eq!(listing.len(), 2, "both passing and failing evidence must persist");
1073    }
1074
1075    fn human_decision(kind: AttestationKind, ts: u64) -> Attestation {
1076        Attestation::with_timestamp(
1077            "stage-abc",
1078            None, None,
1079            kind,
1080            AttestationResult::Passed,
1081            ProducerDescriptor {
1082                tool: "lex stage".into(),
1083                version: "0.1.0".into(),
1084                model: None,
1085            },
1086            None,
1087            ts,
1088        )
1089    }
1090
1091    #[test]
1092    fn is_stage_blocked_empty_log_is_false() {
1093        assert!(!is_stage_blocked(&[]));
1094    }
1095
1096    #[test]
1097    fn is_stage_blocked_only_unrelated_attestations() {
1098        // TypeCheck/Override attestations don't gate activation —
1099        // only Block/Unblock do.
1100        let attestations = vec![
1101            typecheck_passed(),
1102            human_decision(
1103                AttestationKind::Override {
1104                    actor: "alice".into(),
1105                    reason: "ship".into(),
1106                    target_attestation_id: None,
1107                },
1108                500,
1109            ),
1110        ];
1111        assert!(!is_stage_blocked(&attestations));
1112    }
1113
1114    #[test]
1115    fn is_stage_blocked_block_alone_blocks() {
1116        let attestations = vec![human_decision(
1117            AttestationKind::Block { actor: "alice".into(), reason: "x".into() },
1118            500,
1119        )];
1120        assert!(is_stage_blocked(&attestations));
1121    }
1122
1123    #[test]
1124    fn is_stage_blocked_later_unblock_clears_block() {
1125        let attestations = vec![
1126            human_decision(
1127                AttestationKind::Block { actor: "alice".into(), reason: "x".into() },
1128                500,
1129            ),
1130            human_decision(
1131                AttestationKind::Unblock { actor: "alice".into(), reason: "ok".into() },
1132                600,
1133            ),
1134        ];
1135        assert!(!is_stage_blocked(&attestations));
1136    }
1137
1138    #[test]
1139    fn is_stage_blocked_later_block_re_blocks() {
1140        let attestations = vec![
1141            human_decision(
1142                AttestationKind::Block { actor: "a".into(), reason: "1".into() },
1143                500,
1144            ),
1145            human_decision(
1146                AttestationKind::Unblock { actor: "a".into(), reason: "2".into() },
1147                600,
1148            ),
1149            human_decision(
1150                AttestationKind::Block { actor: "a".into(), reason: "3".into() },
1151                700,
1152            ),
1153        ];
1154        assert!(is_stage_blocked(&attestations));
1155    }
1156
1157    #[test]
1158    fn is_stage_blocked_unblock_wins_at_same_timestamp() {
1159        // Tie-break favours Unblock so a hasty re-attempt at the
1160        // same wall-clock second can't strand the stage.
1161        let attestations = vec![
1162            human_decision(
1163                AttestationKind::Block { actor: "a".into(), reason: "1".into() },
1164                500,
1165            ),
1166            human_decision(
1167                AttestationKind::Unblock { actor: "a".into(), reason: "2".into() },
1168                500,
1169            ),
1170        ];
1171        assert!(!is_stage_blocked(&attestations));
1172    }
1173}