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:
| Tool | Result |
|---|---|
madt_finish | branch merged — CONDITIONAL (see below) |
madt_prs action=merge | PR merged |
madt_prs action=close | PR closed |
madt_issues action=close | issue closed |
madt_branch_delete | merged local branch deleted |
madt_releases action=create | release 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:
| Marker | Means | Carried by |
|---|---|---|
ClosureSuffix | This unit of work is done; authoritative final state. | finish (clean), PR merge/close, issue close, branch delete, release publish |
RecordedSuffix | This 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 carriesClosureSuffix(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 ininternal/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
RecordedSuffixtier 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
mainchanges — 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.