ADR 0004 — A positive terminal-closure signal on completed results

  • Status: Accepted
  • Date: 2026-06-23
  • Tracking: legacy tracker 1597.

Context

A side-effecting command already confirms what it did (ADR-0003): finish names its merge and pull, a close says “Closed issue #42”. That cured the caller re-running a step the tool had already performed — but it did not cure a second, subtler redundancy: after a result that ends a unit of work, an agent re-verifies the outcome. The concrete failure that prompted this: madt_finish reported a clean merge, and the agent then ran go build to re-confirm the merged tree compiled — work the finish had already validated and landed. The same instinct fires after a release publish (re-fetch the release), an issue close (re-get the issue), a branch delete (re-list branches).

The confirmation tells the caller the operation happened; it does not tell the caller the operation is settled — that there is no remaining reason to look again. An agent optimizing for correctness will, absent a signal to the contrary, re-verify a terminal result “to be safe.” That re-verification is pure waste: the tool has already done the thing and confirmed it.

Two design facts shape the fix:

A negative steer is the wrong shape. The reflex is to enumerate “don’t run go build / git status / re-get after X.” madtea deliberately removed per-field negative steers in legacy tracker 1593 because they are brittle: they enumerate a forbidden set that is never complete, they read as nagging, and an agent that hits a case the list didn’t name is unguided. The lesson there generalizes — teach the agent why it need not act, not a growing list of acts to avoid.

The signal must travel on the surface the instinct reads, at the moment it fires. The instruction “don’t re-verify a settled result” is most effective read at the exact moment the agent holds a terminal result and is deciding whether to look again — i.e. in the result text itself — not only in a help topic the agent may never open (ADR-0001’s reach argument).

Decision

A result that ENDS a unit of work carries a POSITIVE terminal-closure marker — the canonical suffix:

— done; authoritative final state, no need to re-verify.

It is a settled-state signal, not an enumerated prohibition: it states that the result is the authoritative final state, which removes the reason to re-run build/test/status/get to reconfirm it. One identical clause is used everywhere (format.ClosureSuffix, appended by format.WithClosure / gated by format.IsTerminalMutation) so the agent learns one pattern and the wording moves in exactly one place.

The completion set (true completions only)

The marker is applied ONLY to results that END a unit of work:

ToolResult
madt_finishbranch merged — CONDITIONAL (see below)
madt_prs action=mergePR merged
madt_prs action=closePR closed
madt_issues action=closeissue closed
madt_branch_deletemerged local branch deleted
madt_releases action=createrelease published

Reads (list / get / search) and mid-flow successes (push, stage/add, branch-create) do not carry the marker — they keep their existing next-step pointers. The marker therefore only ever means “this unit of work is finished,” and nothing dilutes it.

A second, narrower tier: a recorded-and-immutable mid-flow result

The terminal marker answers “this unit of work is finished.” There is a distinct redundancy one rung lower: a result that is settled and immutable but does not end the unit of work. The exemplar is a checkpoint commit. When madt_commit records a commit, that commit is a fixed, content-addressed object — re-git log-ing or re-git show-ing it to confirm it landed is the same pure waste the terminal marker cures, but the work is not done: the agent is still mid-branch and will keep editing, committing, and eventually finish. Carrying ClosureSuffix here would be a lie — it would tell the agent the unit is finished when it is only checkpointed, and (per ADR-0001’s reach argument and the dilution risk below) would erode the one signal that means “stop.”

So a second canonical clause covers exactly the recorded-and-immutable case — the suffix:

— recorded; no need to re-check the commit.

It is format.RecordedSuffix, mirroring ClosureSuffix’s shape (the same leading separator, the same single-author / one-place-to-reword discipline, the same positive framing — it states that the commit is recorded, which removes the reason to re-inspect it, rather than enumerating “don’t re-git log”). madt_commit is its sole carrier today: it succeeds with the contract confirmation Committed <shortHash> on <branch> (<N> file(s), +<ins> -<del>): <message> and appends RecordedSuffix.

The two markers are deliberately distinct strings with distinct meanings, so neither dilutes the other:

MarkerMeansCarried by
ClosureSuffixThis unit of work is done; authoritative final state.finish (clean), PR merge/close, issue close, branch delete, release publish
RecordedSuffixThis commit is recorded and immutable; re-checking IT is waste — but the unit of work continues.madt_commit

