# ADR-0041: Refuse to Amend Pushed Commits; Override Denied Only to Autonomous AI Rewrites

## Status

✅ Accepted (2026-07-07)

## Context

omni-dev rewrites commit messages through four surfaces: the CLI `git commit
message twiddle` (AI generates new messages) and `amend` (applies a
caller-supplied YAML plan deterministically), plus the matching MCP tools
`git_twiddle_commits` and `git_amend_commits`. Rewriting a commit that has
already been pushed to a shared main branch rewrites *published* history:
anyone who has pulled it now has a divergent copy, and a force-push is required
to reconcile. That is a foot-gun on the human path and a genuine hazard on the
autonomous one.

[Issue #1105](https://github.com/rust-works/omni-dev/issues/1105) (commit
`bd3bfa96`, `feat(git,cli)!`) added a guard: every amendment target is checked
for containment in a remote main branch and refused by default, with an
`--allow-pushed` escape hatch.

The obvious way to describe the trust boundary is "the human CLI can override,
the autonomous MCP surface cannot." **That description is wrong**, and writing
the ADR to it would encode a boundary the code does not implement. The actual
override availability is:

| Surface | Command / tool | Content origin | Override exposed? |
|---|---|---|---|
| CLI | `git commit message twiddle` | AI-generated | ✅ `--allow-pushed` |
| CLI | `git commit message amend` | deterministic YAML | ✅ `--allow-pushed` |
| MCP | `git_amend_commits` | deterministic YAML | ✅ `allow_pushed` param |
| MCP | `git_twiddle_commits` | AI-generated | ❌ none |

The guard refuses pushed commits by **default** on all four; only the
*availability of the override* differs. It is denied on **exactly one** surface,
`git_twiddle_commits` — the intersection of two properties:

1. **Autonomous** — no human is in the loop (MCP, not an interactive CLI run).
2. **AI generates the content** — the new messages are model output, not a
   caller-supplied plan.

When either property is false the escape hatch is available: a human at the CLI
keeps it even for AI-generated `twiddle`, and a deterministic caller-supplied
plan keeps it even on the MCP `git_amend_commits` tool. Only when *both* hold —
an AI silently generating rewrites with no human review — is the override
withheld, because that combination *is* the failure mode the guard exists to
prevent.

This is adjacent to [ADR-0027](adr-0027.md) (destructive Atlassian mutations
confirm by default, overridable by a human) but is a **different mechanism on a
different surface**: containment-refusal over git-history rewriting, versus
interactive-confirm over API deletes. It is recorded separately rather than
folded in, and the containment-detection and library-signature decisions below
have no analogue there.

## Decision

### Default-refuse via a single chokepoint

All four surfaces converge on
[`AmendmentHandler::validate_commit_amendable`](../../src/git/amendment.rs)
(`src/git/amendment.rs:114-145`), called from `perform_safety_checks` before any
rebase or `git commit --amend`. For each target commit it computes the remote
main branches that contain it and, if the set is non-empty and `allow_pushed` is
unset, refuses with an error that names the short hash, the containing branch(es),
and the escape hatch:

```
Refusing to amend commit <short>: it is already in remote main branch(es): <branches>
Amending pushed commits rewrites published history. Re-run with --allow-pushed to override.
```

A single chokepoint (rather than a check duplicated per surface) means the
refusal, the message, and the override semantics cannot drift between CLI and
MCP, or between `twiddle` and `amend`.

### The override is the two-factor boundary, by construction

`AmendmentHandler` carries an `allow_pushed` flag, default `false`, set only via
the explicit builder `with_allow_pushed` (`src/git/amendment.rs:35-43`). The four
surfaces wire it as follows, and the asymmetry is an intentional consequence of
*which callers are given a way to set the flag*:

- **CLI `amend`** — `--allow-pushed` clap flag threaded into the handler
  (`src/cli/git/amend.rs:16`, and in `run_amend`). A human typed the flag.
- **CLI `twiddle`** — `--allow-pushed` clap flag threaded into the handler
  (`src/cli/git/twiddle.rs:23`, `:813`). AI generated the messages, but a human
  ran the command (and, without `--auto-apply`, reviews before applying).
- **MCP `git_amend_commits`** — `allow_pushed: bool` parameter
  (`src/mcp/git_tools.rs:125`) threaded through `run_amend` (`:288`). No human,
  but the caller supplied an explicit, deterministic YAML plan naming exact
  40-char SHAs; the mutation is auditable and no AI generated the content, so the
  "AI silently rewrites history" failure mode does not apply.
