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/// The decision a [`AttestationKind::Review`] carries (#836 G4).
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum ReviewVerdict {
92    /// This candidate should win / this stage is good to promote.
93    Approve,
94    /// This candidate should not win / this stage should not advance.
95    Reject,
96    /// Not a veto, but changes are wanted before it advances.
97    RequestChanges,
98}
99
100/// What was verified. The variants mirror the verdict surfaces
101/// `lex agent-tool` and the store-write gate already produce.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(tag = "kind", rename_all = "snake_case")]
104pub enum AttestationKind {
105    /// `lex agent-tool --examples FILE` — body was run against
106    /// `{input, expected}` pairs.
107    Examples {
108        file_hash: ContentHash,
109        count: usize,
110    },
111    /// `lex spec check` or `lex agent-tool --spec FILE` — a
112    /// behavioral contract was checked against the body.
113    Spec {
114        spec_id: SpecId,
115        method: SpecMethod,
116        #[serde(default, skip_serializing_if = "Option::is_none")]
117        trials: Option<usize>,
118    },
119    /// `lex agent-tool --diff-body 'src'` — a second body was run on
120    /// the same inputs and the outputs compared.
121    DiffBody {
122        other_body_hash: ContentHash,
123        input_count: usize,
124    },
125    /// A structured review verdict on a stage — closes the
126    /// Candidate->Promote gap (#836 G4): the path from a `Candidate`
127    /// to a `Promote` otherwise carries no recorded reason. An agent
128    /// or human records why a candidate should (or shouldn't) win the
129    /// bake-off, addressable at the candidate's stage like any other
130    /// attestation. `Approve`/`Reject`/`RequestChanges` also map onto
131    /// the `result` field (Passed/Failed/Inconclusive) so existing
132    /// result-based tooling reads it sensibly.
133    Review {
134        reviewer: String,
135        verdict: ReviewVerdict,
136        #[serde(default, skip_serializing_if = "Option::is_none")]
137        notes: Option<String>,
138    },
139    /// A typed issue's declared acceptance was evaluated at a head (#949
140    /// phase 2) — the definition of done as code. Keyed by the *issue id*
141    /// (as the attestation's stage subject) so "is issue X done?" is a
142    /// lookup, not a judgment. `result` carries the verdict: `Passed` when
143    /// the oracle holds, `Failed` with what didn't, `Inconclusive` when the
144    /// shape isn't machine-evaluable here (free-form is human-closed; the
145    /// metric/evidence oracles land later). Never recorded as passed for a
146    /// shape the gate could not actually check.
147    IssueVerified {
148        issue_id: String,
149        shape: String,
150    },
151    /// Emitted by the store-write gate (#130) on every accepted op.
152    /// The store can answer "the HEAD typechecks" as a queryable
153    /// fact rather than an implicit invariant.
154    TypeCheck,
155    /// Emitted by `lex audit --effect K` when no violations are
156    /// found. Useful as a trust signal that a stage was checked
157    /// against a specific effect-policy revision.
158    EffectAudit,
159    /// Emitted by `lex agent-tool` on a successful sandboxed run.
160    /// `effects` is the set the sandbox actually allowed; useful for
161    /// answering "did this code run under fs_write?" after the fact.
162    SandboxRun {
163        effects: BTreeSet<String>,
164    },
165    /// Human-issued override (lex-tea v3, #172). Records that a
166    /// human took an action that bypassed an automatic verdict
167    /// — e.g. activating a stage despite a `Spec::Failed` or
168    /// `TypeCheck::Failed` attestation. Subject to the same
169    /// trust trail as agent attestations: the audit fact lives
170    /// in the log alongside what it overrode.
171    ///
172    /// `actor` is the human's identifier (today: `LEX_TEA_USER`
173    /// env var or `--actor` flag; v3b adds session auth).
174    /// `target_attestation_id` points at the attestation being
175    /// overridden, when one exists; for unconditional pins
176    /// (e.g. activate-by-default) it can be `None`.
177    Override {
178        actor: String,
179        reason: String,
180        #[serde(default, skip_serializing_if = "Option::is_none")]
181        target_attestation_id: Option<AttestationId>,
182    },
183    /// `lex stage defer` (lex-tea v3b, #172). Records that a human
184    /// looked at the stage and chose to revisit it later. No state
185    /// change — purely an audit/triage signal so dashboards and AI
186    /// reviewers can see "this isn't abandoned, it's snoozed."
187    Defer {
188        actor: String,
189        reason: String,
190    },
191    /// `lex stage block` (lex-tea v3b, #172). Records that a human
192    /// has decided this stage should not activate. `lex stage pin`
193    /// and any other activation path consults the attestation log
194    /// and refuses while a Block is the latest decision for the
195    /// stage. Reversed by [`AttestationKind::Unblock`].
196    Block {
197        actor: String,
198        reason: String,
199    },
200    /// `lex stage unblock` (lex-tea v3b, #172). Counterpart to
201    /// [`AttestationKind::Block`]. The attestation log is append-
202    /// only, so we encode "block lifted" as a separate, later fact
203    /// rather than mutating the original block.
204    Unblock {
205        actor: String,
206        reason: String,
207    },
208    /// `lex run --trace` finalized a [`lex_trace::TraceTree`] (#246).
209    /// Links the trace blob to the stage that was the run's entry
210    /// point. The trace itself stays at
211    /// `<store>/traces/<run_id>/trace.json` (per
212    /// `docs/design/trace-vs-vcs.md`); this attestation is the
213    /// audit-side hook so `lex attest filter --kind trace` and
214    /// cross-store sync can reason about runs without copying the
215    /// trace bytes.
216    ///
217    /// `root_target` is the entry function's `SigId` — the call site
218    /// the user (or agent) typed on the command line. Distinct from
219    /// `Attestation::stage_id`, which records the *content-addressed*
220    /// stage the entry function resolved to; the same `root_target`
221    /// across multiple body edits surfaces as multiple
222    /// `(stage_id, root_target)` rows in the attestation log.
223    Trace {
224        run_id: TraceRunId,
225        root_target: super::operation::SigId,
226    },
227    /// Retroactive producer quarantine (#248). Declares "as of
228    /// `blocked_at`, attestations produced by `tool_id` are no
229    /// longer trusted; the branch advance gate must refuse to move
230    /// past any op whose attestations were produced by this tool
231    /// at or after `blocked_at`."
232    ///
233    /// Distinct from `policy.json`'s `blocked_producers` (#181):
234    /// that is a *forward-going* read-time tag for the activity
235    /// feed; this is a write-time gate on branch advance, retro-
236    /// active to a specific timestamp. The two compose cleanly —
237    /// `blocked_producers` filters what reviewers see; `ProducerBlock`
238    /// stops a compromised tool's history from being promoted past
239    /// a known-bad point.
240    ///
241    /// Stored at the attestation log under `stage_id == tool_id`
242    /// so the by-stage index doubles as a by-tool lookup for these
243    /// records — no schema break, no separate index needed.
244    /// `Attestation::stage_id` carries the `tool_id` for these
245    /// records; the variant payload duplicates it for clarity in
246    /// the JSON.
247    ProducerBlock {
248        tool_id: String,
249        reason: String,
250        blocked_at: u64,
251    },
252    /// Counterpart to [`AttestationKind::ProducerBlock`] (#248). The
253    /// attestation log is append-only, so revoking a producer block
254    /// is a separate, later fact rather than a delete. The branch
255    /// advance gate honors the most recent verdict for each
256    /// `tool_id` by timestamp.
257    ProducerUnblock {
258        tool_id: String,
259        reason: String,
260        unblocked_at: u64,
261    },
262    /// Auto-emitted by `Store::apply_operation_checked` when an op
263    /// is rejected for `TypeError` (#281). Records the failed op's
264    /// id, the structured type-error envelope, and an optional
265    /// suggested-transform payload (left empty by the gate; the
266    /// `lex repair --apply` flow populates it via LLM call). The
267    /// hint is attached to the candidate stage that didn't
268    /// typecheck, so `lex_vcs::AttestationLog::list_for_stage`
269    /// surfaces it on the next read.
270    ///
271    /// Schema: `errors` and `suggested_transform` are
272    /// `serde_json::Value` to keep this crate independent of
273    /// `lex-types::TypeError` (which lives downstream) and to let
274    /// the slice-2 LLM integration ship without a schema bump.
275    RepairHint {
276        failed_op_id: super::operation::OpId,
277        errors: serde_json::Value,
278        #[serde(default, skip_serializing_if = "Option::is_none")]
279        suggested_transform: Option<serde_json::Value>,
280    },
281    /// Records one iteration of `lex repair --apply` (#281). The
282    /// repair loop emits a chain of `RepairAttempt`s — one per
283    /// applied transform — so the audit trail walks the agent's
284    /// fix progression.
285    RepairAttempt {
286        hint_id: super::operation::OpId,
287        /// Outcome tag: `passed` / `failed` / `skipped`.
288        outcome: String,
289        #[serde(default, skip_serializing_if = "Option::is_none")]
290        applied_op_id: Option<super::operation::OpId>,
291    },
292    /// Positive trust signal for a producer (#293). Complement to
293    /// [`Self::ProducerBlock`]. Computed from a producer's recent
294    /// history of (passed, failed, inconclusive) attestations;
295    /// not manually set. `score_thousandths` is in `[0, 1000]`
296    /// (representing `0.0 .. 1.0`); fixed-point because
297    /// `AttestationKind` is `Eq` for content-addressed hashing,
298    /// which `f64` doesn't implement. Consumers (the
299    /// `required_attestations` gate) may waive a requirement
300    /// when the latest score for a tool exceeds a configured
301    /// threshold in `policy.required_attestations[].skip_if_producer_trust_thousandths_above`.
302    ///
303    /// Refuses to grant trust to a tool with an active
304    /// `ProducerBlock` (the hard veto wins).
305    ///
306    /// Stored under `stage_id == tool_id` so the by-stage index
307    /// doubles as a per-tool lookup — same trick `ProducerBlock`
308    /// uses.
309    ProducerTrust {
310        tool_id: String,
311        /// Score × 1000, clamped to `[0, 1000]`. Derived from
312        /// `passed / (passed + failed + inconclusive)` over the
313        /// last `window` attestations from this tool.
314        score_thousandths: u32,
315        /// Free-form reference to the evidence corpus the score
316        /// was derived from — e.g. "window=1000 as of <head_op>".
317        evidence: String,
318        granted_by: String,
319    },
320    /// Records that the `required_attestations` gate waived a
321    /// requirement because the producer's `ProducerTrust` score
322    /// exceeded the configured threshold (#293). Audit signal —
323    /// not load-bearing for gate decisions, but ensures every
324    /// skip is recoverable from the attestation log.
325    TrustWaived {
326        /// Tool whose trust score caused the waiver.
327        producer: String,
328        /// Latest score (× 1000) consulted at gate time.
329        score_thousandths: u32,
330        /// Threshold (× 1000) from the policy rule.
331        threshold_thousandths: u32,
332        /// Which required-attestation kind tag was skipped
333        /// (e.g. `spec`, `type_check`).
334        kind_tag: String,
335    },
336    /// A capsule installed cleanly under lex-os (lex-os#36 / #38).
337    /// Promotes the tamper-evident `CapsuleInstalled` record from a
338    /// `lex-os capsule install --audit-out` log into a durable,
339    /// content-addressed attestation, via `lex attest import-install`.
340    ///
341    /// In the capsule distribution model the publisher's signing key
342    /// *is* the producer identity, so these records are stored under
343    /// `stage_id == signer` **and** carry `produced_by.tool == signer`
344    /// — the same convention `ProducerBlock` / `ProducerTrust` use.
345    /// That makes a publisher's install track record feed
346    /// `recompute_producer_trust` (which scores `produced_by.tool`)
347    /// and, through it, the trusted-keys keyring that `capsule install
348    /// --trusted-keys` consumes. The loop closes: install → attestation
349    /// → earned trust → keyring → next install.
350    CapsuleInstall {
351        /// `name@version` label of the installed artifact.
352        artifact: String,
353        /// Hex SHA-256 of the published archive bytes — the
354        /// publish-time identity of exactly which bytes installed.
355        /// Empty when imported from a pre-content-hash audit log.
356        content_hash: ContentHash,
357        /// The publisher's Ed25519 public key (hex): the verified
358        /// signer of the capability contract. Duplicated from
359        /// `stage_id` / `produced_by.tool` for clarity in the JSON.
360        signer: String,
361        /// The grant the box actually ran at — `meet(consumer,
362        /// requires)`, pretty-printed.
363        effective_grant: String,
364    },
365    /// A plan-shaped decision a capability gate reached, promoted into
366    /// durable evidence by `lex attest import-apply` (#790).
367    ///
368    /// Two gates emit this fact today — `lex-iac` between `terraform
369    /// plan` and `terraform apply`, and `lex-k8s` at Kubernetes
370    /// admission — and both emit the *same* fact: a plan-shaped
371    /// artifact, checked against a manifest, decided under a signer.
372    /// The name is deliberately generic. An `InfraApply` variant would
373    /// have pushed the Kubernetes gate into minting a near-duplicate,
374    /// and a duplicate discriminant splits one producer's track record
375    /// across two kinds. Whatever differs between the gates lives in
376    /// the `gate` and `subject` fields, which are payload rather than
377    /// identity-by-variant.
378    ///
379    /// Stored under `stage_id == signer` **and** `produced_by.tool ==
380    /// signer`, the convention [`Self::CapsuleInstall`] /
381    /// [`Self::ProducerBlock`] / [`Self::ProducerTrust`] use — get it
382    /// wrong and `recompute_producer_trust`, which scores
383    /// `produced_by.tool`, silently never sees these records.
384    ///
385    /// # Refusals are part of the record
386    ///
387    /// [`AttestationResult`] carries the verdict, so a refused decision
388    /// is this same kind with `Failed { detail }`. That is not an
389    /// afterthought: producer trust is `passed / (passed + failed)`, so
390    /// a corpus of acceptances only would score every submitter 1.0 and
391    /// mean nothing. A submitter loses trust by having its refusals on
392    /// the record next to its acceptances.
393    PlanApply {
394        /// Which gate decided — `terraform`, `kubernetes`, … Free-form,
395        /// and in the payload rather than the discriminant so a third
396        /// gate needs no schema change here.
397        gate: String,
398        /// What was decided about, in the gate's own vocabulary: a
399        /// workspace, a namespaced pod name. Human-facing; may be empty
400        /// when the gate names nothing.
401        subject: String,
402        /// Hex SHA-256 of the artifact's bytes — the plan or spec the
403        /// decision was actually reached about. Load-bearing: an
404        /// acceptance authorises *these* bytes, so a substituted plan
405        /// must not match. Never empty; the importer refuses an event
406        /// without it rather than minting evidence that matches
407        /// anything.
408        artifact_sha256: ContentHash,
409        /// The identity the decision was made under — a CI pipeline
410        /// key, a ServiceAccount, an agent key. Duplicated from
411        /// `stage_id` / `produced_by.tool` for clarity in the JSON.
412        signer: String,
413        /// The manifest the artifact was checked against: the ceiling
414        /// in force at decision time.
415        manifest: String,
416    },
417    /// Replay-as-verification (#836 G3): the recorded [`Intent`] behind
418    /// an op was re-run against the op's parent state, and the
419    /// regenerated stage compared to the recorded one. Makes the
420    /// reproducibility claim concrete — "can this change be regenerated
421    /// from its recorded cause". Addressed to the op's recorded stage;
422    /// `result` maps `reproduced` onto Passed/Failed so result-based
423    /// tooling reads it. The model call itself is external (the agent
424    /// harness regenerates and hands back a candidate); this attestation
425    /// records the deterministic comparison lex performed.
426    ///
427    /// [`Intent`]: crate::Intent
428    Replay {
429        /// The stage id the op recorded producing — what a faithful
430        /// regeneration should reproduce.
431        expected_stage_id: super::operation::StageId,
432        /// The stage id the regeneration actually produced, or `None`
433        /// when the regenerator returned nothing / a non-matching sig.
434        #[serde(default, skip_serializing_if = "Option::is_none")]
435        produced_stage_id: Option<super::operation::StageId>,
436        /// Whether the regeneration reproduced the recorded change — by
437        /// exact stage-id match, or (see `behavioral_samples`) behaviorally.
438        reproduced: bool,
439        /// When reproduction was established *behaviorally* rather than by
440        /// exact stage-id match — the regenerated function returned the same
441        /// value as the recorded one over this many sampled inputs (#836
442        /// follow-up). `None` for an exact match (the stronger claim:
443        /// `produced_stage_id == expected_stage_id`) or a genuine miss.
444        /// Distinguishes "the same function, written differently" from
445        /// "byte-identical" without conflating them.
446        #[serde(default, skip_serializing_if = "Option::is_none")]
447        behavioral_samples: Option<usize>,
448        /// The model the recorded intent named, for audit (`None` when
449        /// the op carried no intent or the intent no model).
450        #[serde(default, skip_serializing_if = "Option::is_none")]
451        model: Option<String>,
452    },
453}
454
455/// Walk a tool's `ProducerBlock` / `ProducerUnblock` attestations
456/// and return the active block timestamp, if any (#248). The
457/// attestation log is append-only, so a tool's state is whichever
458/// `ProducerBlock` / `ProducerUnblock` record has the latest
459/// `timestamp`. Returns `Some(blocked_at)` when the latest verdict
460/// is a `ProducerBlock` and `None` when the latest is an unblock or
461/// no verdict exists.
462///
463/// Ties: a `ProducerUnblock` at the same wall-clock second as a
464/// `ProducerBlock` wins, so re-running an unblock immediately after
465/// a block leaves the tool unblocked. Mirrors the tie-breaking in
466/// [`is_stage_blocked`].
467pub fn active_producer_block(
468    attestations: &[Attestation],
469    tool_id: &str,
470) -> Option<u64> {
471    let mut latest: Option<&Attestation> = None;
472    for a in attestations {
473        let matches = match &a.kind {
474            AttestationKind::ProducerBlock { tool_id: tid, .. }
475            | AttestationKind::ProducerUnblock { tool_id: tid, .. } => tid == tool_id,
476            _ => false,
477        };
478        if !matches {
479            continue;
480        }
481        match latest {
482            None => latest = Some(a),
483            Some(prev) if a.timestamp > prev.timestamp => latest = Some(a),
484            Some(prev) if a.timestamp == prev.timestamp
485                && matches!(a.kind, AttestationKind::ProducerUnblock { .. }) =>
486            {
487                latest = Some(a);
488            }
489            _ => {}
490        }
491    }
492    match latest.map(|a| &a.kind) {
493        Some(AttestationKind::ProducerBlock { blocked_at, .. }) => Some(*blocked_at),
494        _ => None,
495    }
496}
497
498/// Stable identifier for a [`lex_trace::TraceTree`]. Mirrors the
499/// `run_id` field on the trace JSON; kept as a `String` so this
500/// crate doesn't pull `lex-trace` in.
501pub type TraceRunId = String;
502
503/// Walk a stage's attestations and return whether the latest
504/// Block/Unblock decision is currently a Block. Used by
505/// activation paths (e.g. `lex stage pin`) to refuse when a
506/// human has signalled the stage shouldn't ship.
507///
508/// "Latest" is defined by `timestamp`, which matches what users
509/// see in `lex stage <id> --attestations`. Ties go to Unblock so
510/// retrying an unblock right after a block (same wall-clock
511/// second) doesn't leave the stage stuck.
512pub fn is_stage_blocked(attestations: &[Attestation]) -> bool {
513    let mut latest: Option<&Attestation> = None;
514    for a in attestations {
515        if !matches!(a.kind, AttestationKind::Block { .. } | AttestationKind::Unblock { .. }) {
516            continue;
517        }
518        match latest {
519            None => latest = Some(a),
520            Some(prev) if a.timestamp > prev.timestamp => latest = Some(a),
521            Some(prev) if a.timestamp == prev.timestamp
522                && matches!(a.kind, AttestationKind::Unblock { .. }) =>
523            {
524                latest = Some(a);
525            }
526            _ => {}
527        }
528    }
529    matches!(latest.map(|a| &a.kind), Some(AttestationKind::Block { .. }))
530}
531
532/// Verification method for [`AttestationKind::Spec`]. Mirrors the
533/// tag the spec checker already uses — kept as a string so the
534/// vcs crate doesn't have to pull `spec-checker` in.
535#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
536#[serde(rename_all = "snake_case")]
537pub enum SpecMethod {
538    /// Exhaustive search; `trials` is unset.
539    Exhaustive,
540    /// Random sampling; `trials` carries the sample count.
541    Random,
542    /// Symbolic execution.
543    Symbolic,
544}
545
546/// Whether the verification succeeded. `Inconclusive` is its own
547/// state because some checkers (e.g. random-sampling spec checks
548/// over an unbounded input space) can pass within their budget
549/// without proving the contract holds in general.
550#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
551#[serde(tag = "result", rename_all = "snake_case")]
552pub enum AttestationResult {
553    Passed,
554    Failed { detail: String },
555    Inconclusive { detail: String },
556}
557
558/// Who produced this attestation. `tool` is the CLI / harness name
559/// (`"lex check"`, `"lex agent-tool"`, `"ci-runner@v3"`). `version`
560/// pins the tool revision so a regression in the producer is
561/// distinguishable from a regression in the code being verified.
562/// `model` is set when an LLM was the proximate producer — for
563/// `--spec`-style runs the harness is the producer; for `lex
564/// agent-tool` the model is, and we want both recorded.
565#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
566pub struct ProducerDescriptor {
567    pub tool: String,
568    pub version: String,
569    #[serde(default, skip_serializing_if = "Option::is_none")]
570    pub model: Option<String>,
571}
572
573/// Optional cost record. Excluded from the attestation hash so
574/// rerunning a verification on a different machine (different
575/// wall-clock, different token pricing) doesn't break dedup.
576#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
577pub struct Cost {
578    #[serde(default, skip_serializing_if = "Option::is_none")]
579    pub tokens_in: Option<u64>,
580    #[serde(default, skip_serializing_if = "Option::is_none")]
581    pub tokens_out: Option<u64>,
582    /// USD cents (avoid floating-point in persisted form).
583    #[serde(default, skip_serializing_if = "Option::is_none")]
584    pub usd_cents: Option<u64>,
585    #[serde(default, skip_serializing_if = "Option::is_none")]
586    pub wall_time_ms: Option<u64>,
587}
588
589/// Optional Ed25519 signature over the attestation hash. Verifying
590/// it is the consumer's job; the data layer just stores the bytes.
591#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
592pub struct Signature {
593    /// Hex-encoded Ed25519 public key.
594    pub public_key: String,
595    /// Hex-encoded signature over the lowercase-hex `attestation_id`.
596    pub signature: String,
597}
598
599/// The persisted attestation. See module docs for what each field
600/// is, what's in the hash, and what isn't.
601#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
602pub struct Attestation {
603    pub attestation_id: AttestationId,
604    pub stage_id: StageId,
605    #[serde(default, skip_serializing_if = "Option::is_none")]
606    pub op_id: Option<OpId>,
607    #[serde(default, skip_serializing_if = "Option::is_none")]
608    pub intent_id: Option<IntentId>,
609    pub kind: AttestationKind,
610    pub result: AttestationResult,
611    pub produced_by: ProducerDescriptor,
612    #[serde(default, skip_serializing_if = "Option::is_none")]
613    pub cost: Option<Cost>,
614    /// Wall-clock seconds since epoch when this attestation was
615    /// produced. Excluded from `attestation_id` so the dedup
616    /// property holds across runs.
617    pub timestamp: u64,
618    #[serde(default, skip_serializing_if = "Option::is_none")]
619    pub signature: Option<Signature>,
620}
621
622impl Attestation {
623    /// Build an attestation against a stage, computing its
624    /// content-addressed id. `timestamp` defaults to the current
625    /// wall clock; pass to [`Attestation::with_timestamp`] in tests.
626    #[allow(clippy::too_many_arguments)]
627    pub fn new(
628        stage_id: impl Into<StageId>,
629        op_id: Option<OpId>,
630        intent_id: Option<IntentId>,
631        kind: AttestationKind,
632        result: AttestationResult,
633        produced_by: ProducerDescriptor,
634        cost: Option<Cost>,
635    ) -> Self {
636        let now = SystemTime::now()
637            .duration_since(UNIX_EPOCH)
638            .map(|d| d.as_secs())
639            .unwrap_or(0);
640        Self::with_timestamp(stage_id, op_id, intent_id, kind, result, produced_by, cost, now)
641    }
642
643    /// Build an attestation with a caller-controlled `timestamp`.
644    /// Used in tests to keep golden hashes stable.
645    #[allow(clippy::too_many_arguments)]
646    pub fn with_timestamp(
647        stage_id: impl Into<StageId>,
648        op_id: Option<OpId>,
649        intent_id: Option<IntentId>,
650        kind: AttestationKind,
651        result: AttestationResult,
652        produced_by: ProducerDescriptor,
653        cost: Option<Cost>,
654        timestamp: u64,
655    ) -> Self {
656        let stage_id = stage_id.into();
657        let attestation_id = compute_attestation_id(
658            &stage_id,
659            op_id.as_deref(),
660            intent_id.as_deref(),
661            &kind,
662            &result,
663            &produced_by,
664        );
665        Self {
666            attestation_id,
667            stage_id,
668            op_id,
669            intent_id,
670            kind,
671            result,
672            produced_by,
673            cost,
674            timestamp,
675            signature: None,
676        }
677    }
678
679    /// Attach a signature. The signature is not part of the hash;
680    /// the same logical attestation produced by an unsigned harness
681    /// dedupes against a signed one. Callers who *want* signature
682    /// to be part of identity should hash signature into the
683    /// `produced_by.tool` string explicitly.
684    pub fn with_signature(mut self, signature: Signature) -> Self {
685        self.signature = Some(signature);
686        self
687    }
688}
689
690fn compute_attestation_id(
691    stage_id: &str,
692    op_id: Option<&str>,
693    intent_id: Option<&str>,
694    kind: &AttestationKind,
695    result: &AttestationResult,
696    produced_by: &ProducerDescriptor,
697) -> AttestationId {
698    let view = CanonicalAttestationView {
699        stage_id,
700        op_id,
701        intent_id,
702        kind,
703        result,
704        produced_by,
705    };
706    canonical::hash(&view)
707}
708
709/// Hashable shadow of [`Attestation`] omitting the fields we
710/// deliberately exclude from identity (`attestation_id`, `cost`,
711/// `timestamp`, `signature`). Lives only as a transient.
712#[derive(Serialize)]
713struct CanonicalAttestationView<'a> {
714    stage_id: &'a str,
715    #[serde(skip_serializing_if = "Option::is_none")]
716    op_id: Option<&'a str>,
717    #[serde(skip_serializing_if = "Option::is_none")]
718    intent_id: Option<&'a str>,
719    kind: &'a AttestationKind,
720    result: &'a AttestationResult,
721    produced_by: &'a ProducerDescriptor,
722}
723
724// ---- Persistence -------------------------------------------------
725
726/// Persistent log of [`Attestation`] records.
727///
728/// Mirrors [`crate::OpLog`] / [`crate::IntentLog`] in shape: one
729/// canonical-JSON file per attestation, atomic writes via tempfile +
730/// rename, idempotent on re-puts. Maintains two secondary indices
731/// for cheap reverse lookups:
732///
733/// * `by-stage/<StageId>/<AttestationId>` — every attestation,
734///   indexed by the stage it records evidence for.
735/// * `by-run/<TraceRunId>/<AttestationId>` (#246) — only
736///   `AttestationKind::Trace` entries are indexed here, so
737///   `list_for_run` is `O(traces of that run)` rather than scanning
738///   the whole log.
739pub struct AttestationLog {
740    dir: PathBuf,
741    by_stage: PathBuf,
742    by_run: PathBuf,
743}
744
745impl AttestationLog {
746    pub fn open(root: &Path) -> io::Result<Self> {
747        let dir = root.join("attestations");
748        let by_stage = dir.join("by-stage");
749        let by_run = dir.join("by-run");
750        fs::create_dir_all(&by_stage)?;
751        fs::create_dir_all(&by_run)?;
752        Ok(Self { dir, by_stage, by_run })
753    }
754
755    fn primary_path(&self, id: &AttestationId) -> PathBuf {
756        self.dir.join(format!("{id}.json"))
757    }
758
759    /// Persist an attestation. Idempotent on existing ids — content
760    /// addressing guarantees the same logical attestation produces
761    /// the same id, so re-putting is a no-op for the primary file.
762    /// The by-stage index is also re-written idempotently.
763    pub fn put(&self, attestation: &Attestation) -> io::Result<()> {
764        let primary = self.primary_path(&attestation.attestation_id);
765        if !primary.exists() {
766            let bytes = serde_json::to_vec(attestation)
767                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
768            let tmp = primary.with_extension("json.tmp");
769            let mut f = fs::File::create(&tmp)?;
770            f.write_all(&bytes)?;
771            f.sync_all()?;
772            fs::rename(&tmp, &primary)?;
773        }
774        // Index entry: empty marker file. Reading the index is a
775        // directory listing; resolving each entry is a primary-file
776        // read by id.
777        let stage_dir = self.by_stage.join(&attestation.stage_id);
778        fs::create_dir_all(&stage_dir)?;
779        let idx = stage_dir.join(&attestation.attestation_id);
780        if !idx.exists() {
781            fs::File::create(&idx)?;
782        }
783        // by-run secondary index for Trace attestations (#246) —
784        // only the variants that carry a `run_id` are indexed; every
785        // other kind skips this directory entirely.
786        if let AttestationKind::Trace { run_id, .. } = &attestation.kind {
787            let run_dir = self.by_run.join(run_id);
788            fs::create_dir_all(&run_dir)?;
789            let idx = run_dir.join(&attestation.attestation_id);
790            if !idx.exists() {
791                fs::File::create(&idx)?;
792            }
793        }
794        Ok(())
795    }
796
797    /// Remove an attestation from the log along with both index
798    /// entries (#258). Idempotent on missing files.
799    ///
800    /// **Not** part of the day-to-day API — the attestation log is
801    /// append-only by design (#132). The only legitimate caller is
802    /// the migration tool, which supervises a destructive,
803    /// `--confirm`-gated batch.
804    pub fn delete(&self, attestation: &Attestation) -> io::Result<()> {
805        let primary = self.primary_path(&attestation.attestation_id);
806        match fs::remove_file(&primary) {
807            Ok(()) | Err(_) => {} // best-effort; missing is fine
808        }
809        let stage_idx = self.by_stage
810            .join(&attestation.stage_id)
811            .join(&attestation.attestation_id);
812        let _ = fs::remove_file(&stage_idx);
813        if let AttestationKind::Trace { run_id, .. } = &attestation.kind {
814            let run_idx = self.by_run.join(run_id).join(&attestation.attestation_id);
815            let _ = fs::remove_file(&run_idx);
816        }
817        Ok(())
818    }
819
820    pub fn get(&self, id: &AttestationId) -> io::Result<Option<Attestation>> {
821        let path = self.primary_path(id);
822        if !path.exists() {
823            return Ok(None);
824        }
825        let bytes = fs::read(&path)?;
826        let attestation: Attestation = serde_json::from_slice(&bytes)
827            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
828        Ok(Some(attestation))
829    }
830
831    /// Enumerate every attestation in the log. Walks
832    /// `<root>/attestations/*.json` directly — no per-stage index
833    /// — so cost is `O(total attestations)`. Used by `lex attest
834    /// filter` for CI / dashboard queries that span stages.
835    /// Order is not stable; callers that need stable ordering
836    /// should sort by `timestamp` or `attestation_id`.
837    pub fn list_all(&self) -> io::Result<Vec<Attestation>> {
838        let mut out = Vec::new();
839        if !self.dir.exists() {
840            return Ok(out);
841        }
842        for entry in fs::read_dir(&self.dir)? {
843            let entry = entry?;
844            let p = entry.path();
845            // Skip the by-stage/ subdir and the .tmp staging files
846            // a crashed put might have left behind.
847            if p.is_dir() {
848                continue;
849            }
850            if p.extension().is_none_or(|e| e != "json") {
851                continue;
852            }
853            let bytes = fs::read(&p)?;
854            // A corrupt primary file shouldn't take down a filter
855            // query — log to stderr and skip.
856            match serde_json::from_slice::<Attestation>(&bytes) {
857                Ok(att) => out.push(att),
858                Err(e) => eprintln!(
859                    "warning: skipping unreadable attestation {}: {e}",
860                    p.display()
861                ),
862            }
863        }
864        Ok(out)
865    }
866
867    /// Enumerate attestations for a given stage. Order is not
868    /// stable across calls (it follows directory iteration order).
869    /// Callers that need a stable ordering should sort by
870    /// `timestamp` or `attestation_id`.
871    pub fn list_for_stage(&self, stage_id: &StageId) -> io::Result<Vec<Attestation>> {
872        let stage_dir = self.by_stage.join(stage_id);
873        if !stage_dir.exists() {
874            return Ok(Vec::new());
875        }
876        let mut out = Vec::new();
877        for entry in fs::read_dir(&stage_dir)? {
878            let entry = entry?;
879            let id = match entry.file_name().into_string() {
880                Ok(s) => s,
881                Err(_) => continue,
882            };
883            if let Some(att) = self.get(&id)? {
884                out.push(att);
885            }
886        }
887        Ok(out)
888    }
889
890    /// Enumerate `AttestationKind::Trace` entries for a given
891    /// `run_id` (#246). Walks the `by-run/<run_id>/` directory; cost
892    /// is `O(trace attestations for that run)`, typically 1.
893    /// Returns an empty vec if the run has no Trace attestations.
894    /// Order is not stable.
895    pub fn list_for_run(&self, run_id: &TraceRunId) -> io::Result<Vec<Attestation>> {
896        let run_dir = self.by_run.join(run_id);
897        if !run_dir.exists() {
898            return Ok(Vec::new());
899        }
900        let mut out = Vec::new();
901        for entry in fs::read_dir(&run_dir)? {
902            let entry = entry?;
903            let id = match entry.file_name().into_string() {
904                Ok(s) => s,
905                Err(_) => continue,
906            };
907            if let Some(att) = self.get(&id)? {
908                out.push(att);
909            }
910        }
911        Ok(out)
912    }
913}
914
915// ---- Tests --------------------------------------------------------
916
917#[cfg(test)]
918mod tests {
919    use super::*;
920
921    fn ci_runner() -> ProducerDescriptor {
922        ProducerDescriptor {
923            tool: "lex check".into(),
924            version: "0.1.0".into(),
925            model: None,
926        }
927    }
928
929    fn typecheck_passed() -> Attestation {
930        Attestation::with_timestamp(
931            "stage-abc",
932            Some("op-123".into()),
933            None,
934            AttestationKind::TypeCheck,
935            AttestationResult::Passed,
936            ci_runner(),
937            None,
938            1000,
939        )
940    }
941
942    #[test]
943    fn same_logical_verification_hashes_equal() {
944        // Dedup invariant: same stage, same kind, same producer,
945        // same outcome → same `attestation_id` regardless of
946        // wall-clock or cost.
947        let a = typecheck_passed();
948        let b = Attestation::with_timestamp(
949            "stage-abc",
950            Some("op-123".into()),
951            None,
952            AttestationKind::TypeCheck,
953            AttestationResult::Passed,
954            ci_runner(),
955            Some(Cost {
956                tokens_in: Some(0),
957                tokens_out: Some(0),
958                usd_cents: Some(0),
959                wall_time_ms: Some(42),
960            }),
961            99999,
962        );
963        assert_eq!(a.attestation_id, b.attestation_id);
964    }
965
966    #[test]
967    fn different_stages_hash_differently() {
968        let a = typecheck_passed();
969        let b = Attestation::with_timestamp(
970            "stage-XYZ",
971            Some("op-123".into()),
972            None,
973            AttestationKind::TypeCheck,
974            AttestationResult::Passed,
975            ci_runner(),
976            None,
977            1000,
978        );
979        assert_ne!(a.attestation_id, b.attestation_id);
980    }
981
982    #[test]
983    fn different_op_ids_hash_differently() {
984        let a = typecheck_passed();
985        let b = Attestation::with_timestamp(
986            "stage-abc",
987            Some("op-XYZ".into()),
988            None,
989            AttestationKind::TypeCheck,
990            AttestationResult::Passed,
991            ci_runner(),
992            None,
993            1000,
994        );
995        assert_ne!(a.attestation_id, b.attestation_id);
996    }
997
998    #[test]
999    fn different_intents_hash_differently() {
1000        let a = Attestation::with_timestamp(
1001            "stage-abc", None,
1002            Some("intent-A".into()),
1003            AttestationKind::TypeCheck, AttestationResult::Passed,
1004            ci_runner(), None, 1000,
1005        );
1006        let b = Attestation::with_timestamp(
1007            "stage-abc", None,
1008            Some("intent-B".into()),
1009            AttestationKind::TypeCheck, AttestationResult::Passed,
1010            ci_runner(), None, 1000,
1011        );
1012        assert_ne!(a.attestation_id, b.attestation_id);
1013    }
1014
1015    #[test]
1016    fn different_kinds_hash_differently() {
1017        let a = typecheck_passed();
1018        let b = Attestation::with_timestamp(
1019            "stage-abc",
1020            Some("op-123".into()),
1021            None,
1022            AttestationKind::EffectAudit,
1023            AttestationResult::Passed,
1024            ci_runner(),
1025            None,
1026            1000,
1027        );
1028        assert_ne!(a.attestation_id, b.attestation_id);
1029    }
1030
1031    #[test]
1032    fn passed_vs_failed_hash_differently() {
1033        // Critical: a Failed attestation must not collide with a
1034        // Passed one for the same logical verification. Otherwise
1035        // a flaky producer could overwrite the negative evidence
1036        // by re-running and getting Passed.
1037        let a = typecheck_passed();
1038        let b = Attestation::with_timestamp(
1039            "stage-abc",
1040            Some("op-123".into()),
1041            None,
1042            AttestationKind::TypeCheck,
1043            AttestationResult::Failed { detail: "arity mismatch".into() },
1044            ci_runner(),
1045            None,
1046            1000,
1047        );
1048        assert_ne!(a.attestation_id, b.attestation_id);
1049    }
1050
1051    #[test]
1052    fn different_producers_hash_differently() {
1053        let a = typecheck_passed();
1054        let mut other = ci_runner();
1055        other.tool = "third-party-runner".into();
1056        let b = Attestation::with_timestamp(
1057            "stage-abc",
1058            Some("op-123".into()),
1059            None,
1060            AttestationKind::TypeCheck,
1061            AttestationResult::Passed,
1062            other,
1063            None,
1064            1000,
1065        );
1066        assert_ne!(
1067            a.attestation_id, b.attestation_id,
1068            "an attestation from a different producer is a different fact",
1069        );
1070    }
1071
1072    #[test]
1073    fn signature_is_excluded_from_hash() {
1074        // A signed and unsigned attestation of the same logical
1075        // fact must dedupe. Otherwise late-signing a record would
1076        // create two attestations that say the same thing.
1077        let a = typecheck_passed();
1078        let b = typecheck_passed().with_signature(Signature {
1079            public_key: "ed25519:fffe".into(),
1080            signature: "0xabcd".into(),
1081        });
1082        assert_eq!(a.attestation_id, b.attestation_id);
1083    }
1084
1085    #[test]
1086    fn attestation_id_is_64_char_lowercase_hex() {
1087        let a = typecheck_passed();
1088        assert_eq!(a.attestation_id.len(), 64);
1089        assert!(a
1090            .attestation_id
1091            .chars()
1092            .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)));
1093    }
1094
1095    #[test]
1096    fn round_trip_through_serde_json() {
1097        let a = Attestation::with_timestamp(
1098            "stage-abc",
1099            Some("op-123".into()),
1100            Some("intent-A".into()),
1101            AttestationKind::Spec {
1102                spec_id: "clamp.spec".into(),
1103                method: SpecMethod::Random,
1104                trials: Some(1000),
1105            },
1106            AttestationResult::Passed,
1107            ProducerDescriptor {
1108                tool: "lex agent-tool".into(),
1109                version: "0.1.0".into(),
1110                model: Some("claude-opus-4-7".into()),
1111            },
1112            Some(Cost {
1113                tokens_in: Some(1234),
1114                tokens_out: Some(567),
1115                usd_cents: Some(2),
1116                wall_time_ms: Some(3400),
1117            }),
1118            99,
1119        )
1120        .with_signature(Signature {
1121            public_key: "ed25519:abc".into(),
1122            signature: "0x1234".into(),
1123        });
1124        let json = serde_json::to_string(&a).unwrap();
1125        let back: Attestation = serde_json::from_str(&json).unwrap();
1126        assert_eq!(a, back);
1127    }
1128
1129    /// Golden hash. If this changes, the canonical form has shifted
1130    /// — every `AttestationId` in every existing store has changed
1131    /// too. Update with care; same protective shape as the
1132    /// `Operation` and `Intent` golden tests.
1133    #[test]
1134    fn canonical_form_is_stable_for_a_known_input() {
1135        let a = Attestation::with_timestamp(
1136            "stage-abc",
1137            Some("op-123".into()),
1138            None,
1139            AttestationKind::TypeCheck,
1140            AttestationResult::Passed,
1141            ProducerDescriptor {
1142                tool: "lex check".into(),
1143                version: "0.1.0".into(),
1144                model: None,
1145            },
1146            None,
1147            0,
1148        );
1149        assert_eq!(
1150            a.attestation_id,
1151            "a4ef921f7bb0db70779c5b698cda1744d49165a4a56aa8414bdbafc85bcbc16b",
1152            "canonical-form regression: the AttestationId for a known input changed",
1153        );
1154    }
1155
1156    // ---- AttestationLog ----
1157
1158    #[test]
1159    fn log_round_trips_through_disk() {
1160        let tmp = tempfile::tempdir().unwrap();
1161        let log = AttestationLog::open(tmp.path()).unwrap();
1162        let a = typecheck_passed();
1163        log.put(&a).unwrap();
1164        let read_back = log.get(&a.attestation_id).unwrap().unwrap();
1165        assert_eq!(a, read_back);
1166    }
1167
1168    #[test]
1169    fn log_get_unknown_returns_none() {
1170        let tmp = tempfile::tempdir().unwrap();
1171        let log = AttestationLog::open(tmp.path()).unwrap();
1172        assert!(log
1173            .get(&"nonexistent".to_string())
1174            .unwrap()
1175            .is_none());
1176    }
1177
1178    #[test]
1179    fn log_put_is_idempotent() {
1180        let tmp = tempfile::tempdir().unwrap();
1181        let log = AttestationLog::open(tmp.path()).unwrap();
1182        let a = typecheck_passed();
1183        log.put(&a).unwrap();
1184        log.put(&a).unwrap();
1185        let read_back = log.get(&a.attestation_id).unwrap().unwrap();
1186        assert_eq!(a, read_back);
1187    }
1188
1189    #[test]
1190    fn list_for_stage_returns_only_that_stage() {
1191        let tmp = tempfile::tempdir().unwrap();
1192        let log = AttestationLog::open(tmp.path()).unwrap();
1193
1194        let on_abc_1 = typecheck_passed();
1195        let on_abc_2 = Attestation::with_timestamp(
1196            "stage-abc",
1197            Some("op-123".into()),
1198            None,
1199            AttestationKind::EffectAudit,
1200            AttestationResult::Passed,
1201            ci_runner(),
1202            None,
1203            2000,
1204        );
1205        let on_xyz = Attestation::with_timestamp(
1206            "stage-xyz",
1207            Some("op-456".into()),
1208            None,
1209            AttestationKind::TypeCheck,
1210            AttestationResult::Passed,
1211            ci_runner(),
1212            None,
1213            1000,
1214        );
1215
1216        log.put(&on_abc_1).unwrap();
1217        log.put(&on_abc_2).unwrap();
1218        log.put(&on_xyz).unwrap();
1219
1220        let mut on_abc = log.list_for_stage(&"stage-abc".to_string()).unwrap();
1221        on_abc.sort_by_key(|a| a.timestamp);
1222        assert_eq!(on_abc.len(), 2);
1223        assert_eq!(on_abc[0], on_abc_1);
1224        assert_eq!(on_abc[1], on_abc_2);
1225
1226        let on_xyz_listed = log.list_for_stage(&"stage-xyz".to_string()).unwrap();
1227        assert_eq!(on_xyz_listed.len(), 1);
1228        assert_eq!(on_xyz_listed[0], on_xyz);
1229    }
1230
1231    #[test]
1232    fn list_for_unknown_stage_is_empty() {
1233        let tmp = tempfile::tempdir().unwrap();
1234        let log = AttestationLog::open(tmp.path()).unwrap();
1235        let v = log.list_for_stage(&"never-attested".to_string()).unwrap();
1236        assert!(v.is_empty());
1237    }
1238
1239    #[test]
1240    fn list_all_returns_every_persisted_attestation() {
1241        // Cross-stage enumeration: `list_all` walks the primary
1242        // directory regardless of stage, so a CI / dashboard query
1243        // can filter across the whole log without iterating the
1244        // by-stage index.
1245        let tmp = tempfile::tempdir().unwrap();
1246        let log = AttestationLog::open(tmp.path()).unwrap();
1247        let on_abc = typecheck_passed();
1248        let on_xyz = Attestation::with_timestamp(
1249            "stage-xyz",
1250            Some("op-456".into()),
1251            None,
1252            AttestationKind::TypeCheck,
1253            AttestationResult::Passed,
1254            ci_runner(),
1255            None,
1256            2000,
1257        );
1258        log.put(&on_abc).unwrap();
1259        log.put(&on_xyz).unwrap();
1260        let mut all = log.list_all().unwrap();
1261        all.sort_by_key(|a| a.attestation_id.clone());
1262        assert_eq!(all.len(), 2);
1263        let ids: BTreeSet<_> = all.iter().map(|a| a.attestation_id.clone()).collect();
1264        assert!(ids.contains(&on_abc.attestation_id));
1265        assert!(ids.contains(&on_xyz.attestation_id));
1266    }
1267
1268    #[test]
1269    fn list_all_on_empty_log_is_empty() {
1270        let tmp = tempfile::tempdir().unwrap();
1271        let log = AttestationLog::open(tmp.path()).unwrap();
1272        let v = log.list_all().unwrap();
1273        assert!(v.is_empty());
1274    }
1275
1276    #[test]
1277    fn passed_and_failed_for_same_stage_both_persist() {
1278        // Failure attestations are evidence too; they must not be
1279        // overwritten by a later passing attestation. The hash
1280        // distinction (tested above) plus the by-stage listing
1281        // should keep both visible.
1282        let tmp = tempfile::tempdir().unwrap();
1283        let log = AttestationLog::open(tmp.path()).unwrap();
1284
1285        let passed = typecheck_passed();
1286        let failed = Attestation::with_timestamp(
1287            "stage-abc",
1288            Some("op-123".into()),
1289            None,
1290            AttestationKind::TypeCheck,
1291            AttestationResult::Failed { detail: "arity mismatch".into() },
1292            ci_runner(),
1293            None,
1294            500,
1295        );
1296
1297        log.put(&failed).unwrap();
1298        log.put(&passed).unwrap();
1299
1300        let listing = log.list_for_stage(&"stage-abc".to_string()).unwrap();
1301        assert_eq!(listing.len(), 2, "both passing and failing evidence must persist");
1302    }
1303
1304    fn human_decision(kind: AttestationKind, ts: u64) -> Attestation {
1305        Attestation::with_timestamp(
1306            "stage-abc",
1307            None, None,
1308            kind,
1309            AttestationResult::Passed,
1310            ProducerDescriptor {
1311                tool: "lex stage".into(),
1312                version: "0.1.0".into(),
1313                model: None,
1314            },
1315            None,
1316            ts,
1317        )
1318    }
1319
1320    #[test]
1321    fn is_stage_blocked_empty_log_is_false() {
1322        assert!(!is_stage_blocked(&[]));
1323    }
1324
1325    #[test]
1326    fn is_stage_blocked_only_unrelated_attestations() {
1327        // TypeCheck/Override attestations don't gate activation —
1328        // only Block/Unblock do.
1329        let attestations = vec![
1330            typecheck_passed(),
1331            human_decision(
1332                AttestationKind::Override {
1333                    actor: "alice".into(),
1334                    reason: "ship".into(),
1335                    target_attestation_id: None,
1336                },
1337                500,
1338            ),
1339        ];
1340        assert!(!is_stage_blocked(&attestations));
1341    }
1342
1343    #[test]
1344    fn is_stage_blocked_block_alone_blocks() {
1345        let attestations = vec![human_decision(
1346            AttestationKind::Block { actor: "alice".into(), reason: "x".into() },
1347            500,
1348        )];
1349        assert!(is_stage_blocked(&attestations));
1350    }
1351
1352    #[test]
1353    fn is_stage_blocked_later_unblock_clears_block() {
1354        let attestations = vec![
1355            human_decision(
1356                AttestationKind::Block { actor: "alice".into(), reason: "x".into() },
1357                500,
1358            ),
1359            human_decision(
1360                AttestationKind::Unblock { actor: "alice".into(), reason: "ok".into() },
1361                600,
1362            ),
1363        ];
1364        assert!(!is_stage_blocked(&attestations));
1365    }
1366
1367    #[test]
1368    fn is_stage_blocked_later_block_re_blocks() {
1369        let attestations = vec![
1370            human_decision(
1371                AttestationKind::Block { actor: "a".into(), reason: "1".into() },
1372                500,
1373            ),
1374            human_decision(
1375                AttestationKind::Unblock { actor: "a".into(), reason: "2".into() },
1376                600,
1377            ),
1378            human_decision(
1379                AttestationKind::Block { actor: "a".into(), reason: "3".into() },
1380                700,
1381            ),
1382        ];
1383        assert!(is_stage_blocked(&attestations));
1384    }
1385
1386    #[test]
1387    fn is_stage_blocked_unblock_wins_at_same_timestamp() {
1388        // Tie-break favours Unblock so a hasty re-attempt at the
1389        // same wall-clock second can't strand the stage.
1390        let attestations = vec![
1391            human_decision(
1392                AttestationKind::Block { actor: "a".into(), reason: "1".into() },
1393                500,
1394            ),
1395            human_decision(
1396                AttestationKind::Unblock { actor: "a".into(), reason: "2".into() },
1397                500,
1398            ),
1399        ];
1400        assert!(!is_stage_blocked(&attestations));
1401    }
1402}