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