ADR 0029 - Strict omission: no madtea surface carries a work-destroying override

  • Status: Accepted
  • Date: 2026-07-17
  • Tracking: #206 (epic), #207

Context

madtea’s destructive-operation surfaces grew case by case, and by mid-2026 three different safety models coexisted with no ADR pinning any of them:

  1. Omission - force-delete of a local branch exists on NO madtea surface. The MCP madt_branch_delete tool has no force parameter, and the CLI madtea branch -d refuses -D/--force-delete, redirecting to raw git branch -D in the user’s own shell (internal/cmd/git/branch.go). The plugin hooks then steer agents off raw git. Two independent layers, neither depending on the other behaving.
  2. Machine-proof destruction - madtea prune deletes a ref only after content-verifying it is contained in the default branch, and madtea push --force rides --force-with-lease. Refs die; work provably does not.
  3. Consent-gated presence - madt_worktrees remove force= (#168) and the madt_delete tool (repo/issue/release/tag/wiki page) DO exist on the agent surface, guarded by an elicitation dialog.

Model 3 leaked twice, and both leaks are structural, not incidental:

  • Fail-open on non-elicitation clients. madt_delete’s issue-delete gate proceeds without confirmation when the client cannot show an elicitation dialog (“proceeds without confirmation on non-elicitation clients”, per the action summary in internal/mcp/tools_delete.go). Those same clients are the ones without the plugin’s PreToolUse hooks, and madt_enable(domains= ["destructive"]) is agent-callable. The net is zero-consent, irreversible deletion on exactly the clients with the fewest other guards.
  • The refusal recommends the bypass. gateForcedWorktreeRemove (internal/mcp/tools_worktrees.go) ends its non-elicitation refusal with a runnable escape: “run it yourself in a terminal: madtea worktree remove X –force”. That is the same deny-message-recommends-the-escape flaw ADR 0009 removed the madtea.allow-* opt-out keys for - and it only ever renders on the unhooked clients where that CLI command works unguarded.

A gate degrades per client and invites the fail-open regression; omission cannot degrade, because there is nothing to switch off. The #168 design (elicitation-gated worktree force-remove, landed 2026-07-16 in PR #178) is the concrete casualty: it lasted one day before these two findings made the case that a gate is the wrong shape for irreversible destruction.

This ADR extends ADR 0009 (agent-facing guards are non-overridable; the one allowed carve-out is structural, keyed on git state, never on a settable switch) from the hook opt-outs to the tool surface itself, and it sits inside the ADR 0014 dual-surface model: because the CLI and MCP are thin surfaces over one internal/service layer, a destructive operation is omitted at the service seam and both surfaces lose it together, with no risk of the two drifting. It also carries forward ADR 0026 (every refusal terminates in a runnable non-madtea tool, and no refusal recommends a raw form the hooks block) and ADR 0023 (shipped refusal strings state their rule self-contained, without ADR or issue numbers).

Decision

A madtea surface (MCP tool or CLI command) never carries an operation that can destroy unrecoverable work or data. Destructive capability is omitted from every surface, not gated behind a consent dialog, a consent flag, a gated domain, or a hook.

Three clauses make the rule precise:

1. Omission, not a gate

Gates (elicitation dialogs, madt_enable domains, PreToolUse hooks) are not an accepted substitute for omitting an irreversible operation. They degrade per client - a non-elicitation client silently loses the dialog - and each degraded path is a fail-open regression waiting to happen (legacy tracker 2004 is the demonstration). An operation that can discard uncommitted work or irreversibly delete forge data is simply absent from the tool schema and the CLI flag set.

2. The one carve-out: machine-proof destruction

Destruction is offered only when madtea can machine-prove, from git or forge state, that nothing unrecoverable is lost before it acts. The proof is structural and computed, never a caller assertion or a human “yes”:

  • madtea prune deletes a merged branch only after verifying its content is contained in the default branch - ancestry-merged branches go through git branch -d (git’s own reachability refusal), and squash/cherry-pick branches are content-verified and then re-verified per branch immediately before the git branch -D, routing any no-longer-contained branch to a skipped list (deleteSquashMerged, internal/service/prune/run.go).
  • madtea push --force rides --force-with-lease: git refuses the push if the remote ref moved since the last fetch, so no unseen work is clobbered (internal/service/git/push.go). There is no lease-free --force anywhere.
  • madt_branch_delete / madtea branch -d use git branch -d only, which git refuses for a branch whose commits are not merged.

This mirrors ADR 0009’s structural carve-out (the empty-repo initial commit, keyed on the absence of HEAD): a fact about state, not a setting.

3. The escape for genuine need names a non-madtea tool

Where a user genuinely needs the irreversible operation, the refusal names the tool the user already holds - raw git in their own shell, or the forge web UI

  • as guidance for the human. It never hands back a runnable madtea flag or incantation that performs the destruction. This is ADR 0009’s deny-message lesson (a deny message must not recommend the escape it is guarding against) applied to the surface itself, and it stays inside ADR 0026’s DAG: the refusal terminates in a tool madtea does not gate, not in a raw form the hooks block.

Elicitation’s remaining role

Elicitation stays, scoped to consent for reversible-but-notable actions only - an action worth a human confirming but which loses nothing unrecoverable if confirmed. It is never the guard standing between an agent and irreversible destruction.

Per-operation classification

Every currently destructive-adjacent operation, classified. The convergence work (#208, #209) implements this decided list rather than re-arguing each op.

OperationSurface(s)ClassificationReasoning
worktree remove force= / --forceMCP madt_worktrees + CLI worktree removeomitForce-removing a dirty or locked worktree discards uncommitted work or overrides a deliberate lock - unrecoverable. A clean worktree already removes without force, so dropping the flag loses nothing recoverable-affecting. Refusal names raw git worktree remove --force. Supersedes the #168 gate.
madt_delete repoMCP madt_delete + CLI repo deleteomitDeletes the repository and all its issues, PRs, releases, and wiki server-side. No structural proof of recoverability exists. Refusal names the forge web UI (its own typed-confirm).
madt_delete issueMCP madt_delete + CLI issue deleteomitHard delete with no forge undelete; the reversible alternative is close, not delete. This is the legacy tracker 2004 fail-open path. Refusal names the forge web UI.
madt_delete releaseMCP madt_delete + CLI release deleteomit (judgment)Release notes and attached assets are unrecoverable and madtea proves nothing about them; whether the surviving tag makes a release “recoverable metadata” is a product call - see judgment note.
madt_delete tagMCP madt_delete + CLI tag deleteomit (judgment)The target commit may survive, but ref restoration (and an annotated/signed tag’s own object) is not machine-proven from the delete; classifying it machine-proof would require madtea to first verify the commit is reachable from a retained ref - see judgment note.
madt_delete wiki pageMCP madt_delete + CLI wiki page deleteomitIrreversible page deletion with no forge restore and no structural proof. Refusal names the forge web UI / wiki git history.
prune (merged-branch cleanup)CLI prune + MCP prunemachine-proofContent-verified containment in the default branch before any ref drop, re-verified per branch at delete time; ancestry deletes use git branch -d. Structural, computed from git state.
push --forceCLI push --force + MCP pushmachine-proofRides --force-with-lease; git refuses if the remote moved since last fetch. No lease-free force exists.
branch deleteCLI branch -d + MCP madt_branch_deletemachine-proofgit branch -d only - git refuses an unmerged branch. -D/--force-delete is absent and redirects to raw git.
worktree add force= / --forceMCP madt_worktrees + CLI worktree addalready-safeForce only permits reusing a branch already checked out elsewhere or a registered path; it creates a working tree and destroys no committed or uncommitted work (AddWorktree, git refuses to overwrite a non-empty non-worktree directory). Nothing to omit.

Consequences

  • The convergence work implements a decided list. #208 (worktree force removal) and #209 (madt_delete) remove the omitted operations from both surfaces and replace each with a refusal that names a non-madtea tool; they do not re-argue whether each op should go.
  • The elicitation-as-destruction-guard pattern is retired. The #168 gateForcedWorktreeRemove seam and madt_delete’s per-call confirm are removed as guards for destruction; elicitation survives only for reversible-but-notable consent.
  • No new gate can silently reintroduce the footgun. Because omission is the rule, adding a destructive capability is a visible schema/flag change subject to this ADR, not a quietly-degrading dialog. The machine-proof carve-out is the only door, and it requires a computed proof from git/forge state.
  • A user who needs the irreversible op is never wedged. Raw git and the forge web UI remain fully available to the human; madtea simply stops being the tool that performs the unrecoverable step on an agent’s behalf. This is the ADR 0009 posture: a visibly-refused agent that points at the human’s own tools is strictly better than a silent bypass.
  • Refusal strings stay self-contained (ADR 0023). The shipped refusals name raw git / the forge web UI in plain terms and carry no ADR or issue numbers; the rationale lives here and in docs/reference/destructive.md, which are not shipped with the binary.

Alternatives considered

  • Keep the operations, harden the gate (require elicitation, refuse non-elicitation clients). Rejected. It is exactly what #168 did for worktree force-remove, and it still degrades per client and still ended its refusal with the runnable CLI bypass. A gate is the wrong shape for irreversible destruction; only its absence cannot fail open.
  • Gate madt_enable(domains=["destructive"]) behind something an agent cannot reach. Rejected. It bolts a second guard onto a capability that should not be present, invites evasion races (ADR 0009’s rejected “block the writes” alternative), and leaves the fail-open confirm underneath untouched.
  • Classify tag and release deletion as machine-proof because the underlying commit survives. Considered and deferred to a judgment call (see the table). madtea computes no proof today that the commit stays reachable or that a release’s notes/assets are reconstructable, so absent that proof the conservative disposition is omit. If a future change made madtea verify reachability from a retained ref before deleting a lightweight tag, that specific path could move to the machine-proof carve-out.
  • Leave the three models coexisting, documented but unreconciled. Rejected. The whole point of the epic (#206) is that “safe by design” cannot rest on an agent’s incantations staying on the blessed path; three overlapping models with per-client degradation is the status quo that leaked twice.

Relates to ADR 0009 (non-overridable agent guards; the structural carve-out), ADR 0014 (one service layer under both surfaces, so omission holds on both), ADR 0023 (shipped strings self-contained), and ADR 0026 (refusals terminate in runnable non-blocked tools).

Canonical source: docs/adr/0029-strict-omission-destruction-model.md in the madtea repo.