lex_store/policy.rs
1//! `<store>/policy.json` — local trust policy.
2//!
3//! Two orthogonal concerns share the same file:
4//!
5//! 1. **`blocked_producers`** (#181) — negative gate on
6//! attestations. Producers on this list keep their attestations
7//! in the log (audit trail intact) but consumers tag those rows
8//! `blocked`. Enforcement is at attestation-read time.
9//! 2. **`required_attestations`** (#245) — positive gate on
10//! *branch advancement*. Each entry says "every op landed on
11//! this branch must carry a `Passed` attestation of kind X (or
12//! of kind X *when its effects intersect Y*) before the branch
13//! head can move past it." This is the agent-shaped equivalent
14//! of "branch protection rules" in human VCSes, grounded in the
15//! attestation graph rather than human review.
16//!
17//! File schema (additive across versions):
18//!
19//! ```json
20//! {
21//! "blocked_producers": [
22//! {"tool": "buggy-bot", "reason": "false positives", "blocked_at": 1714960000}
23//! ],
24//! "required_attestations": [
25//! {"kind": "type_check", "when": {"always": null}},
26//! {"kind": "spec", "when": {"always": null}},
27//! {"kind": "sandbox_run", "when": {"effects_intersect": ["io", "net", "fs_write"]}}
28//! ]
29//! }
30//! ```
31//!
32//! Existing `policy.json` files keep working — `required_attestations`
33//! defaults to empty (no gate).
34
35use lex_vcs::{
36 active_producer_block, Attestation, AttestationKind, AttestationLog, AttestationResult, OpId,
37};
38use serde::{Deserialize, Serialize};
39use std::collections::BTreeSet;
40use std::fs;
41use std::io::{self, Write};
42use std::path::Path;
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct BlockedProducer {
46 /// Matched against `ProducerDescriptor::tool`.
47 pub tool: String,
48 pub reason: String,
49 /// Wall-clock seconds since epoch when the block was added.
50 /// Useful for "blocked since X" rendering in the activity feed.
51 pub blocked_at: u64,
52}
53
54#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
55pub struct PolicyFile {
56 #[serde(default)]
57 pub blocked_producers: Vec<BlockedProducer>,
58 /// Positive gate on branch advance (#245). Empty means "no
59 /// requirements" — same behavior as before this field existed.
60 #[serde(default, skip_serializing_if = "Vec::is_empty")]
61 pub required_attestations: Vec<RequiredAttestation>,
62 /// Retention rules for `lex op gc` (#261 slice 2). Default
63 /// (empty) means "every op is GC-eligible unless it's reachable
64 /// from a branch head" — branch reachability is always honored.
65 #[serde(default, skip_serializing_if = "GcRetention::is_empty")]
66 pub gc_retention: GcRetention,
67 /// Per-session budget caps (#292 slices 2 + 3). Absence /
68 /// empty value means "no enforcement; ledger stays
69 /// descriptive (slice 1)." See [`SessionBudgetPolicy`].
70 #[serde(default, skip_serializing_if = "SessionBudgetPolicy::is_empty")]
71 pub session_budgets: SessionBudgetPolicy,
72}
73
74/// Per-session budget caps for the apply-path gate (#292 slices 2
75/// and 3). The `default_cap` field applies to every session not
76/// listed in `overrides`. Within the overrides map, a value of
77/// `Some(n)` sets that session's cap to `n`, and an explicit
78/// `null` means the session is unbounded — the escape hatch for
79/// ops outside the budget envelope, e.g. one-shot human
80/// interventions.
81///
82/// Absence of `session_budgets` keeps current (#292 slice 1)
83/// behavior: spending is recorded but never refused.
84#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
85pub struct SessionBudgetPolicy {
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub default_cap: Option<u64>,
88 /// Per-session overrides. Outer `Option`: presence in the
89 /// map. Inner `Option`: `Some(n)` is a cap of `n`; `None` is
90 /// the explicit "unbounded for this session."
91 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
92 pub overrides: std::collections::BTreeMap<String, Option<u64>>,
93}
94
95impl SessionBudgetPolicy {
96 pub fn is_empty(&self) -> bool {
97 self.default_cap.is_none() && self.overrides.is_empty()
98 }
99
100 /// Resolve the cap for a given session. The lookup is:
101 /// 1. If `overrides` has the session_id, use that entry's
102 /// value (which itself may be `None` meaning unbounded).
103 /// 2. Otherwise fall back to `default_cap`.
104 /// 3. Return `None` for "no cap; don't enforce."
105 pub fn cap_for(&self, session_id: &str) -> Option<u64> {
106 match self.overrides.get(session_id) {
107 Some(explicit) => *explicit,
108 None => self.default_cap,
109 }
110 }
111}
112
113/// Retention policy for the predicate-driven op-log GC (#261 slice
114/// 2). Ops matching any retain predicate are kept; ops reachable
115/// from any branch head are *also* always kept regardless of this
116/// policy. The "parent of a retained op is retained too" invariant
117/// is a closure rule applied by [`crate::Store::plan_gc`], not a
118/// schema field.
119///
120/// The retain predicates are stored as `serde_json::Value` rather
121/// than typed `Predicate`s so the policy file is forward-compatible
122/// with future predicate variants — the GC engine parses them at
123/// load time and surfaces a clear error if the schema doesn't match.
124#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
125pub struct GcRetention {
126 /// Each entry is the JSON form produced by `Predicate::to_value`
127 /// (`{"predicate": "intent", "intent_id": "..."}` etc.). Ops
128 /// matching *any* predicate are retained.
129 #[serde(default, skip_serializing_if = "Vec::is_empty")]
130 pub retain: Vec<serde_json::Value>,
131}
132
133impl GcRetention {
134 pub fn is_empty(&self) -> bool {
135 self.retain.is_empty()
136 }
137}
138
139/// One required-attestation rule. Says: "every op advancing the
140/// branch must carry a `Passed` attestation of `kind`, except
141/// possibly when `when` filters it out."
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct RequiredAttestation {
144 pub kind: RequiredAttestationKind,
145 /// Defaults to [`AttestationCondition::Always`] if absent in the
146 /// JSON — the typical "this attestation is mandatory for every
147 /// op" rule.
148 #[serde(default)]
149 pub when: AttestationCondition,
150 /// Trust-based waiver threshold (#293). When set, the gate
151 /// waives this rule if the maximum live
152 /// [`AttestationKind::ProducerTrust`] `score_thousandths`
153 /// across all tools (excluding those with an active
154 /// [`AttestationKind::ProducerBlock`]) exceeds this
155 /// threshold. A `TrustWaived` attestation lands per waiver
156 /// so the audit trail records the skip.
157 ///
158 /// `None` (default) means no waiver — the rule fires
159 /// unconditionally when `when` applies.
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub skip_if_producer_trust_thousandths_above: Option<u32>,
162}
163
164/// Which `AttestationKind` is required. Mirrors the variants the
165/// existing producers emit (`TypeCheck` from #130, `Spec` from
166/// #186, `SandboxRun` from `lex agent-tool`, etc.). Only the
167/// machine-emittable variants are exposed; human-only attestations
168/// like `Override` and `Block` aren't useful here.
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(rename_all = "snake_case")]
171pub enum RequiredAttestationKind {
172 TypeCheck,
173 Spec,
174 SandboxRun,
175 Examples,
176 DiffBody,
177 EffectAudit,
178}
179
180impl RequiredAttestationKind {
181 /// Match against an actual [`AttestationKind`]. Variants with
182 /// payloads (e.g. `Spec { spec_id, … }`, `SandboxRun { effects }`)
183 /// match the type-tag only — any spec, any sandbox run.
184 pub fn matches(&self, kind: &AttestationKind) -> bool {
185 matches!(
186 (self, kind),
187 (Self::TypeCheck, AttestationKind::TypeCheck)
188 | (Self::Spec, AttestationKind::Spec { .. })
189 | (Self::SandboxRun, AttestationKind::SandboxRun { .. })
190 | (Self::Examples, AttestationKind::Examples { .. })
191 | (Self::DiffBody, AttestationKind::DiffBody { .. })
192 | (Self::EffectAudit, AttestationKind::EffectAudit)
193 )
194 }
195
196 /// CLI-friendly tag used by `lex policy require-attestation
197 /// <tag>` and rendered in `lex policy list`.
198 pub fn tag(&self) -> &'static str {
199 match self {
200 Self::TypeCheck => "type_check",
201 Self::Spec => "spec",
202 Self::SandboxRun => "sandbox_run",
203 Self::Examples => "examples",
204 Self::DiffBody => "diff_body",
205 Self::EffectAudit => "effect_audit",
206 }
207 }
208
209 /// Inverse of [`Self::tag`] — used when parsing CLI input.
210 pub fn from_tag(s: &str) -> Option<Self> {
211 match s {
212 "type_check" | "TypeCheck" => Some(Self::TypeCheck),
213 "spec" | "Spec" => Some(Self::Spec),
214 "sandbox_run" | "SandboxRun" => Some(Self::SandboxRun),
215 "examples" | "Examples" => Some(Self::Examples),
216 "diff_body" | "DiffBody" => Some(Self::DiffBody),
217 "effect_audit" | "EffectAudit" => Some(Self::EffectAudit),
218 _ => None,
219 }
220 }
221}
222
223/// When a [`RequiredAttestation`] applies to a given op.
224#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(rename_all = "snake_case")]
226pub enum AttestationCondition {
227 /// The attestation is required for every op.
228 #[default]
229 Always,
230 /// Only required when the op's declared effect set intersects
231 /// any of these effect strings. Matches the same effect-name
232 /// shape used in `OperationKind::AddFunction.effects` etc.
233 /// Empty set means the rule is effectively disabled (useful as
234 /// a temporary kill-switch without removing the entry).
235 EffectsIntersect(BTreeSet<String>),
236}
237
238impl AttestationCondition {
239 /// Whether the rule fires for an op whose declared effects are
240 /// `op_effects`. `EffectsIntersect` with an empty set never
241 /// fires.
242 pub fn applies(&self, op_effects: &BTreeSet<String>) -> bool {
243 match self {
244 AttestationCondition::Always => true,
245 AttestationCondition::EffectsIntersect(needed) => {
246 !needed.is_empty() && op_effects.iter().any(|e| needed.contains(e))
247 }
248 }
249 }
250}
251
252/// Load `<root>/policy.json`. Returns `Ok(None)` when absent
253/// (no policy → no blocks); `Ok(Some(default))` when the file
254/// exists but is empty/has no blocks.
255pub fn load(root: &Path) -> io::Result<Option<PolicyFile>> {
256 let path = root.join("policy.json");
257 if !path.exists() {
258 return Ok(None);
259 }
260 let bytes = fs::read(&path)?;
261 let file: PolicyFile = serde_json::from_slice(&bytes)
262 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData,
263 format!("parsing {}: {e}", path.display())))?;
264 Ok(Some(file))
265}
266
267/// Atomic write: tempfile + rename so a crashed write never
268/// leaves a half-truncated `policy.json`. Same pattern the
269/// attestation log uses.
270pub fn save(root: &Path, file: &PolicyFile) -> io::Result<()> {
271 fs::create_dir_all(root)?;
272 let path = root.join("policy.json");
273 let tmp = path.with_extension("json.tmp");
274 let bytes = serde_json::to_vec_pretty(file)
275 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
276 {
277 let mut f = fs::File::create(&tmp)?;
278 f.write_all(&bytes)?;
279 f.sync_all()?;
280 }
281 fs::rename(&tmp, &path)
282}
283
284impl PolicyFile {
285 /// Whether the named tool is on the block list.
286 pub fn is_blocked(&self, tool: &str) -> bool {
287 self.blocked_producers.iter().any(|p| p.tool == tool)
288 }
289
290 /// Look up the block entry, if any. Useful for "blocked
291 /// since X — reason: Y" rendering.
292 pub fn find(&self, tool: &str) -> Option<&BlockedProducer> {
293 self.blocked_producers.iter().find(|p| p.tool == tool)
294 }
295
296 /// Add a producer to the block list. Idempotent: blocking an
297 /// already-blocked tool is a no-op (preserves the original
298 /// `blocked_at`); the new reason is dropped. Callers that
299 /// want to update a reason should `unblock` then `block`.
300 pub fn block(&mut self, tool: String, reason: String, now: u64) {
301 if self.is_blocked(&tool) {
302 return;
303 }
304 self.blocked_producers.push(BlockedProducer {
305 tool,
306 reason,
307 blocked_at: now,
308 });
309 }
310
311 /// Remove a producer from the block list. Returns whether
312 /// the entry was present.
313 pub fn unblock(&mut self, tool: &str) -> bool {
314 let before = self.blocked_producers.len();
315 self.blocked_producers.retain(|p| p.tool != tool);
316 before != self.blocked_producers.len()
317 }
318
319 /// Add a `RequiredAttestation` rule. Idempotent on `(kind, when)`
320 /// — the same rule submitted twice is a single entry. Different
321 /// `when` clauses for the same `kind` are distinct rules and
322 /// stack (e.g. "always require Spec" plus "require SandboxRun
323 /// when effects intersect [io]").
324 pub fn require_attestation(
325 &mut self,
326 kind: RequiredAttestationKind,
327 when: AttestationCondition,
328 ) -> bool {
329 let new = RequiredAttestation {
330 kind,
331 when,
332 skip_if_producer_trust_thousandths_above: None,
333 };
334 if self.required_attestations.contains(&new) {
335 return false;
336 }
337 self.required_attestations.push(new);
338 true
339 }
340
341 /// Remove every rule with the given kind. Returns how many
342 /// rules were removed. Use this to drop a requirement entirely;
343 /// for narrowing a rule (e.g. `Always` → `EffectsIntersect`)
344 /// remove + re-add.
345 pub fn unrequire_attestation(&mut self, kind: RequiredAttestationKind) -> usize {
346 let before = self.required_attestations.len();
347 self.required_attestations
348 .retain(|r| r.kind != kind);
349 before - self.required_attestations.len()
350 }
351}
352
353// ---------------------------------------------------------------- gate
354
355/// Why a branch advance was refused by the [`required_attestations`]
356/// gate. Surfaced as `StoreError::BranchAdvanceBlocked` and as a
357/// structured envelope on the HTTP API.
358#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
359pub struct BranchAdvanceBlocked {
360 pub op_id: OpId,
361 /// Stage the gate was checking. `None` for ops that don't touch
362 /// a stage (imports, merges) — those are always allowed because
363 /// there's nothing to attest.
364 pub stage_id: Option<String>,
365 /// Tags of attestation kinds that were required but missing
366 /// (or present only as Failed/Inconclusive).
367 pub missing: Vec<String>,
368}
369
370impl BranchAdvanceBlocked {
371 /// Render as a structured JSON envelope. Used by the HTTP layer
372 /// and `lex` CLI's `--output json`.
373 pub fn to_envelope(&self) -> serde_json::Value {
374 serde_json::json!({
375 "error": "BranchAdvanceBlocked",
376 "op_id": self.op_id,
377 "stage_id": self.stage_id,
378 "missing": self.missing,
379 })
380 }
381}
382
383/// Verify that the candidate ops carry every required attestation.
384/// Called by [`crate::Store::apply_operation`] (and friends) between
385/// op persistence and branch-head advance.
386///
387/// `candidate` lists the ops *being added by this advance* — for the
388/// single-op apply path (today's only writer) it's a one-element
389/// slice. Each op is checked against the policy by walking the
390/// stage's attestation list once and matching on `op_id` and kind.
391///
392/// Ops without an attestable `stage_id` (imports, merges) pass the
393/// gate unconditionally — there's nothing to attest. The
394/// content-addressed merge resolution itself isn't where evidence
395/// belongs; the constituent stages on either side are.
396pub fn check_required_attestations(
397 log: &AttestationLog,
398 candidate: &[(OpId, Option<String>, BTreeSet<String>)],
399 policy: &PolicyFile,
400) -> Result<Vec<TrustWaiver>, BranchAdvanceBlocked> {
401 if policy.required_attestations.is_empty() {
402 return Ok(Vec::new());
403 }
404 // Compute the live trust ceiling once for the whole gate run.
405 // None if no `ProducerTrust` attestations exist or if every
406 // such tool is also blocked. Otherwise `Some((tool, score))`
407 // for the producer with the highest current score.
408 let trust_ceiling = max_live_producer_trust(log).map_err(|e| BranchAdvanceBlocked {
409 op_id: candidate.first().map(|(o, _, _)| o.clone()).unwrap_or_default(),
410 stage_id: None,
411 missing: vec![format!("io:{e}")],
412 })?;
413 let mut waivers: Vec<TrustWaiver> = Vec::new();
414
415 for (op_id, stage_id_opt, op_effects) in candidate {
416 let stage_id = match stage_id_opt {
417 Some(s) => s,
418 // No stage to attest — skip. The policy is per-stage;
419 // an import or merge doesn't have a verdict surface.
420 None => continue,
421 };
422 let attestations = log
423 .list_for_stage(stage_id)
424 .map_err(|e| BranchAdvanceBlocked {
425 op_id: op_id.clone(),
426 stage_id: Some(stage_id.clone()),
427 missing: vec![format!("io:{e}")],
428 })?;
429 let mut missing: Vec<String> = Vec::new();
430 for rule in &policy.required_attestations {
431 if !rule.when.applies(op_effects) {
432 continue;
433 }
434 // #293 trust waiver: if the rule has a threshold AND
435 // a live trusted producer exceeds it, skip the rule
436 // and record a waiver for emission.
437 if let Some(threshold) = rule.skip_if_producer_trust_thousandths_above {
438 if let Some((producer, score)) = &trust_ceiling {
439 if *score > threshold {
440 waivers.push(TrustWaiver {
441 stage_id: stage_id.clone(),
442 producer: producer.clone(),
443 score_thousandths: *score,
444 threshold_thousandths: threshold,
445 kind_tag: rule.kind.tag().into(),
446 });
447 continue;
448 }
449 }
450 }
451 let satisfied = attestations.iter().any(|a| {
452 a.op_id.as_deref() == Some(op_id.as_str())
453 && rule.kind.matches(&a.kind)
454 && passed(&a.result)
455 });
456 if !satisfied {
457 missing.push(rule.kind.tag().to_string());
458 }
459 }
460 if !missing.is_empty() {
461 // De-dup in case the same kind is required twice with
462 // different `when` clauses; the user only needs to
463 // surface it once.
464 missing.sort();
465 missing.dedup();
466 return Err(BranchAdvanceBlocked {
467 op_id: op_id.clone(),
468 stage_id: Some(stage_id.clone()),
469 missing,
470 });
471 }
472 }
473 Ok(waivers)
474}
475
476/// Record of a single trust-based waiver from
477/// [`check_required_attestations`] (#293). The store emits a
478/// `TrustWaived` attestation per waiver after the gate succeeds
479/// so the audit trail captures every skip.
480#[derive(Debug, Clone, PartialEq, Eq)]
481pub struct TrustWaiver {
482 pub stage_id: String,
483 pub producer: String,
484 pub score_thousandths: u32,
485 pub threshold_thousandths: u32,
486 pub kind_tag: String,
487}
488
489/// Scan the attestation log for the highest live
490/// `ProducerTrust` score (#293). "Live" means the trusted tool
491/// does not have an active [`AttestationKind::ProducerBlock`] —
492/// the block wins as a hard veto over trust.
493///
494/// Returns `Some((tool_id, score_thousandths))` for the highest
495/// live trust, or `None` if no tool currently has trust.
496fn max_live_producer_trust(
497 log: &AttestationLog,
498) -> std::io::Result<Option<(String, u32)>> {
499 let all = log.list_all()?;
500 // For each tool with a `ProducerTrust`, take the highest
501 // recorded score across that tool's history. Multiple
502 // recompute runs append; the latest by timestamp wins.
503 use std::collections::BTreeMap;
504 let mut latest: BTreeMap<String, (u64, u32)> = BTreeMap::new();
505 for a in &all {
506 let AttestationKind::ProducerTrust { tool_id, score_thousandths, .. } = &a.kind else { continue };
507 let entry = latest.entry(tool_id.clone()).or_insert((0, 0));
508 if a.timestamp >= entry.0 {
509 *entry = (a.timestamp, *score_thousandths);
510 }
511 }
512 let mut best: Option<(String, u32)> = None;
513 for (tool, (_, score)) in latest {
514 if active_producer_block(&all, &tool).is_some() {
515 // Blocked — ignore even if score is high.
516 continue;
517 }
518 match &best {
519 None => best = Some((tool, score)),
520 Some((_, b)) if score > *b => best = Some((tool, score)),
521 _ => {}
522 }
523 }
524 Ok(best)
525}
526
527fn passed(r: &AttestationResult) -> bool {
528 matches!(r, AttestationResult::Passed)
529}
530
531#[allow(dead_code)]
532fn _force_use(_: Attestation) {} // keep unused-import warning quiet across feature flips
533
534// ----------------------------------------------- producer-block gate (#248)
535
536/// Why a branch advance was refused by the
537/// [`check_producer_block`] gate. Surfaced as
538/// `StoreError::ProducerBlocked` and as a distinct
539/// `ProducerBlocked` envelope on the HTTP API.
540///
541/// Distinct from [`BranchAdvanceBlocked`] (#245's positive
542/// attestation gate) because the response shape is different:
543/// the operator needs to know *which producer* was blocked and
544/// *which attestation* tripped the gate, not just "an
545/// attestation was missing."
546#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
547pub struct ProducerBlocked {
548 pub op_id: OpId,
549 /// Stage the offending attestation was about. Always set —
550 /// `ProducerBlock` only fires when a candidate op has at
551 /// least one stage with attestations.
552 pub stage_id: String,
553 /// Tool that's been retroactively quarantined.
554 pub tool_id: String,
555 /// Cutoff from the most recent `AttestationKind::ProducerBlock`
556 /// for this tool.
557 pub blocked_at: u64,
558 /// Wall-clock timestamp of the offending attestation. Always
559 /// `>= blocked_at`; otherwise the gate wouldn't have fired.
560 pub attestation_at: u64,
561 /// `attestation_id` of the offending attestation, so the
562 /// operator can drill in via `lex attest filter`.
563 pub attestation_id: String,
564}
565
566impl ProducerBlocked {
567 /// Render as a structured JSON envelope. Distinct `error`
568 /// discriminator (`ProducerBlocked`) so the HTTP layer can
569 /// route it to a different status code or recovery path
570 /// from `BranchAdvanceBlocked`.
571 pub fn to_envelope(&self) -> serde_json::Value {
572 serde_json::json!({
573 "error": "ProducerBlocked",
574 "op_id": self.op_id,
575 "stage_id": self.stage_id,
576 "tool_id": self.tool_id,
577 "blocked_at": self.blocked_at,
578 "attestation_at": self.attestation_at,
579 "attestation_id": self.attestation_id,
580 })
581 }
582}
583
584/// Verify that the candidate ops are not contaminated by
585/// attestations from a retroactively quarantined producer (#248).
586///
587/// Algorithm:
588///
589/// 1. Walk the entire attestation log to collect every
590/// `ProducerBlock` / `ProducerUnblock` record. Build a map
591/// `tool_id → Some(blocked_at)` reflecting the latest verdict
592/// per tool.
593/// 2. For each candidate op, list attestations on its
594/// `stage_id`. For every attestation produced by a currently-
595/// blocked tool with `attestation.timestamp >= block.blocked_at`,
596/// refuse the advance with the offending row's details.
597///
598/// Cost is `O(total attestations)` for step 1 — fine for small
599/// stores; a `by-tool` index becomes worthwhile if the producer
600/// list grows past dozens. Step 2 is `O(attestations on the
601/// candidate stage)` per op, dominated by step 1 for the common
602/// case of a single-op advance.
603pub fn check_producer_block(
604 log: &AttestationLog,
605 candidate: &[(OpId, Option<String>, BTreeSet<String>)],
606) -> Result<(), ProducerBlocked> {
607 // Step 1: build the active-block map.
608 let all = match log.list_all() {
609 Ok(v) => v,
610 // Empty log = nothing to check.
611 Err(_) => return Ok(()),
612 };
613 use std::collections::HashMap;
614 let mut active: HashMap<String, u64> = HashMap::new();
615 // Process in timestamp order so the latest verdict per tool
616 // wins. Ties go to ProducerUnblock (matches the spirit of
617 // is_stage_blocked).
618 let mut ordered: Vec<&Attestation> = all.iter().collect();
619 ordered.sort_by(|a, b| {
620 a.timestamp.cmp(&b.timestamp).then_with(|| {
621 // Same timestamp: ProducerUnblock sorts after
622 // ProducerBlock so it wins the "latest" check.
623 let a_unblock = matches!(a.kind, AttestationKind::ProducerUnblock { .. });
624 let b_unblock = matches!(b.kind, AttestationKind::ProducerUnblock { .. });
625 a_unblock.cmp(&b_unblock)
626 })
627 });
628 for a in ordered {
629 match &a.kind {
630 AttestationKind::ProducerBlock { tool_id, blocked_at, .. } => {
631 active.insert(tool_id.clone(), *blocked_at);
632 }
633 AttestationKind::ProducerUnblock { tool_id, .. } => {
634 active.remove(tool_id);
635 }
636 _ => {}
637 }
638 }
639 if active.is_empty() {
640 return Ok(());
641 }
642
643 // Step 2: per-op attestation walk.
644 for (op_id, stage_id_opt, _) in candidate {
645 let stage_id = match stage_id_opt {
646 Some(s) => s,
647 None => continue,
648 };
649 let attestations = match log.list_for_stage(stage_id) {
650 Ok(v) => v,
651 Err(_) => continue,
652 };
653 for a in attestations {
654 // Self-references (a producer's own ProducerBlock /
655 // Unblock attestations are stored at stage_id ==
656 // tool_id) shouldn't be flagged as contamination.
657 if matches!(
658 a.kind,
659 AttestationKind::ProducerBlock { .. }
660 | AttestationKind::ProducerUnblock { .. }
661 ) {
662 continue;
663 }
664 if let Some(&blocked_at) = active.get(&a.produced_by.tool) {
665 if a.timestamp >= blocked_at {
666 return Err(ProducerBlocked {
667 op_id: op_id.clone(),
668 stage_id: stage_id.clone(),
669 tool_id: a.produced_by.tool.clone(),
670 blocked_at,
671 attestation_at: a.timestamp,
672 attestation_id: a.attestation_id.clone(),
673 });
674 }
675 }
676 }
677 }
678 Ok(())
679}
680
681#[cfg(test)]
682mod tests {
683 use super::*;
684 use tempfile::tempdir;
685
686 #[test]
687 fn load_absent_returns_none() {
688 let tmp = tempdir().unwrap();
689 assert!(load(tmp.path()).unwrap().is_none());
690 }
691
692 #[test]
693 fn round_trip_through_disk() {
694 let tmp = tempdir().unwrap();
695 let mut f = PolicyFile::default();
696 f.block("bot-a".into(), "false positives".into(), 1000);
697 f.block("bot-b".into(), "stale model".into(), 2000);
698 save(tmp.path(), &f).unwrap();
699 let got = load(tmp.path()).unwrap().unwrap();
700 assert_eq!(got, f);
701 assert!(got.is_blocked("bot-a"));
702 assert!(!got.is_blocked("not-blocked"));
703 assert_eq!(got.find("bot-b").unwrap().reason, "stale model");
704 }
705
706 #[test]
707 fn block_is_idempotent() {
708 let mut f = PolicyFile::default();
709 f.block("bot".into(), "first reason".into(), 100);
710 f.block("bot".into(), "second reason — ignored".into(), 200);
711 assert_eq!(f.blocked_producers.len(), 1);
712 // Original blocked_at + reason preserved.
713 let entry = f.find("bot").unwrap();
714 assert_eq!(entry.blocked_at, 100);
715 assert_eq!(entry.reason, "first reason");
716 }
717
718 #[test]
719 fn unblock_removes_entry() {
720 let mut f = PolicyFile::default();
721 f.block("bot".into(), "x".into(), 1);
722 assert!(f.unblock("bot"));
723 assert!(!f.is_blocked("bot"));
724 // Second unblock is a no-op and returns false.
725 assert!(!f.unblock("bot"));
726 }
727
728 #[test]
729 fn malformed_json_is_an_error() {
730 let tmp = tempdir().unwrap();
731 std::fs::write(tmp.path().join("policy.json"), "{ not json").unwrap();
732 let err = load(tmp.path()).unwrap_err();
733 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
734 }
735}