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