ADR 0048 — The decision in the event log, and the gate's ordering
Context
Two dogfooding findings that Phase 12 had left recorded and unfixed. Revisited together because they're the same class of defect: an important fact happened and wasn't written anywhere that served as memory.
Finding #17 — an action's decision didn't exist in the log
proposed_action.created, .approved and .denied only went to the
outbox. The outbox is transport: it gets drained, marked with
processed_at and pruned. The decision only survived in
proposed_actions.decided_at, a column that says WHEN but doesn't show
up in the timeline the UI, the Psychologist and the Anamnese read.
Two consequences, and the second one only became visible while writing this ADR:
- Phase 10's main metric couldn't be collected. "Approval clicks" was the central column of the dogfooding observation table, and there was no query that could produce it.
docs/reference/events.mdhad documented all three as domain events all along. The doc promised what the code didn't do — the worst kind of documentation error, because the reader has no way to suspect it.
D5, and the defect underneath it
ADR 0045 recorded D5
as a known limit: with the dev's autonomy set to require_approval,
the agent recycled the worktree and the pending approval ran against a
path that no longer existed. It predicted the right fix would be
"worktree per task."
While investigating to fix it, the picture was different and worse:
AgentIo.propose/3 discarded the action's status ({:ok, _action} -> :ok).
The agent would propose commit, push and PR and call open_gate +
run_qa unconditionally, with no idea whether anything had
actually executed. With manual autonomy:
- the three actions are born
pending; - the gate opens anyway, and QA scans the worktree — where the files are — and approves;
- SecOps approves, the task becomes
done; task.gate_resolvedfrees the agent, which claims the next task and deletes the worktree;- the user approves the commit, and
git add -Aruns in a directory that's gone.
Step 5 returns {"", 2} — System.cmd with a nonexistent cd doesn't
raise an exception — and git/2 turns that into {:error, ""}: the
action fails with an empty diagnosis.
The real damage isn't the approval failing. It's the task closing as
done with no line committed and no PR at all. And it didn't require
exotic configuration: the panel's toggle exposes require_approval for
dev-*.
Decision
The action's decision becomes a domain event, with the real actor
proposed_action.created (actor = the agent that proposed it),
.approved and .denied (actor = the user who decided) start
being recorded in session_events, alongside the outbox lines that
already existed. The outbox stays as transport; the log becomes memory.
The .created payload carries the resulting status, and that's what
makes auto-approval auditable: counting .approved events counts
HUMAN decisions; the policy deciding on its own shows up in .created
with status: auto_approved and an agent actor, and is never confused
with a click. That distinction was exactly what was missing for Phase
10's metric.
approve_always gets it for free: it delegates to
ApproveActionUseCase.
Left out, deliberately, is the proposed_action.created that
provision-repository and bootstrap-runner emit directly to the
outbox: those actions are bootstrap mutations, already narrated by
bootstrap.step_* in the same session, and duplicating them would
count the same fact twice in an approval metric.
The gate only opens after the PR opens
AgentIo.propose/3 now returns :executed | :pending | :refused. The
agent reads the three outcomes and:
- everything executed (
auto_approveautonomy, the default activation seeds) — opens the gate, as always; - something pending — enters
:awaiting_approval, retaining the worktree, and opens no gate. With no PR, there's nothing to judge.
What releases the agent is task.pr_settled, emitted by the api when
pr_open reaches an outcome — executed, denied or failed. opened: true opens the gate, late but correct; opened: false returns the
task with a diagnosis instead of leaving the agent waiting forever for
a gate nobody is going to open.
Three things made this cheap:
propose_pralready carriedstoryTaskIdin its payload, so the api knows exactly which task the PR opened for, with no new table or join.aggregateType: 'task'is already drained byEngine.Outbox.Drainsince Phase 12b — no new aggregate type, no new worker, just one more clause inDevAgentWakeWorker.dev_agent_states.statusis a:string, not an enum — the new state didn't require a migration.
A denied PR doesn't count against the circuit breaker. The decision was the user's; the agent didn't burn any cap. It's the same principle that already applied to restart recovery (RN-047).
Why NOT worktree per task
ADR 0045 predicted worktree-per-task as D5's fix. That prediction wasn't fulfilled, and the reason is it was treating the wrong symptom: worktree per task keeps the directory from disappearing, but the gate would still be judging a PR that doesn't exist, and the task would still close with no commit. The defect wasn't the deletion — it was the gate opening too early.
With the order fixed, D5 dies as a consequence: the worktree is only
recycled on gate_resolved, the gate only opens after the PR has
opened, and the PR only opens after commit and push have executed.
No pending action outlives its own worktree, with no change to the
directory structure, no disk growth and no new cleanup policy.
Consequences
docs/reference/events.md stops lying about three event types — and
the generator, which compares the prose against the emission points,
now actually finds all three.
The metric Phase 10 lost now exists. A future dogfooding can answer "how many times did the human decide" with a query, instead of live note-taking — which is exactly what got lost by not being done (see the harvest).
session_events volume grows: every proposed action generates one more
event. These are actions that were already narrated during execution
(action.executed/action.failed); what was missing was the beginning
and the decision.
The engine test fake's default changed from pending to
auto_approved. It made no difference while the status was discarded;
from here on it does, and auto_approved is what reality produces —
ActivateExecutionUseCase seeds auto_approve for every dev agent's
git actions. A default different from production would send the whole
suite down the manual-approval path.
Left for later, as backlog:
- Worktree per task, if a case ever shows up where the approval takes long enough for the worktree to become a problem for another reason. It stopped being a defect fix and became an architecture choice.
git/2with an empty diagnosis.System.cmdwith a nonexistentcdreturns{"", 2}, and that turns into{:error, ""}on any directory failure, not just this one. This one's root cause is now closed, so the empty message no longer has a known trigger — but the diagnosis gap is still there.- Pending actions of types other than
pr_open. The interlock covers the dev agent's path, which is where the damage was concrete. A pendingterminalstill doesn't block anything, and it didn't need to.