Skip to main content

truth_mirror/
gate.rs

1//! Deterministic pre-push and repository safety gates.
2
3use std::{
4    collections::BTreeSet,
5    fs,
6    path::{Path, PathBuf},
7    process::{Command, ExitCode},
8};
9
10use anyhow::{Context, Result, bail};
11
12use crate::{
13    claim, cli,
14    ledger::{LedgerEntry, LedgerStore},
15    reviewer::{ReviewQueue, ReviewRunStore},
16};
17
18pub fn run(
19    args: cli::GateArgs,
20    state_dir: &Path,
21    config_path: Option<&Path>,
22    config: &crate::config::TruthMirrorConfig,
23) -> Result<ExitCode> {
24    if let Some(range) = args.pre_push {
25        warn_pending_reviews(state_dir, config_path);
26        let all_commits = range == "all";
27        let commits = commits_for_range(&range)?;
28        match pre_push_decision(&LedgerStore::new(state_dir), &commits, all_commits)? {
29            PushGateDecision::Allow => {
30                block_for_pending_memory_skill(state_dir, config, all_commits, &commits)?;
31                return Ok(ExitCode::SUCCESS);
32            }
33            PushGateDecision::Block(entries) => {
34                bail!(
35                    "pre-push blocked unresolved rejection(s): {}",
36                    rejection_summary(&entries)
37                );
38            }
39        }
40    }
41
42    if args.pre_tool_use {
43        return pre_tool_use_gate(args, state_dir, config);
44    }
45
46    let commit_msg_path = args
47        .commit_msg
48        .as_ref()
49        .context("gate requires --commit-msg, --pre-push, or --pre-tool-use")?;
50    let commit_message = fs::read_to_string(commit_msg_path).with_context(|| {
51        format!(
52            "failed to read commit message {}",
53            commit_msg_path.display()
54        )
55    })?;
56
57    let claim_file = read_optional_file(args.claim_file.as_ref(), "claim file")?;
58    let diff_file = read_optional_file(args.diff_file.as_ref(), "diff file")?;
59
60    let mut policy = config.gates.to_policy();
61    if !args.fake_markers.is_empty() {
62        // CLI markers are additive on top of the resolved policy.
63        for marker in &args.fake_markers {
64            if !policy
65                .fake_markers
66                .iter()
67                .any(|existing| existing == marker)
68            {
69                policy.fake_markers.push(marker.clone());
70            }
71        }
72    }
73
74    claim::evaluate_commit_message(
75        &commit_message,
76        claim_file.as_deref(),
77        diff_file.as_deref(),
78        &policy,
79    )?;
80
81    Ok(ExitCode::SUCCESS)
82}
83
84/// Age threshold beyond which a pending review with no running watcher is
85/// considered evidence of a dead/absent watcher daemon (5 minutes).
86const WATCHER_DEAD_THRESHOLD_SECS: u64 = 300;
87
88fn warn_pending_reviews(state_dir: &Path, config_path: Option<&Path>) {
89    let summary = match ReviewQueue::new(state_dir).summary() {
90        Ok(summary) => summary,
91        Err(error) => {
92            eprintln!(
93                "{} warning: could not read async review queue for advisory pre-push warning: {error}",
94                crate::messages::diagnostic_prefix()
95            );
96            return;
97        }
98    };
99    if summary.pending_count == 0 {
100        return;
101    }
102    let now = crate::time::unix_now();
103    let oldest_age = summary.oldest_age_secs_at(now);
104    let oldest = oldest_age.map_or_else(|| "unknown age".to_owned(), |age| format!("{age}s old"));
105    eprintln!(
106        "{} warning: {} unreviewed claim(s) queued, oldest {oldest}. {}.",
107        crate::messages::diagnostic_prefix(),
108        summary.pending_count,
109        crate::messages::async_review_drain_hint_for_cli(config_path, state_dir)
110    );
111
112    // Warn when the queue has aged beyond the threshold with no evidence of a
113    // running watcher. A stale queue strongly suggests the watcher daemon is
114    // dead and verdicts will not arrive without manual intervention.
115    let appears_dead = oldest_age.is_some_and(|age| age > WATCHER_DEAD_THRESHOLD_SECS);
116    if appears_dead {
117        let has_running = ReviewRunStore::new(state_dir)
118            .status_counts()
119            .is_ok_and(|counts| counts.running > 0);
120        if !has_running {
121            eprintln!(
122                "{} warning: async review appears DEAD — oldest pending review is {oldest} \
123                 with no running watcher detected. Restart the watcher or drain the queue manually.",
124                crate::messages::diagnostic_prefix(),
125            );
126        }
127    }
128}
129
130fn pre_tool_use_gate(
131    args: cli::GateArgs,
132    state_dir: &Path,
133    config: &crate::config::TruthMirrorConfig,
134) -> Result<ExitCode> {
135    let unresolved = LedgerStore::new(state_dir).unresolved_rejections()?;
136    let count = u32::try_from(unresolved.len()).unwrap_or(u32::MAX);
137    let oldest_age = unresolved
138        .iter()
139        .map(|entry| entry.created_at_unix)
140        .min()
141        .map(|oldest| crate::time::unix_now().saturating_sub(oldest));
142
143    // Tool name comes from `--tool`, else the agent's PreToolUse hook stdin JSON.
144    // A hook payload we cannot parse fails CLOSED (treated as mutating): a safety
145    // gate must not silently allow a tool just because its name was unreadable.
146    let (tool, mutating) = match args.tool {
147        Some(name) => {
148            let mutating = crate::enforcement::is_mutating_tool(
149                &name,
150                crate::enforcement::DEFAULT_MUTATING_TOOLS,
151            );
152            (name, mutating)
153        }
154        None => match resolve_tool_from_stdin() {
155            ResolvedTool::Named(name) => {
156                let mutating = crate::enforcement::is_mutating_tool(
157                    &name,
158                    crate::enforcement::DEFAULT_MUTATING_TOOLS,
159                );
160                (name, mutating)
161            }
162            // Payload present but unreadable -> fail closed.
163            ResolvedTool::Unknown => ("<unparseable-hook-payload>".to_owned(), true),
164            // No payload at all (manual invocation) -> nothing to gate.
165            ResolvedTool::None => (String::new(), false),
166        },
167    };
168
169    match crate::enforcement::pre_tool_use_decision(
170        count,
171        oldest_age,
172        mutating,
173        &config.enforcement,
174    ) {
175        crate::enforcement::ToolGateDecision::Allow => Ok(ExitCode::SUCCESS),
176        crate::enforcement::ToolGateDecision::Block { reason } => {
177            // Exit code 2 is the agent-hook block convention (Claude Code / Codex
178            // PreToolUse); the message goes to stderr for the model to read.
179            eprintln!(
180                "{} blocked tool {tool:?}: {reason}. Resolve or waive the ledger to continue.",
181                crate::messages::diagnostic_prefix()
182            );
183            Ok(ExitCode::from(2))
184        }
185    }
186}
187
188/// Outcome of resolving the tool name from a PreToolUse hook stdin payload.
189enum ResolvedTool {
190    /// The tool name was read from the payload.
191    Named(String),
192    /// A payload was present but no tool name could be parsed — fail closed.
193    Unknown,
194    /// No payload at all (manual invocation) — nothing to gate.
195    None,
196}
197
198fn resolve_tool_from_stdin() -> ResolvedTool {
199    use std::io::{IsTerminal, Read};
200    if std::io::stdin().is_terminal() {
201        return ResolvedTool::None;
202    }
203    let mut buffer = String::new();
204    // A payload that cannot even be read (e.g. non-UTF8) fails CLOSED, not open.
205    if std::io::stdin().read_to_string(&mut buffer).is_err() {
206        return ResolvedTool::Unknown;
207    }
208    if buffer.trim().is_empty() {
209        return ResolvedTool::None;
210    }
211
212    match serde_json::from_str::<serde_json::Value>(&buffer)
213        .ok()
214        .and_then(|value| {
215            value
216                .get("tool_name")
217                .or_else(|| value.get("toolName"))
218                .or_else(|| value.get("tool"))
219                .and_then(|name| name.as_str())
220                .map(str::to_owned)
221        }) {
222        Some(name) if !name.trim().is_empty() => ResolvedTool::Named(name),
223        // Payload present but no recognizable tool field: fail closed.
224        _ => ResolvedTool::Unknown,
225    }
226}
227
228#[derive(Clone, Debug, Eq, PartialEq)]
229pub enum PushGateDecision {
230    Allow,
231    Block(Vec<LedgerEntry>),
232}
233
234fn block_for_pending_memory_skill(
235    state_dir: &Path,
236    config: &crate::config::TruthMirrorConfig,
237    all_commits: bool,
238    commits: &[String],
239) -> Result<()> {
240    if !config.memory_skill.effective_enabled(&config.skills)
241        || !config.memory_skill.pre_push_blocks_pending
242    {
243        return Ok(());
244    }
245
246    let store = crate::memory_skill::MemorySkillStore::new(state_dir, &config.memory_skill)?;
247    let pending = pending_memory_skill_candidates(&store, all_commits, commits)?;
248    if pending.is_empty() {
249        return Ok(());
250    }
251
252    bail!(
253        "pre-push blocked pending memory-skill candidate(s): {}",
254        pending
255            .iter()
256            .map(|candidate| format!("{} {}", candidate.id, candidate.source_commit))
257            .collect::<Vec<_>>()
258            .join("; ")
259    );
260}
261
262fn pending_memory_skill_candidates(
263    store: &crate::memory_skill::MemorySkillStore,
264    all_commits: bool,
265    commits: &[String],
266) -> Result<Vec<crate::memory_skill::MemorySkillCandidate>> {
267    if all_commits {
268        return Ok(store.pending_candidates()?);
269    }
270
271    let commit_set = commits.iter().map(String::as_str).collect::<BTreeSet<_>>();
272    Ok(store.pending_for_commits(&commit_set)?)
273}
274
275pub fn pre_push_decision(
276    store: &LedgerStore,
277    commits: &[String],
278    all_commits: bool,
279) -> Result<PushGateDecision> {
280    let commit_set = commits.iter().map(String::as_str).collect::<BTreeSet<_>>();
281    let blocked = store
282        .unresolved_rejections()?
283        .into_iter()
284        .filter(|entry| all_commits || commit_set.contains(entry.commit_sha.as_str()))
285        .collect::<Vec<_>>();
286
287    if blocked.is_empty() {
288        Ok(PushGateDecision::Allow)
289    } else {
290        Ok(PushGateDecision::Block(blocked))
291    }
292}
293
294fn commits_for_range(range: &str) -> Result<Vec<String>> {
295    commits_for_range_in(range, Path::new("."))
296}
297
298fn commits_for_range_in(range: &str, cwd: &Path) -> Result<Vec<String>> {
299    if range == "all" {
300        return Ok(Vec::new());
301    }
302
303    if let Some(new_branch) = range.strip_prefix("new:") {
304        return match new_branch.split_once(':') {
305            Some((remote, local_sha)) => {
306                let remote = remote.trim();
307                let local_sha = local_sha.trim();
308                if remote.is_empty()
309                    || remote
310                        .chars()
311                        .any(|character| matches!(character, '*' | '?' | '['))
312                    || local_sha.is_empty()
313                    || local_sha.contains(':')
314                    || local_sha.starts_with('-')
315                {
316                    bail!(
317                        "malformed new-branch pre-push range {range}: expected new:<remote>:<sha>"
318                    );
319                }
320                git_rev_list_for_new_branch_on_remote(cwd, remote, local_sha, range)
321            }
322            _ => git_rev_list(cwd, &[new_branch, "--not", "--remotes"], range),
323        };
324    }
325
326    if !range.contains("..") {
327        return Ok(vec![range.to_owned()]);
328    }
329
330    git_rev_list(cwd, &[range], range)
331}
332
333fn git_rev_list(cwd: &Path, args: &[&str], label: &str) -> Result<Vec<String>> {
334    let output = Command::new("git")
335        .arg("rev-list")
336        .args(args)
337        .current_dir(cwd)
338        .output()
339        .context("failed to run git rev-list for pre-push range")?;
340    if !output.status.success() {
341        bail!(
342            "git rev-list failed for pre-push range {label}: {}",
343            String::from_utf8_lossy(&output.stderr)
344        );
345    }
346
347    Ok(String::from_utf8_lossy(&output.stdout)
348        .lines()
349        .map(str::trim)
350        .filter(|line| !line.is_empty())
351        .map(str::to_owned)
352        .collect())
353}
354
355fn git_rev_list_for_new_branch_on_remote(
356    cwd: &Path,
357    remote: &str,
358    local_sha: &str,
359    label: &str,
360) -> Result<Vec<String>> {
361    let remote_refs = remote_tracking_refs(cwd, remote)?;
362    let mut args = vec![local_sha.to_owned()];
363    if !remote_refs.is_empty() {
364        args.push("--not".to_owned());
365        args.extend(remote_refs);
366    }
367    let args = args.iter().map(String::as_str).collect::<Vec<_>>();
368    git_rev_list(cwd, &args, label)
369}
370
371fn remote_tracking_refs(cwd: &Path, remote: &str) -> Result<Vec<String>> {
372    let prefix = format!("refs/remotes/{remote}/");
373    let output = Command::new("git")
374        .args(["for-each-ref", "--format=%(refname)", &prefix])
375        .current_dir(cwd)
376        .output()
377        .context("failed to list remote refs for pre-push range")?;
378    if !output.status.success() {
379        bail!(
380            "git for-each-ref failed for pre-push remote {remote}: {}",
381            String::from_utf8_lossy(&output.stderr)
382        );
383    }
384
385    Ok(String::from_utf8_lossy(&output.stdout)
386        .lines()
387        .map(str::trim)
388        .filter(|line| !line.is_empty())
389        .filter(|line| line.starts_with(&prefix))
390        .map(str::to_owned)
391        .collect())
392}
393
394fn rejection_summary(entries: &[LedgerEntry]) -> String {
395    entries
396        .iter()
397        .map(|entry| format!("{} {}", entry.commit_sha, entry.claim))
398        .collect::<Vec<_>>()
399        .join("; ")
400}
401
402fn read_optional_file(path: Option<&PathBuf>, label: &str) -> Result<Option<String>> {
403    path.map(|path| {
404        fs::read_to_string(path)
405            .with_context(|| format!("failed to read {label} {}", path.display()))
406    })
407    .transpose()
408}
409
410#[cfg(test)]
411mod tests {
412    use std::{fs, path::Path, process::Command};
413
414    use proptest::prelude::*;
415
416    use crate::{
417        config::{MemorySkillCandidateKind, MemorySkillConfig, TruthMirrorConfig},
418        ledger::{LedgerEntry, LedgerStore, ReviewerConfig, Verdict},
419        memory_skill::{
420            CANDIDATE_SCHEMA_VERSION, CandidateStatus, EvidenceRef, MemorySkillCandidate,
421            MemorySkillStore,
422        },
423    };
424
425    use super::{
426        PushGateDecision, block_for_pending_memory_skill, commits_for_range_in,
427        pending_memory_skill_candidates, pre_push_decision,
428    };
429
430    fn reject_entry(sha: &str) -> LedgerEntry {
431        LedgerEntry::new_at(
432            sha,
433            Verdict::Reject,
434            "CLAIM: bad | verified: cargo test | evidence: tests:cargo-test",
435            vec!["tests:cargo-test".to_owned()],
436            ReviewerConfig::new("claude", "opus", false),
437            vec!["unsupported".to_owned()],
438            100,
439        )
440    }
441
442    #[test]
443    fn pre_push_blocks_unresolved_rejection_in_range() {
444        let temp = tempfile::tempdir().unwrap();
445        let store = LedgerStore::new(temp.path());
446        store.append_entry(&reject_entry("abc123")).unwrap();
447
448        let decision = pre_push_decision(&store, &["abc123".to_owned()], false).unwrap();
449
450        assert!(matches!(decision, PushGateDecision::Block(_)));
451    }
452
453    #[test]
454    fn pre_push_all_blocks_any_unresolved_rejection() {
455        let temp = tempfile::tempdir().unwrap();
456        let store = LedgerStore::new(temp.path());
457        store.append_entry(&reject_entry("abc123")).unwrap();
458
459        let decision = pre_push_decision(&store, &[], true).unwrap();
460
461        assert!(matches!(decision, PushGateDecision::Block(_)));
462    }
463
464    #[test]
465    fn pre_push_empty_range_without_all_does_not_block_everything() {
466        let temp = tempfile::tempdir().unwrap();
467        let store = LedgerStore::new(temp.path());
468        store.append_entry(&reject_entry("abc123")).unwrap();
469
470        let decision = pre_push_decision(&store, &[], false).unwrap();
471
472        assert_eq!(decision, PushGateDecision::Allow);
473    }
474
475    #[test]
476    fn pre_push_allows_after_resolve_or_waive() {
477        let temp = tempfile::tempdir().unwrap();
478        let store = LedgerStore::new(temp.path());
479        store.append_entry(&reject_entry("abc123")).unwrap();
480        store.resolve("abc123").unwrap();
481
482        let decision = pre_push_decision(&store, &["abc123".to_owned()], false).unwrap();
483
484        assert_eq!(decision, PushGateDecision::Allow);
485    }
486
487    #[test]
488    fn pending_memory_skill_filter_honors_commit_set() {
489        let temp = tempfile::tempdir().unwrap();
490        let config = MemorySkillConfig {
491            candidate_dir: "skills/candidates".to_owned(),
492            ..MemorySkillConfig::default()
493        };
494        let store = MemorySkillStore::new(temp.path(), &config).unwrap();
495        store
496            .write_candidate(&candidate_for_commit("candidate-keep", "keep123"))
497            .unwrap();
498        store
499            .write_candidate(&candidate_for_commit("candidate-skip", "skip123"))
500            .unwrap();
501
502        let commits = vec!["keep123".to_owned()];
503        let pending = pending_memory_skill_candidates(&store, false, &commits).unwrap();
504        let all_pending = pending_memory_skill_candidates(&store, true, &[]).unwrap();
505
506        assert_eq!(
507            pending
508                .iter()
509                .map(|candidate| candidate.id.as_str())
510                .collect::<Vec<_>>(),
511            vec!["candidate-keep"]
512        );
513        assert_eq!(all_pending.len(), 2);
514    }
515
516    #[test]
517    fn pending_memory_skill_gate_honors_enable_and_block_flags() {
518        let temp = tempfile::tempdir().unwrap();
519        let mut config = TruthMirrorConfig::default();
520        config.skills.enabled = true;
521        config.memory_skill.enabled = true;
522        config.memory_skill.pre_push_blocks_pending = true;
523        let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
524        store
525            .write_candidate(&candidate_for_commit("candidate-keep", "keep123"))
526            .unwrap();
527        let commits = vec!["keep123".to_owned()];
528
529        config.memory_skill.enabled = false;
530        block_for_pending_memory_skill(temp.path(), &config, false, &commits).unwrap();
531
532        config.memory_skill.enabled = true;
533        config.memory_skill.pre_push_blocks_pending = false;
534        block_for_pending_memory_skill(temp.path(), &config, false, &commits).unwrap();
535
536        config.memory_skill.pre_push_blocks_pending = true;
537        let error = block_for_pending_memory_skill(temp.path(), &config, false, &commits)
538            .unwrap_err()
539            .to_string();
540
541        assert!(error.contains("pending memory-skill candidate"));
542    }
543
544    #[test]
545    fn new_branch_range_expands_to_all_unpushed_commits() {
546        let temp = tempfile::tempdir().unwrap();
547        let repo = temp.path();
548        git(repo, &["init"]);
549        git(repo, &["config", "user.email", "truth@example.invalid"]);
550        git(repo, &["config", "user.name", "Truth Mirror Test"]);
551        fs::write(repo.join("one.txt"), "one\n").unwrap();
552        git(repo, &["add", "one.txt"]);
553        git(repo, &["commit", "-m", "one"]);
554        let first = git_stdout(repo, &["rev-parse", "HEAD"]);
555        fs::write(repo.join("two.txt"), "two\n").unwrap();
556        git(repo, &["add", "two.txt"]);
557        git(repo, &["commit", "-m", "two"]);
558        let second = git_stdout(repo, &["rev-parse", "HEAD"]);
559
560        let commits = commits_for_range_in(&format!("new:{second}"), repo).unwrap();
561
562        assert_eq!(commits, vec![second, first]);
563    }
564
565    #[test]
566    fn new_branch_range_excludes_only_target_remote_refs_when_remote_is_named() {
567        let temp = tempfile::tempdir().unwrap();
568        let repo = temp.path();
569        git(repo, &["init"]);
570        git(repo, &["config", "user.email", "truth@example.invalid"]);
571        git(repo, &["config", "user.name", "Truth Mirror Test"]);
572        fs::write(repo.join("one.txt"), "one\n").unwrap();
573        git(repo, &["add", "one.txt"]);
574        git(repo, &["commit", "-m", "one"]);
575        let first = git_stdout(repo, &["rev-parse", "HEAD"]);
576        fs::write(repo.join("two.txt"), "two\n").unwrap();
577        git(repo, &["add", "two.txt"]);
578        git(repo, &["commit", "-m", "two"]);
579        let second = git_stdout(repo, &["rev-parse", "HEAD"]);
580        git(repo, &["update-ref", "refs/remotes/backup/main", &first]);
581
582        let missing_from_origin =
583            commits_for_range_in(&format!("new:origin:{second}"), repo).unwrap();
584        let missing_with_whitespace =
585            commits_for_range_in(&format!("new: origin : {second} "), repo).unwrap();
586        git(repo, &["update-ref", "refs/remotes/origin/main", &first]);
587        let missing_after_origin_has_first =
588            commits_for_range_in(&format!("new:origin:{second}"), repo).unwrap();
589
590        assert_eq!(missing_from_origin, vec![second.clone(), first.clone()]);
591        assert_eq!(missing_with_whitespace, vec![second.clone(), first]);
592        assert_eq!(missing_after_origin_has_first, vec![second]);
593    }
594
595    #[test]
596    fn malformed_remote_new_branch_range_fails_before_git_rev_list() {
597        let temp = tempfile::tempdir().unwrap();
598        let repo = temp.path();
599        git(repo, &["init"]);
600
601        let error = commits_for_range_in("new::abc123", repo)
602            .unwrap_err()
603            .to_string();
604        let glob_error = commits_for_range_in("new:or*:abc123", repo)
605            .unwrap_err()
606            .to_string();
607        let option_like_sha_error = commits_for_range_in("new:origin:--output=bad", repo)
608            .unwrap_err()
609            .to_string();
610
611        assert!(error.contains("malformed new-branch pre-push range"));
612        assert!(glob_error.contains("malformed new-branch pre-push range"));
613        assert!(option_like_sha_error.contains("malformed new-branch pre-push range"));
614    }
615
616    fn git(repo: &Path, args: &[&str]) {
617        let output = Command::new("git")
618            .args(args)
619            .current_dir(repo)
620            .output()
621            .unwrap();
622        assert!(
623            output.status.success(),
624            "git {args:?} failed\nstdout:\n{}\nstderr:\n{}",
625            String::from_utf8_lossy(&output.stdout),
626            String::from_utf8_lossy(&output.stderr)
627        );
628    }
629
630    fn git_stdout(repo: &Path, args: &[&str]) -> String {
631        let output = Command::new("git")
632            .args(args)
633            .current_dir(repo)
634            .output()
635            .unwrap();
636        assert!(
637            output.status.success(),
638            "git {args:?} failed\nstdout:\n{}\nstderr:\n{}",
639            String::from_utf8_lossy(&output.stdout),
640            String::from_utf8_lossy(&output.stderr)
641        );
642        String::from_utf8(output.stdout).unwrap().trim().to_owned()
643    }
644
645    fn candidate_for_commit(id: &str, sha: &str) -> MemorySkillCandidate {
646        let ledger_ref = EvidenceRef::ledger(sha);
647        MemorySkillCandidate {
648            schema_version: CANDIDATE_SCHEMA_VERSION,
649            id: id.to_owned(),
650            fingerprint: format!("fingerprint-{id}"),
651            learning_key: id.to_owned(),
652            status: CandidateStatus::Pending,
653            candidate_kind: MemorySkillCandidateKind::HowToSkill,
654            scope: "project".to_owned(),
655            source_commit: sha.to_owned(),
656            source_claim: format!("CLAIM: {id} | verified: cargo test | evidence: tests:{id}"),
657            truth_label: Verdict::Pass,
658            ledger_entry_ref: ledger_ref.clone(),
659            source_run_ref: EvidenceRef::run("run-1"),
660            occurrence_count: 1,
661            occurrence_refs: vec![ledger_ref.clone()],
662            slug: id.to_owned(),
663            title: id.to_owned(),
664            description: "test candidate".to_owned(),
665            when_to_use: vec!["When testing pending candidate filtering.".to_owned()],
666            procedure_steps: vec!["Run the pending candidate filter.".to_owned()],
667            pitfalls: vec!["Do not include unrelated commits.".to_owned()],
668            verification_steps: vec!["Assert only matching commits remain.".to_owned()],
669            evidence: vec![ledger_ref],
670            created_at_unix: 100,
671        }
672    }
673
674    proptest! {
675        #[test]
676        fn pushed_range_decision_blocks_only_matching_unresolved_sha(
677            rejected_sha in "[a-f0-9]{7,40}",
678            other_sha in "[a-f0-9]{7,40}",
679        ) {
680            prop_assume!(rejected_sha != other_sha);
681            let temp = tempfile::tempdir().unwrap();
682            let store = LedgerStore::new(temp.path());
683            store.append_entry(&reject_entry(&rejected_sha)).unwrap();
684
685            let unrelated = pre_push_decision(&store, std::slice::from_ref(&other_sha), false).unwrap();
686            prop_assert_eq!(unrelated, PushGateDecision::Allow);
687
688            let blocked = pre_push_decision(&store, std::slice::from_ref(&rejected_sha), false).unwrap();
689            prop_assert!(matches!(blocked, PushGateDecision::Block(_)));
690        }
691    }
692}