Skip to main content

lex_vcs/
attestation.rs

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