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