ADR 0067: Accept validates the exact tree it lands, fast-pathed by a gate receipt
Amendments.
- Vocabulary: current spellings are
done(formerlyfinish),accept(formerlygraduate), andupdate(formerlyintegrate); the gate-pass artifact became the receipt and is now the proof — receipt-family terms below read as their proof-family successors; the decisions below are unchanged.- ADR 0110 — landing target:
accept --to trunkbelow is now plainaccept— the configurable destination was removed and the trunk is the single landing target; the proof-fast-pathed validation decided here stands unchanged.- ADR 0152 — write authority: proof I/O remains best-effort against failures that arise after validation starts, but
donenow proves its Git-admin write authority before project jobs run; a known denial is a fail-fast gate precondition, not a green run without a proof that acceptance has to repeat.- ADR 0165 — marker path: the worktree-local proof marker lives beneath Discern's Git-admin
discern/namespace, and its filename followed the proof rename (src/engine/gate/proof.tsis the live authority); its identity and lifetime are unchanged.- ADR 0313 — setup landing: the setup-specific acceptance path now uses the same Proof inspection and exact validated-commit rule. It refuses incomplete Proof instead of rerunning the Gate implicitly; a moved trunk earns new Proof on the setup branch before landing.
- ADR 0339 — proposed Standard limits: a proposal-bearing Proof adds a narrower owner decision before acceptance. The live proposal record, Proof, and supplied approval token must agree on the Standard, value, and reason; acceptance still lands the validated commit without changing it.
Status: accepted. Supersedes ADR 0061 (the fix-stage-only fixed-point guard in accept) and overturns its "run the whole gate in accept" rejection — the gate receipt removes the cost that rejection rested on. Reuses the merge precondition and the one result envelope.
Context
§accept lands a branch onto the trunk — locally, via accept --to trunk, with no PR and no CI. Its only quality guards were the merge precondition (the branch contains the latest trunk) and the fix-stage fixed-point check (ADR 0061, which re-ran only the fix stage). Neither runs the build, the checks, the tests, or the scope gates. So the property "what lands passed the gate" held only because an agent was trusted to have run a clean done — and that trust breaks on a routine sequence:
- An agent finishes its work;
doneis green. It then waits for review. - While it waits, the trunk advances beneath it (other branches accept).
- On approval it runs
accept, which refuses: the branch is now behind the trunk. - It runs
update(a clean merge of the new trunk into its branch) and, seeing it succeed, immediately re-runsaccept— which now lands.
The merge in step 4 creates a new tree the green done from step 1 never saw. A clean textual merge can still be a semantic conflict — the trunk renamed a function the branch calls, changed a type, tightened a check, added a test the branch breaks. accept re-ran only the fix stage, so none of that was caught, and the broken merge fast-forwarded onto the trunk. discern's own gate already encodes the invalidation: done front-loads the merge check precisely because a branch that updates "discards whatever the gate computed against the pre-integration tree" — but nothing re-asserted the gate at the one boundary that writes to the trunk.
ADR 0061 considered running the whole gate in accept and rejected it: "it runs the test suite on every acceptance, slow across many parallel worktrees." True — if accept re-runs the gate unconditionally. But in the common case nothing changed since the agent's own done, and a re-run is pure waste. The cost objection is really an objection to redundant runs, not to checking.
Decision
§accept refuses to land a tree that does not pass the whole gate — but skips the re-run when a gate receipt proves the current tree already passed.
- A gate receipt. On a GREEN run over a CLEAN tree,
donestamps the validated HEAD SHA intodiscern-gate-receiptin the per-worktree git admin directory (.git/worktrees/<name>/discern-gate-receipt), the same mechanism as the worktree-ready sentinel. It is worktree-local (never shared across branches), never tracked or committed (it sits inside.git), and self-cleaning (it vanishes with the worktree). A FAILED run clears it (fail-closed); a green-but-dirty run leaves a prior clean vouch intact (it can't vouch for clean HEAD, but the old vouch is still truthful at its own SHA). - Honored only when it still describes the tree. The receipt is trusted by
acceptonly while it names exactly the current HEAD and the tree is clean (git status --porcelainempty — thegit add -Ainacceptwould sweep untracked files too). Any new commit (the mergeupdatecreates), amend, or uncommitted edit makes it stale, and accept falls back to the gate. It is a fast-path cache for "this tree already passed", never a substitute for the gate. - Fast path / slow path, in the apply phase. Like the guard it replaces, the check sits in
executeAcceptPlan, before any teardown/removal, so a refusal leaves the branch and worktree intact and--dry-runnever reaches it. Valid receipt → land without re-running. No/stale receipt → run the full gate via the onefinishResultcore; on any failure, refuse with the gate's own failed-stage message and a capped list of its diagnostics, thendiscern donefor the rest. The branch keeps all its commits. - One definition of "good enough to land". Accept no longer enforces a weaker subset (the fix stage) than
done; it asksdoneitself. The merge precondition it already checked is the same onedonefront-loads, so the two never diverge.
Consequences
§- The stale-
donehole is closed. A clean-merging but gate-breakingupdatecannot fast-forward onto the trunk: the merge commit invalidates the receipt, accept re-runs the gate against the merged tree, and refuses. The same now covers lint, type, test, and scope-gate failures — not just the fix stage (ADR 0061) — and any tree that reached accept without a cleandoneat all. - No redundant gate runs in the common case. When the agent finished and nothing moved, the receipt is valid and accept lands immediately — no second gate run, so the cost ADR 0061 feared never materializes. The gate runs at accept exactly when the tree is genuinely new (post-update, a stray commit, a dirty tree) — which is precisely when it must.
- A sliver of persistent state, kept out of the repo. discern's footprint stays one tracked file (
discern.toml); the receipt lives inside.git, is worktree-scoped, and is keyed to git state so it self-invalidates. It is an optimization — fail-closed to honor, protected by an early write-authority probe, and still best-effort against a later point-in-time failure. - Receipt decisions are visible in the agent envelope. The receipt remains best-effort, but not silent:
done --jsonreports the stamp/clear outcome indata.gate_receipt, andaccept --jsonreports whether it used the receipt fast path or re-ran the gate indata.gate_validation. A suppressed human logger, failed receipt write, or stale marker should never leave an agent guessing why acceptance ran the gate. - Residual: environment drift at a constant tree. A receipt vouches that a clean HEAD passed; if the environment later changes so the same tree would now fail (a dependency or clock-dependent test), the fast path would skip a run that would catch it. Vanishingly rare, unavoidable in any caching scheme, and narrowed by clearing the receipt on every failed run. The always-run alternative avoids it only at the redundant cost this ADR exists to remove.
Alternatives considered
§- Auto-run
doneafter a non-no-opupdate. Validates the merge at update time, which closes the common path — but it checks the wrong moment: anything committed between update and accept (fixing the conflict the auto-run surfaced) lands without re-validation, accept stays unsafe-by-construction, and it couplesupdateto the heavy gate. Rejected: the invariant belongs at the land boundary, and a wait with repeated updates can make it run the gate more often than the lazy receipt-gated accept. - Run the whole gate in accept unconditionally (ADR 0061's rejected option, no receipt). Correct but pays the redundant-run cost on every acceptance — the objection that motivated ADR 0061's narrower guard. The receipt keeps the correctness and removes the cost.
- Keep only the fix-stage guard (ADR 0061). It catches unformatted output but nothing else; the observed hole is broader than formatting. The validation gate subsumes it (a fix-stage strand is a
fix_driftgate failure), so the targeted guard is retired, not kept alongside.