An agent that learns both reads a checkpoint commit as “logged, keep going” and a finish/merge/close as “settled, stop” — and because the wordings differ, a mid-flow recorded line can never be misread as a terminal done one.

madt_finish is conditional

The “no need to re-verify” claim is only honest when the merged tree is exactly what the branch’s gate validated — i.e. the merge integrated no divergent main changes since the branch’s base. So finish emits the marker only when it can confirm a clean merge, and otherwise reports that the default integrated other changes (so a legitimate re-check is not suppressed).

Detection is a conservative ancestry test made before the merge, while the feature branch is still checked out (HEAD = the gate-validated tip): after a bounded git fetch origin <default>, origin/<default> is an ancestor of HEAD exactly when the branch already contains every commit on the default — a clean, fast-forwardable merge with no foreign commits. If it is not an ancestor, the default has advanced past the branch’s base and the merge integrates divergent changes → not clean. If HEAD or the remote default ref can’t be resolved (a failed offline fetch, a renamed default), the check returns “could not confirm” and the marker is withheld. The asymmetry is deliberate: a false negative only costs a needless re-check, whereas a false positive would wrongly suppress a warranted one.

One author, structurally

The marker has exactly one author per surface — format.RenderConfirmation consults the single IsTerminalMutation predicate for the forge-mutation completions (PR merge/close, issue close, release publish), FormatBranchDelete appends it for the delete, and finishSuccessAgent appends it on the clean path. Error paths never carry it. Under raw_json, the no-body completion results additionally carry "terminal": true — the structured twin of the text marker — so a JSON consumer reads the same settled signal without scraping text.

Taught once, where it is cheapest to reach

Per ADR-0001, the convention lives in the MCP layer: one terse line in the server instructions (a "no need to re-verify." result is settled; do NOT re-run build/test/status/get), added within the legacy tracker 1319 byte budget without raising it, plus a full explanation in madt_help(topic="workflow"). The self-explanatory result-time clause is the primary teacher — it is read at the exact moment the re-verify instinct fires.

Consequences

  • The marker is load-bearing text, so it is CI-guarded. A drift test (internal/mcp/closure_drift_test.go) asserts each completion tool’s success result carries ClosureSuffix (by const reference, not a copied string) and that reads / mid-flow successes do NOT — the consistency-or-nothing guarantee that keeps the convention from half-applying. The conditional finish path is locked by its own tests in internal/service/workflow.
  • Existing confirmation assertions changed. Every test pinning a terminal result’s text (issue close, PR merge, batch close, finish clean) now includes the marker; the change is mechanical and the wording is referenced through the const so a future re-word touches one place.
  • A new line in the “adding a command” checklist. A new completion command routes its success text through the closure helper (or the predicate) and adds a guard row; a new read/mid-flow command must NOT.

Alternatives considered

  • An enumerated negative steer (“after finish, don’t run go build; after close, don’t re-get; …”). Rejected for the legacy tracker 1593 reason: brittle, never complete, and unguided on the case the list didn’t name. The positive closure clause removes the reason to re-verify instead of policing acts.
  • Marker on every mutation (create, edit, reopen, lock, …). Rejected: those are mid-flow, and a marker on a non-terminal result trains the agent to ignore it. Scoping to true completions keeps the signal meaningful. The later RecordedSuffix tier is not a relapse into this. That rejection is about stamping a closure-shaped marker onto arbitrary mid-flow mutations, which dilutes “done.” The recorded tier is the opposite: a separately-named marker, with a different meaning (“this immutable record need not be re-inspected,” not “the work is done”), scoped to the one mid-flow result that is genuinely settled-and-immutable — a recorded commit. Create / edit / reopen are still mid-flow and still mutable in intent (you will edit again, reopen, follow up), so they keep their next-step pointers and carry neither marker. The narrow, named, single-carrier scope is what keeps this from collapsing back into a marker-on-everything that trains the agent to ignore all of them.
  • Unconditional marker on finish. Rejected: dishonest when the merge integrated divergent main changes — there the merged tree is not what the gate validated, and suppressing a warranted re-check is the one failure mode worse than a needless one.
  • Help topic only, no result-time clause. Rejected per ADR-0001’s reach argument: the instinct fires while the agent holds the result, not while it reads a topic it may never open. The clause must be on the result.

Canonical source: docs/adr/0004-terminal-closure-signal.md in the madtea repo.