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).blocking_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            let message = format!(
178                "{} blocked tool {tool:?}: {reason}. Resolve or waive the ledger to continue.",
179                crate::messages::diagnostic_prefix()
180            );
181            // Grok PreToolUse requires a JSON decision on stdout; fail-open otherwise.
182            // Claude/Codex use exit 2 + stderr. Emit both when under Grok so the
183            // deny is structured; keep stderr for scrollback in all harnesses.
184            if crate::reinject::under_grok_from_env() {
185                let payload = serde_json::json!({
186                    "decision": "deny",
187                    "reason": message,
188                });
189                println!("{payload}");
190            }
191            eprintln!("{message}");
192            Ok(ExitCode::from(2))
193        }
194    }
195}
196
197/// Outcome of resolving the tool name from a PreToolUse hook stdin payload.
198enum ResolvedTool {
199    /// The tool name was read from the payload.
200    Named(String),
201    /// A payload was present but no tool name could be parsed — fail closed.
202    Unknown,
203    /// No payload at all (manual invocation) — nothing to gate.
204    None,
205}
206
207fn resolve_tool_from_stdin() -> ResolvedTool {
208    use std::io::{IsTerminal, Read};
209    if std::io::stdin().is_terminal() {
210        return ResolvedTool::None;
211    }
212    let mut buffer = String::new();
213    // A payload that cannot even be read (e.g. non-UTF8) fails CLOSED, not open.
214    if std::io::stdin().read_to_string(&mut buffer).is_err() {
215        return ResolvedTool::Unknown;
216    }
217    if buffer.trim().is_empty() {
218        return ResolvedTool::None;
219    }
220
221    match serde_json::from_str::<serde_json::Value>(&buffer)
222        .ok()
223        .and_then(|value| {
224            value
225                .get("tool_name")
226                .or_else(|| value.get("toolName"))
227                .or_else(|| value.get("tool"))
228                .and_then(|name| name.as_str())
229                .map(str::to_owned)
230        }) {
231        Some(name) if !name.trim().is_empty() => ResolvedTool::Named(name),
232        // Payload present but no recognizable tool field: fail closed.
233        _ => ResolvedTool::Unknown,
234    }
235}
236
237#[derive(Clone, Debug, Eq, PartialEq)]
238pub enum PushGateDecision {
239    Allow,
240    Block(Vec<LedgerEntry>),
241}
242
243fn block_for_pending_memory_skill(
244    state_dir: &Path,
245    config: &crate::config::TruthMirrorConfig,
246    all_commits: bool,
247    commits: &[String],
248) -> Result<()> {
249    if !config.memory_skill.effective_enabled(&config.skills)
250        || !config.memory_skill.pre_push_blocks_pending
251    {
252        return Ok(());
253    }
254
255    let store = crate::memory_skill::MemorySkillStore::new(state_dir, &config.memory_skill)?;
256    let pending = pending_memory_skill_candidates(&store, all_commits, commits)?;
257    if pending.is_empty() {
258        return Ok(());
259    }
260
261    bail!(
262        "pre-push blocked pending memory-skill candidate(s): {}",
263        pending
264            .iter()
265            .map(|candidate| format!("{} {}", candidate.id, candidate.source_commit))
266            .collect::<Vec<_>>()
267            .join("; ")
268    );
269}
270
271fn pending_memory_skill_candidates(
272    store: &crate::memory_skill::MemorySkillStore,
273    all_commits: bool,
274    commits: &[String],
275) -> Result<Vec<crate::memory_skill::MemorySkillCandidate>> {
276    if all_commits {
277        return Ok(store.pending_candidates()?);
278    }
279
280    let commit_set = commits.iter().map(String::as_str).collect::<BTreeSet<_>>();
281    Ok(store.pending_for_commits(&commit_set)?)
282}
283
284pub fn pre_push_decision(
285    store: &LedgerStore,
286    commits: &[String],
287    all_commits: bool,
288) -> Result<PushGateDecision> {
289    let commit_set = commits.iter().map(String::as_str).collect::<BTreeSet<_>>();
290    let blocked = store
291        .blocking_rejections()?
292        .into_iter()
293        .filter(|entry| all_commits || commit_set.contains(entry.commit_sha.as_str()))
294        .collect::<Vec<_>>();
295
296    if blocked.is_empty() {
297        Ok(PushGateDecision::Allow)
298    } else {
299        Ok(PushGateDecision::Block(blocked))
300    }
301}
302
303fn commits_for_range(range: &str) -> Result<Vec<String>> {
304    commits_for_range_in(range, Path::new("."))
305}
306
307fn commits_for_range_in(range: &str, cwd: &Path) -> Result<Vec<String>> {
308    if range == "all" {
309        return Ok(Vec::new());
310    }
311
312    if let Some(new_branch) = range.strip_prefix("new:") {
313        return match new_branch.split_once(':') {
314            Some((remote, local_sha)) => {
315                let remote = remote.trim();
316                let local_sha = local_sha.trim();
317                if remote.is_empty()
318                    || remote
319                        .chars()
320                        .any(|character| matches!(character, '*' | '?' | '['))
321                    || local_sha.is_empty()
322                    || local_sha.contains(':')
323                    || local_sha.starts_with('-')
324                {
325                    bail!(
326                        "malformed new-branch pre-push range {range}: expected new:<remote>:<sha>"
327                    );
328                }
329                git_rev_list_for_new_branch_on_remote(cwd, remote, local_sha, range)
330            }
331            _ => git_rev_list(cwd, &[new_branch, "--not", "--remotes"], range),
332        };
333    }
334
335    if !range.contains("..") {
336        return Ok(vec![range.to_owned()]);
337    }
338
339    git_rev_list(cwd, &[range], range)
340}
341
342fn git_rev_list(cwd: &Path, args: &[&str], label: &str) -> Result<Vec<String>> {
343    let output = Command::new("git")
344        .arg("rev-list")
345        .args(args)
346        .current_dir(cwd)
347        .output()
348        .context("failed to run git rev-list for pre-push range")?;
349    if !output.status.success() {
350        bail!(
351            "git rev-list failed for pre-push range {label}: {}",
352            String::from_utf8_lossy(&output.stderr)
353        );
354    }
355
356    Ok(String::from_utf8_lossy(&output.stdout)
357        .lines()
358        .map(str::trim)
359        .filter(|line| !line.is_empty())
360        .map(str::to_owned)
361        .collect())
362}
363
364fn git_rev_list_for_new_branch_on_remote(
365    cwd: &Path,
366    remote: &str,
367    local_sha: &str,
368    label: &str,
369) -> Result<Vec<String>> {
370    let remote_refs = remote_tracking_refs(cwd, remote)?;
371    let mut args = vec![local_sha.to_owned()];
372    if !remote_refs.is_empty() {
373        args.push("--not".to_owned());
374        args.extend(remote_refs);
375    }
376    let args = args.iter().map(String::as_str).collect::<Vec<_>>();
377    git_rev_list(cwd, &args, label)
378}
379
380fn remote_tracking_refs(cwd: &Path, remote: &str) -> Result<Vec<String>> {
381    let prefix = format!("refs/remotes/{remote}/");
382    let output = Command::new("git")
383        .args(["for-each-ref", "--format=%(refname)", &prefix])
384        .current_dir(cwd)
385        .output()
386        .context("failed to list remote refs for pre-push range")?;
387    if !output.status.success() {
388        bail!(
389            "git for-each-ref failed for pre-push remote {remote}: {}",
390            String::from_utf8_lossy(&output.stderr)
391        );
392    }
393
394    Ok(String::from_utf8_lossy(&output.stdout)
395        .lines()
396        .map(str::trim)
397        .filter(|line| !line.is_empty())
398        .filter(|line| line.starts_with(&prefix))
399        .map(str::to_owned)
400        .collect())
401}
402
403fn rejection_summary(entries: &[LedgerEntry]) -> String {
404    entries
405        .iter()
406        .map(|entry| format!("{} {}", entry.commit_sha, entry.claim))
407        .collect::<Vec<_>>()
408        .join("; ")
409}
410
411fn read_optional_file(path: Option<&PathBuf>, label: &str) -> Result<Option<String>> {
412    path.map(|path| {
413        fs::read_to_string(path)
414            .with_context(|| format!("failed to read {label} {}", path.display()))
415    })
416    .transpose()
417}
418
419#[cfg(test)]
420mod tests {
421    use std::{fs, path::Path, process::Command};
422
423    use proptest::prelude::*;
424
425    use crate::{
426        config::{MemorySkillCandidateKind, MemorySkillConfig, TruthMirrorConfig},
427        ledger::{LedgerEntry, LedgerStore, ReviewerConfig, Verdict},
428        memory_skill::{
429            CANDIDATE_SCHEMA_VERSION, CandidateStatus, EvidenceRef, MemorySkillCandidate,
430            MemorySkillStore,
431        },
432    };
433
434    use super::{
435        PushGateDecision, block_for_pending_memory_skill, commits_for_range_in,
436        pending_memory_skill_candidates, pre_push_decision,
437    };
438
439    fn reject_entry(sha: &str) -> LedgerEntry {
440        LedgerEntry::new_at(
441            sha,
442            Verdict::Reject,
443            "CLAIM: bad | verified: cargo test | evidence: tests:cargo-test",
444            vec!["tests:cargo-test".to_owned()],
445            ReviewerConfig::new("claude", "opus", false),
446            vec!["unsupported".to_owned()],
447            100,
448        )
449    }
450
451    #[test]
452    fn pre_push_blocks_unresolved_rejection_in_range() {
453        let temp = tempfile::tempdir().unwrap();
454        let store = LedgerStore::new(temp.path());
455        store.append_entry(&reject_entry("abc123")).unwrap();
456
457        let decision = pre_push_decision(&store, &["abc123".to_owned()], false).unwrap();
458
459        assert!(matches!(decision, PushGateDecision::Block(_)));
460    }
461
462    #[test]
463    fn pre_push_all_blocks_any_unresolved_rejection() {
464        let temp = tempfile::tempdir().unwrap();
465        let store = LedgerStore::new(temp.path());
466        store.append_entry(&reject_entry("abc123")).unwrap();
467
468        let decision = pre_push_decision(&store, &[], true).unwrap();
469
470        assert!(matches!(decision, PushGateDecision::Block(_)));
471    }
472
473    #[test]
474    fn pre_push_empty_range_without_all_does_not_block_everything() {
475        let temp = tempfile::tempdir().unwrap();
476        let store = LedgerStore::new(temp.path());
477        store.append_entry(&reject_entry("abc123")).unwrap();
478
479        let decision = pre_push_decision(&store, &[], false).unwrap();
480
481        assert_eq!(decision, PushGateDecision::Allow);
482    }
483
484    #[test]
485    fn pre_push_allows_after_resolve_or_waive() {
486        let temp = tempfile::tempdir().unwrap();
487        let store = LedgerStore::new(temp.path());
488        store.append_entry(&reject_entry("abc123")).unwrap();
489        store.resolve("abc123").unwrap();
490
491        let decision = pre_push_decision(&store, &["abc123".to_owned()], false).unwrap();
492
493        assert_eq!(decision, PushGateDecision::Allow);
494    }
495
496    #[test]
497    fn pending_memory_skill_filter_honors_commit_set() {
498        let temp = tempfile::tempdir().unwrap();
499        let config = MemorySkillConfig {
500            candidate_dir: "skills/candidates".to_owned(),
501            ..MemorySkillConfig::default()
502        };
503        let store = MemorySkillStore::new(temp.path(), &config).unwrap();
504        store
505            .write_candidate(&candidate_for_commit("candidate-keep", "keep123"))
506            .unwrap();
507        store
508            .write_candidate(&candidate_for_commit("candidate-skip", "skip123"))
509            .unwrap();
510
511        let commits = vec!["keep123".to_owned()];
512        let pending = pending_memory_skill_candidates(&store, false, &commits).unwrap();
513        let all_pending = pending_memory_skill_candidates(&store, true, &[]).unwrap();
514
515        assert_eq!(
516            pending
517                .iter()
518                .map(|candidate| candidate.id.as_str())
519                .collect::<Vec<_>>(),
520            vec!["candidate-keep"]
521        );
522        assert_eq!(all_pending.len(), 2);
523    }
524
525    #[test]
526    fn pending_memory_skill_gate_honors_enable_and_block_flags() {
527        let temp = tempfile::tempdir().unwrap();
528        let mut config = TruthMirrorConfig::default();
529        config.skills.enabled = true;
530        config.memory_skill.enabled = true;
531        config.memory_skill.pre_push_blocks_pending = true;
532        let store = MemorySkillStore::new(temp.path(), &config.memory_skill).unwrap();
533        store
534            .write_candidate(&candidate_for_commit("candidate-keep", "keep123"))
535            .unwrap();
536        let commits = vec!["keep123".to_owned()];
537
538        config.memory_skill.enabled = false;
539        block_for_pending_memory_skill(temp.path(), &config, false, &commits).unwrap();
540
541        config.memory_skill.enabled = true;
542        config.memory_skill.pre_push_blocks_pending = false;
543        block_for_pending_memory_skill(temp.path(), &config, false, &commits).unwrap();
544
545        config.memory_skill.pre_push_blocks_pending = true;
546        let error = block_for_pending_memory_skill(temp.path(), &config, false, &commits)
547            .unwrap_err()
548            .to_string();
549
550        assert!(error.contains("pending memory-skill candidate"));
551    }
552
553    #[test]
554    fn new_branch_range_expands_to_all_unpushed_commits() {
555        let temp = tempfile::tempdir().unwrap();
556        let repo = temp.path();
557        git(repo, &["init"]);
558        git(repo, &["config", "user.email", "truth@example.invalid"]);
559        git(repo, &["config", "user.name", "Truth Mirror Test"]);
560        fs::write(repo.join("one.txt"), "one\n").unwrap();
561        git(repo, &["add", "one.txt"]);
562        git(repo, &["commit", "-m", "one"]);
563        let first = git_stdout(repo, &["rev-parse", "HEAD"]);
564        fs::write(repo.join("two.txt"), "two\n").unwrap();
565        git(repo, &["add", "two.txt"]);
566        git(repo, &["commit", "-m", "two"]);
567        let second = git_stdout(repo, &["rev-parse", "HEAD"]);
568
569        let commits = commits_for_range_in(&format!("new:{second}"), repo).unwrap();
570
571        assert_eq!(commits, vec![second, first]);
572    }
573
574    #[test]
575    fn new_branch_range_excludes_only_target_remote_refs_when_remote_is_named() {
576        let temp = tempfile::tempdir().unwrap();
577        let repo = temp.path();
578        git(repo, &["init"]);
579        git(repo, &["config", "user.email", "truth@example.invalid"]);
580        git(repo, &["config", "user.name", "Truth Mirror Test"]);
581        fs::write(repo.join("one.txt"), "one\n").unwrap();
582        git(repo, &["add", "one.txt"]);
583        git(repo, &["commit", "-m", "one"]);
584        let first = git_stdout(repo, &["rev-parse", "HEAD"]);
585        fs::write(repo.join("two.txt"), "two\n").unwrap();
586        git(repo, &["add", "two.txt"]);
587        git(repo, &["commit", "-m", "two"]);
588        let second = git_stdout(repo, &["rev-parse", "HEAD"]);
589        git(repo, &["update-ref", "refs/remotes/backup/main", &first]);
590
591        let missing_from_origin =
592            commits_for_range_in(&format!("new:origin:{second}"), repo).unwrap();
593        let missing_with_whitespace =
594            commits_for_range_in(&format!("new: origin : {second} "), repo).unwrap();
595        git(repo, &["update-ref", "refs/remotes/origin/main", &first]);
596        let missing_after_origin_has_first =
597            commits_for_range_in(&format!("new:origin:{second}"), repo).unwrap();
598
599        assert_eq!(missing_from_origin, vec![second.clone(), first.clone()]);
600        assert_eq!(missing_with_whitespace, vec![second.clone(), first]);
601        assert_eq!(missing_after_origin_has_first, vec![second]);
602    }
603
604    #[test]
605    fn malformed_remote_new_branch_range_fails_before_git_rev_list() {
606        let temp = tempfile::tempdir().unwrap();
607        let repo = temp.path();
608        git(repo, &["init"]);
609
610        let error = commits_for_range_in("new::abc123", repo)
611            .unwrap_err()
612            .to_string();
613        let glob_error = commits_for_range_in("new:or*:abc123", repo)
614            .unwrap_err()
615            .to_string();
616        let option_like_sha_error = commits_for_range_in("new:origin:--output=bad", repo)
617            .unwrap_err()
618            .to_string();
619
620        assert!(error.contains("malformed new-branch pre-push range"));
621        assert!(glob_error.contains("malformed new-branch pre-push range"));
622        assert!(option_like_sha_error.contains("malformed new-branch pre-push range"));
623    }
624
625    fn git(repo: &Path, args: &[&str]) {
626        let output = Command::new("git")
627            .args(args)
628            .current_dir(repo)
629            .output()
630            .unwrap();
631        assert!(
632            output.status.success(),
633            "git {args:?} failed\nstdout:\n{}\nstderr:\n{}",
634            String::from_utf8_lossy(&output.stdout),
635            String::from_utf8_lossy(&output.stderr)
636        );
637    }
638
639    fn git_stdout(repo: &Path, args: &[&str]) -> String {
640        let output = Command::new("git")
641            .args(args)
642            .current_dir(repo)
643            .output()
644            .unwrap();
645        assert!(
646            output.status.success(),
647            "git {args:?} failed\nstdout:\n{}\nstderr:\n{}",
648            String::from_utf8_lossy(&output.stdout),
649            String::from_utf8_lossy(&output.stderr)
650        );
651        String::from_utf8(output.stdout).unwrap().trim().to_owned()
652    }
653
654    fn candidate_for_commit(id: &str, sha: &str) -> MemorySkillCandidate {
655        let ledger_ref = EvidenceRef::ledger(sha);
656        MemorySkillCandidate {
657            schema_version: CANDIDATE_SCHEMA_VERSION,
658            id: id.to_owned(),
659            fingerprint: format!("fingerprint-{id}"),
660            learning_key: id.to_owned(),
661            status: CandidateStatus::Pending,
662            candidate_kind: MemorySkillCandidateKind::HowToSkill,
663            scope: "project".to_owned(),
664            source_commit: sha.to_owned(),
665            source_claim: format!("CLAIM: {id} | verified: cargo test | evidence: tests:{id}"),
666            truth_label: Verdict::Pass,
667            ledger_entry_ref: ledger_ref.clone(),
668            source_run_ref: EvidenceRef::run("run-1"),
669            occurrence_count: 1,
670            occurrence_refs: vec![ledger_ref.clone()],
671            slug: id.to_owned(),
672            title: id.to_owned(),
673            description: "test candidate".to_owned(),
674            when_to_use: vec!["When testing pending candidate filtering.".to_owned()],
675            procedure_steps: vec!["Run the pending candidate filter.".to_owned()],
676            pitfalls: vec!["Do not include unrelated commits.".to_owned()],
677            verification_steps: vec!["Assert only matching commits remain.".to_owned()],
678            evidence: vec![ledger_ref],
679            created_at_unix: 100,
680        }
681    }
682
683    proptest! {
684        #[test]
685        fn pushed_range_decision_blocks_only_matching_unresolved_sha(
686            rejected_sha in "[a-f0-9]{7,40}",
687            other_sha in "[a-f0-9]{7,40}",
688        ) {
689            prop_assume!(rejected_sha != other_sha);
690            let temp = tempfile::tempdir().unwrap();
691            let store = LedgerStore::new(temp.path());
692            store.append_entry(&reject_entry(&rejected_sha)).unwrap();
693
694            let unrelated = pre_push_decision(&store, std::slice::from_ref(&other_sha), false).unwrap();
695            prop_assert_eq!(unrelated, PushGateDecision::Allow);
696
697            let blocked = pre_push_decision(&store, std::slice::from_ref(&rejected_sha), false).unwrap();
698            prop_assert!(matches!(blocked, PushGateDecision::Block(_)));
699        }
700    }
701}