Skip to main content

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}