crates/gunnar-policy/src/gates/ai_trailers.rs
//! Gate 6. A commit NEW TO THE FORGE must not carry a machine-attribution
//! trailer (workspace LAW -1; owner ruling 2026-09-28).
//!
//! The matcher is [`crate::trailer`]; the policy, the default and the seed
//! exemption are documented on [`AiTrailerPolicy`]. What lives here is the
//! question "which commits does this push make new to the forge", answered as
//! `git rev-list <new> --not <every existing ref>` would answer it.
//!
//! # The walk
//!
//! A date-ordered two-colour walk, the shape git's own `--not --all` uses:
//! the pushed tip is painted NEW, every existing reference tip is painted OLD,
//! and commits are taken newest-first; each passes its paint to its parents.
//! The walk stops as soon as no NEW-only commit is left in the queue, so a
//! fast-forward, or a branch forked from last week's `main`, costs the commits
//! since the fork and not the repository.
//!
//! **OLD is always right, NEW may be pessimistic.** A commit is painted OLD
//! only by propagation from an existing tip, which is exact. Committer clocks
//! can be skewed, though, and a skewed ancestor can be popped as NEW before
//! the OLD paint reaches it. That error only ever REFUSES, never admits, and
//! the gate closes it for the commits that matter: before refusing, it keeps
//! walking the OLD side until every offending candidate is either reached
//! (old after all — allowed) or the OLD side is exhausted (genuinely new —
//! refused). That tail walk happens only on the refusal path.
//!
//! **The pack's object set is not used**, and [`Gate::reads_new_objects`] says
//! so, so a repository whose only live gate is this one does not make the
//! ingest enumerate objects it cannot (the gix engine refuses to).
use std::collections::{BinaryHeap, HashMap, HashSet};
use std::sync::Mutex;
use gix_hash::ObjectId;
use gunnar_store::git_storage_trait::ObjType;
use gunnar_store::object;
use crate::config::AiTrailerPolicy;
use crate::context::{PrincipalKind, PushContext, RefUpdate};
use crate::error::{Error, Result};
use crate::trailer::{ai_attribution_lines, AttributionLine};
use crate::{Gate, Verdict};
const NEW: u8 = 1;
const OLD: u8 = 2;
/// Refuses a push that introduces a commit carrying a machine-attribution
/// trailer.
#[derive(Debug)]
pub struct AiTrailerGate {
policy: AiTrailerPolicy,
/// **What this gate already judged clean, against which existing tips** (GUN-PERF,
/// 2026-10-31). receive-pack asks once per reference, and an import pushes a
/// thousand references over one history (git.git: 1 005 tags, 81 000 commits), so
/// each walk re-read the history below it: 328 s of one push. A walk that allowed
/// has judged every commit it reached (NEW-only ones clean, the rest old), so a
/// later walk against the SAME existing tips stops where it meets one of them. A
/// different set of existing tips (another push) starts the memory afresh.
cleared: Mutex<Cleared>,
}
#[derive(Debug, Default)]
struct Cleared {
existing: Vec<ObjectId>,
commits: HashSet<ObjectId>,
}
impl Clone for AiTrailerGate {
/// A copy judges afresh: the memory belongs to the pushes this one has seen.
fn clone(&self) -> Self {
AiTrailerGate::new(self.policy.clone())
}
}
impl AiTrailerGate {
/// A gate over `policy`.
pub fn new(policy: AiTrailerPolicy) -> Self {
AiTrailerGate {
policy,
cleared: Mutex::new(Cleared::default()),
}
}
/// The policy this gate enforces.
pub fn policy(&self) -> &AiTrailerPolicy {
&self.policy
}
/// The seed exemption: the named MACHINE principal, into a repository with
/// no references. Both halves, always.
fn is_seed(&self, ctx: &PushContext<'_>, existing: &[ObjectId]) -> bool {
match &self.policy.seed_principal {
Some(seed) => {
ctx.principal.kind == PrincipalKind::Machine
&& ctx.principal.id == *seed
&& existing.is_empty()
}
None => false,
}
}
}
impl Gate for AiTrailerGate {
fn name(&self) -> &str {
"ai-trailers"
}
fn reads_new_objects(&self) -> bool {
false
}
fn check(&self, ctx: &PushContext<'_>) -> Result<Verdict> {
if !self.policy.refuse {
return Ok(Verdict::Allow);
}
let existing = existing_tips(ctx)?;
if self.is_seed(ctx, &existing) {
tracing::info!(
gate = self.name(),
principal = %ctx.principal.id,
repo = %ctx.repo,
"seed push into an empty repository: history not judged for attribution trailers"
);
return Ok(Verdict::Allow);
}
// **One walk for the whole push** (GUN-PERF, 2026-10-31). The commits new to the
// forge under several pushed tips are the union of what each tip brings, so one
// walk from all of them against the existing tips finds every offender the
// per-update walks would. Each update walked on its own re-read the history below
// it: an import of git.git (1 005 tags over 81 000 commits) spent 328 s here.
// An allow is answered by the one walk; a refusal (or a union that outgrows the
// budget a single walk would not) goes back to the per-update walks, which name
// the reference.
if ctx.updates.len() > 1 {
let mut tips = Vec::new();
for update in ctx.updates {
if let Some(new_tip) = update.new {
if let (Some(tip), _) = crate::ancestry::peel_to_commit(ctx.objects, &new_tip)?
{
tips.push(tip);
}
}
}
match self.judge(ctx, &tips, &existing) {
Ok(offenders) if offenders.is_empty() => return Ok(Verdict::Allow),
Ok(_) | Err(Error::AncestryBudget { .. }) => {}
Err(err) => return Err(err),
}
}
for update in ctx.updates {
if let Some(verdict) = self.check_update(ctx, update, &existing)? {
return Ok(verdict);
}
}
Ok(Verdict::Allow)
}
}
impl AiTrailerGate {
fn check_update(
&self,
ctx: &PushContext<'_>,
update: &RefUpdate,
existing: &[ObjectId],
) -> Result<Option<Verdict>> {
let Some(new_tip) = update.new else {
return Ok(None); // a delete introduces nothing
};
let (peeled, _tags) = crate::ancestry::peel_to_commit(ctx.objects, &new_tip)?;
let Some(tip) = peeled else {
return Ok(None); // a tag of a tree or a blob: no commits under it
};
let offenders = self.judge(ctx, &[tip], existing)?;
let Some((first, lines)) = offenders.first() else {
return Ok(None);
};
// Every offender carries at least one line (that is what made it one);
// the fallback keeps an impossible empty list a refusal, not an allow.
let (number, text) = lines
.first()
.map(|l| (l.line, l.text.as_str()))
.unwrap_or((0, "?"));
let mut reason = format!(
"commit {first} carries an AI attribution trailer, line {}: \"{}\". This forge refuses \
commits with machine-attribution trailers (Co-Authored-By naming Claude/Anthropic, \
\"Generated with Claude\"). Remove the line and push again: `git commit --amend` if \
{first} is the tip, otherwise `git rebase -i {first}^` and reword it.",
number, text
);
if offenders.len() > 1 {
let rest: Vec<String> = offenders[1..]
.iter()
.map(|(id, _)| id.to_string())
.collect();
reason.push_str(&format!(
" {} more commit(s) in this push carry one too: {}.",
rest.len(),
rest.join(", ")
));
}
Ok(Some(Verdict::reject_ref(update.name.clone(), reason)))
}
}
/// Every reference tip the repository holds right now, peeled to a commit
/// where it can be. Gates run before any reference in the push is applied, so
/// this is the repository as it was when the push arrived.
///
/// A row whose object is missing or undecodable is skipped rather than
/// failing the push: fewer OLD tips can only make the gate stricter, never
/// looser.
impl AiTrailerGate {
/// [`new_commits_with_trailers`] through this gate's memory of cleared commits.
fn judge(
&self,
ctx: &PushContext<'_>,
tips: &[ObjectId],
existing: &[ObjectId],
) -> Result<Vec<(ObjectId, Vec<AttributionLine>)>> {
let mut sorted = existing.to_vec();
sorted.sort_unstable();
// A poisoned lock only loses the memory, never a judgement.
let mut cleared = match self.cleared.lock() {
Ok(c) => c,
Err(poisoned) => poisoned.into_inner(),
};
if cleared.existing != sorted {
cleared.existing = sorted;
cleared.commits.clear();
}
new_commits_with_trailers(
ctx,
tips,
existing,
self.policy.budget,
&mut cleared.commits,
)
}
}
fn existing_tips(ctx: &PushContext<'_>) -> Result<Vec<ObjectId>> {
let rows = ctx.objects.refs().map_err(Error::backend)?;
let mut tips = Vec::with_capacity(rows.len());
for row in rows {
let raw = match (row.peeled.as_deref(), row.oid.as_deref()) {
(Some(peeled), _) => peeled,
(None, Some(oid)) => oid,
(None, None) => continue, // purely symbolic, e.g. HEAD
};
let Ok(id) = gix_hash::oid::try_from_bytes(raw) else {
continue;
};
match crate::ancestry::peel_to_commit(ctx.objects, id) {
Ok((Some(commit), _)) => tips.push(commit),
Ok((None, _)) => {}
Err(err) => tracing::warn!(
reference = %row.name,
%err,
"an existing reference could not be peeled; not counted as forge history"
),
}
}
tips.sort();
tips.dedup();
Ok(tips)
}
struct Node {
flags: u8,
/// The flags last passed on to the parents.
passed: u8,
/// Load order: the tip is 0, so a refusal names commits tip-first.
order: u64,
parents: Vec<ObjectId>,
hits: Vec<AttributionLine>,
}
struct Walk<'c, 'a> {
ctx: &'c PushContext<'a>,
/// Commits an earlier allowing walk against the same existing tips reached: a NEW
/// paint stops at one, since nothing below it is new and unjudged.
cleared: &'c HashSet<ObjectId>,
nodes: HashMap<ObjectId, Node>,
queue: BinaryHeap<(i64, u64, ObjectId)>,
seq: u64,
budget: usize,
from: ObjectId,
}
impl Walk<'_, '_> {
/// Paint `id` with `flag`, loading it on first sight. A node whose paint
/// grows is queued again so the new paint reaches its parents.
fn paint(&mut self, id: ObjectId, flag: u8) -> Result<()> {
if let Some(node) = self.nodes.get_mut(&id) {
if node.flags & flag == flag {
return Ok(());
}
node.flags |= flag;
// Re-queued at "now" so it is taken promptly; its date only
// orders the walk, it does not decide membership.
self.seq += 1;
self.queue.push((i64::MAX, self.seq, id));
return Ok(());
}
if flag == NEW && self.cleared.contains(&id) {
return Ok(());
}
if self.nodes.len() >= self.budget {
return Err(Error::AncestryBudget {
from: self.from,
looking_for: id,
walked: self.nodes.len(),
budget: self.budget,
});
}
let Some((kind, bytes)) = self
.ctx
.objects
.read(id.as_bytes())
.map_err(Error::backend)?
else {
return Err(Error::MissingObject {
oid: id,
what: "the commits new to the forge",
});
};
if kind != ObjType::Commit {
return Err(Error::decode(
id,
format_args!("expected a commit, found a {}", kind.as_str()),
));
}
let commit = object::commit(&bytes, id.kind()).map_err(|err| Error::decode(id, err))?;
let hits = ai_attribution_lines(&commit.message);
self.seq += 1;
self.queue.push((commit.committer.seconds, self.seq, id));
self.nodes.insert(
id,
Node {
flags: flag,
passed: 0,
order: self.seq,
parents: commit.parents,
hits,
},
);
Ok(())
}
/// Take one commit off the queue and pass its paint on.
fn step(&mut self) -> Result<bool> {
let Some((_, _, id)) = self.queue.pop() else {
return Ok(false);
};
let (flags, parents) = {
let node = self.nodes.get_mut(&id).expect("queued nodes are loaded");
if node.passed == node.flags {
return Ok(true);
}
node.passed = node.flags;
(node.flags, node.parents.clone())
};
for parent in parents {
self.paint(parent, flags)?;
}
Ok(true)
}
fn interesting_left(&self) -> bool {
self.queue
.iter()
.any(|(_, _, id)| self.nodes.get(id).is_some_and(|n| n.flags == NEW))
}
/// NEW-only commits that carry a hit, in the order they were found.
fn candidates(&self) -> Vec<ObjectId> {
let mut out: Vec<(u64, ObjectId)> = self
.nodes
.iter()
.filter(|(_, n)| n.flags == NEW && !n.hits.is_empty())
.map(|(id, n)| (n.order, *id))
.collect();
out.sort();
out.into_iter().map(|(_, id)| id).collect()
}
}
/// The commits reachable from any of `tips` and from no `existing` tip that carry a
/// machine-attribution line, each with its lines.
fn new_commits_with_trailers(
ctx: &PushContext<'_>,
tips: &[ObjectId],
existing: &[ObjectId],
budget: usize,
cleared: &mut HashSet<ObjectId>,
) -> Result<Vec<(ObjectId, Vec<AttributionLine>)>> {
let Some(first) = tips.first() else {
return Ok(Vec::new());
};
let mut walk = Walk {
ctx,
cleared,
nodes: HashMap::new(),
queue: BinaryHeap::new(),
seq: 0,
budget,
from: *first,
};
for tip in tips {
walk.paint(*tip, NEW)?;
}
for id in existing {
walk.paint(*id, OLD)?;
}
while walk.interesting_left() {
walk.step()?;
}
let mut suspects = walk.candidates();
if suspects.is_empty() {
// Allowed: every commit this walk reached is judged (the NEW-only ones clean,
// the rest old), and a later walk of this push may stop at any of them.
let judged: Vec<ObjectId> = walk
.nodes
.iter()
.filter(|(_, n)| n.flags & NEW != 0)
.map(|(id, _)| *id)
.collect();
cleared.extend(judged);
return Ok(Vec::new());
}
// The refusal path: make sure each suspect is not old history reached
// late because of a skewed clock. Walk the OLD side until every suspect
// is painted OLD or there is nothing left to walk.
loop {
suspects.retain(|id| walk.nodes.get(id).is_some_and(|n| n.flags & OLD == 0));
if suspects.is_empty() {
return Ok(Vec::new());
}
match walk.step() {
Ok(true) => {}
Ok(false) => break,
// A walk that cannot finish cannot clear a suspect. The suspect
// stays a suspect, which refuses; it does not become an allow.
Err(err) => {
tracing::warn!(%err, "could not finish the old-history walk; suspects stay refused");
break;
}
}
}
Ok(suspects
.into_iter()
.map(|id| {
let hits = walk
.nodes
.get(&id)
.map(|n| n.hits.clone())
.unwrap_or_default();
(id, hits)
})
.collect())
}