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