ADR 0031 - Recoverable-guard tier: refuse wrong-workflow acts by default, override per call

  • Status: Accepted
  • Date: 2026-07-17
  • Tracking: #222

Context

ADR 0029 pinned the rule for unrecoverable acts: they are omitted from every madtea surface. It deliberately said nothing about a second class this repo keeps meeting: acts that destroy nothing but are almost always workflow wreckage. The motivating incident (2026-07-17): a process with a confused working directory committed a feature snapshot directly onto the local default branch. Nothing was lost - the commit was branched off and the ref reset by the user - but the damage cost real recovery work: a diverged local main, a broken local build, and untangling by hand. Remote branch protection never entered the picture; the commit was never pushed, and protection fires at push time anyway, after work has stacked on the wrong base. Not every repo configures protection at all.

madtea finish already refuses to run from the default branch (ADR 0006). madtea commit had no such guard, so the branch-commit-finish loop was open at one end: the sanctioned cycle could not accidentally merge to the default branch through madtea, but it could accidentally build history there.

Two prior decisions constrain any fix:

  • ADR 0009 removed the madtea.allow-* git-config opt-outs because a sticky, ambient setting is an escape the guarded party can arm once, invisibly, in no transcript, surviving sessions. Any override here must not recreate that shape.
  • ADR 0029 rejected consent gates for irreversible destruction because gates degrade per client and invite fail-open regressions. That reasoning is scoped to unrecoverable loss; it does not follow that every guard must be an omission. A commit on the wrong branch is fully recoverable, so a refusal with an escape is acceptable where an omission would be overkill.

Decision

madtea recognises a second guard tier below strict omission. An act that is recoverable but almost always wrong-workflow is REFUSED by default at the service seam, and the only escape is a per-call, transcript-visible override parameter. No sticky configuration key exists for any guard in this tier, and none may be added.

Four clauses make the tier precise:

1. Severity decides the tier

  • Unrecoverable loss (uncommitted work discarded, forge data deleted): the act is OMITTED from every surface - ADR 0029, unchanged.
  • Recoverable-but-wrong-workflow (misplaced history, state a user can undo with ordinary git): the act is refused by default with a per-call override.
  • Ordinary acts: no guard.

The test between the first two: can the user undo the act with tools they already hold, losing nothing? If yes, this tier; if no, ADR 0029’s.

2. The override is per-call and visible, never sticky

The escape is a parameter typed into the very call it excuses - MCP allow_<refused_state>=true, CLI --allow-<refused-state> - so a silent accident cannot pass it, every use appears in the transcript/shell history it came from, and nothing can be pre-armed ambiently. This is the shape ADR 0009’s deleted opt-outs were not: those were sticky config, set once and invisible thereafter. Guards in this tier never read git config, environment, or any other ambient state to decide whether to fire.

3. Naming: --allow-<refused-state>

Every override in this tier names the STATE being tolerated (the cargo --allow-dirty shape): --allow-default-branch, not --force, not --yes-*. Bare force is reserved for git’s own vocabulary on git-mirrored commands (ADR 0030 governs those shapes); the --yes-* family answers consent prompts (install’s sudo/sweep steps), which is a different speech act from overriding a refusal. One canonical name per operation on both surfaces (ADR 0016), snake case on MCP.

4. Structural carve-outs stay structural

Where a guarded act has a legitimate bootstrap case, the exemption keys on repository STATE, never on configuration: the empty-repo initial commit (no HEAD yet) commits to the default branch without any override, exactly as ADR 0009’s empty-repo carve-out and ADR 0006’s initial-push exception already work. A carve-out that could be repurposed as an opt-out is not a carve-out.

First members

  • Default-branch commit guard (#223): madt_commit / madtea commit refuse while HEAD is the repo’s default branch; override allow_default_branch=true / --allow-default-branch; empty-repo bootstrap exempt structurally. This amends ADR 0006: commit joins finish, closing the branch-commit-finish loop at both ends.
  • Ahead-of-origin alarm (#224): madt_status / madt_finish warn loudly when the local default branch is AHEAD of its origin counterpart - the detection layer for misplaced history arriving by any route. A warning, not a refusal: the anomaly may predate the caller and is the user’s to resolve.

Consequences

  • Refusal texts follow the house grammar (ADR 0026, ADR 0023): plain language, one sentence of why, the sanctioned next step first (create a branch), the override second, no decision-record numbers in runtime strings.
  • Both surfaces inherit together. The guard lives at the service seam (ADR 0014), so the CLI and MCP cannot drift apart, and an unhooked client gets the same refusal as a hooked one - no fail-open-on-the-unguarded-client shape.
  • Remote branch protection becomes the backstop, not the guard. The local tier catches the accident before work stacks on the wrong base and works in repos with no protection configured.
  • Breaking for default-branch committers. A user who genuinely commits to the default branch through madtea types the override per call. That cost is accepted deliberately: the sticky alternative is the ADR 0009 hole, and the affected population is small - madtea’s commit surface is primarily the checkpoint tool of the branch-commit-finish loop, and raw git remains fully available.
  • The tier is a decision procedure, not a vibe. Future guards state their severity classification first; “recoverable + wrong-workflow + per-call override” either holds or the proposal escalates to ADR 0029’s omission rule.

Alternatives considered

  • Rely on remote branch protection alone. Rejected: fires at push time or never; the incident’s damage was entirely local.
  • Sticky config opt-in/out (madtea.allow-default-commit). Rejected: ADR 0009 deleted exactly this key for the hooks; recreating it at the tool layer reopens the same invisible, ambient, agent-settable escape.
  • Elicitation dialog instead of a parameter. Rejected: degrades per client (the ADR 0029 findings); a per-call parameter is uniform on every client and cheaper than a dialog round-trip.
  • Omission (no commit on the default branch through madtea at all). Rejected: the act is recoverable and has legitimate uses; omission is the tier for unrecoverable loss, and over-applying it would push users to raw git for a workflow madtea should keep observable.

Relates to ADR 0006 (finish refuses the default branch - amended by this ADR: commit joins it), ADR 0009 (no sticky opt-outs; structural carve-outs), ADR 0014 (one service seam under both surfaces), ADR 0016 (one canonical name), ADR 0026 (refusals terminate in runnable next steps), ADR 0029 (the omission tier above this one), ADR 0030 (git-mirrored argument shapes).

Canonical source: docs/adr/0031-recoverable-guard-tier.md in the madtea repo.