- **MCP `git_twiddle_commits`** — **no** `allow_pushed` field on
  `GitTwiddleCommitsParams` (`src/mcp/git_tools.rs:76`); its non-interactive core
  `run_twiddle` constructs the handler with no `.with_allow_pushed` call
  (`src/cli/git/twiddle.rs:1909`), so the flag is unconditionally `false`. This
  is the one surface where *both* factors hold, so it is the one surface with no
  override.

The `git_twiddle_commits` tool description points a caller that hits the refusal
at the human path (`omni-dev git commit message amend --allow-pushed`) rather
than exposing a way to bypass it in-band.

### Offline, local containment detection

Containment is detected without any network call or `gh` invocation, in
[`src/git/main_branches.rs`](../../src/git/main_branches.rs):

- `detect_main_branch_tips` iterates the repo's remotes and resolves each one's
  main branch **locally** via `RemoteInfo::detect_main_branch_local`: first the
  remote's symbolic `refs/remotes/<remote>/HEAD`, then a common-name fallback
  (`main` → `master` → `develop`) among existing remote-tracking refs. A remote
  whose main cannot be resolved locally contributes **no tip** rather than a
  guessed one, so an exotic branch layout fails open (no false refusal) rather
  than refusing the wrong commits.
- `branches_containing` reports a commit contained when it equals a tip or is an
  ancestor of one (`git2::Repository::graph_descendant_of`).

The tradeoff: detection is only as fresh as the last `fetch` — a commit merged
upstream but not yet fetched locally is not seen as pushed. This is accepted
deliberately in exchange for offline determinism and zero network latency on
every amend; a human who knows a commit is published can still decline to pass
`--allow-pushed`, and the inverse error (refusing an *un*pushed commit) cannot
happen because unresolved remotes contribute no tips.

### Breaking library change: precompute tips once per query

Populating the new `CommitInfo.in_main_branches: Vec<String>` field
(`src/git/commit.rs:33`) for every commit in a range must not recompute the
remote tips per commit. So `CommitInfo::from_git_commit` gained a **mandatory**
`main_tips: &[MainBranchTip]` parameter (`src/git/commit.rs:122-125`): callers
compute the tips once (`RepositoryData` does so at
`src/git/repository.rs:161` and reuses them across every `from_git_commit` call
in the range) and thread the slice down, where `branches_containing` turns it
into the per-commit `in_main_branches`. This is a breaking change to a public
constructor; all call sites were updated in the same change.

## Consequences

**Positive**

- Published history is protected by default on **all four** surfaces; rewriting
  a pushed commit now requires an explicit, deliberate opt-in.
- A single `validate_commit_amendable` chokepoint keeps the refusal, its error
  text, and the override semantics identical across CLI/MCP and twiddle/amend —
  no drift.
- Detection is offline and deterministic: no network round-trip, no `gh`
  dependency, works on a plane.
- Precomputing tips once per query (rather than per commit) keeps range analysis
  from doing O(commits) redundant remote resolution.
- The one surface an autonomous AI drives to *generate and apply* rewrites is
  structurally unable to touch published history — the exact failure mode is
  closed, while auditable deterministic MCP applies (`git_amend_commits`) are
  not needlessly hobbled.

**Negative**

- Breaking change to the public `CommitInfo::from_git_commit` signature (new
  mandatory `main_tips` parameter); every call site had to be updated.
- Detection freshness is bounded by the last fetch: a commit published upstream
  but not yet fetched locally is not recognised as pushed, so the default
  refusal will not fire for it.
- The trust boundary is subtle — it is *not* the intuitive "CLI overrides, MCP
  cannot" line, but a two-factor (autonomous AND AI-generated) rule that lands on
  exactly one tool. This ADR exists precisely because that distinction is easy to
  state wrong (the originating issue and an early review comment both did).

**Neutral**

- Default-refuse is uniform across surfaces; the only variation is whether a
  given caller is handed a way to set `allow_pushed`.
- Shelling out for the containment-sensitive mutations while reading tips through
  `git2` is consistent with the hybrid-git split in [ADR-0003](adr-0003.md).
- Shares the human-can-override spirit of [ADR-0027](adr-0027.md) without sharing
  its mechanism (interactive confirm) or surface (Atlassian API deletes).
