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