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