The mandatory write_denials confession for bounded-write runs (#446).
A bounded-write denial is recoverable feedback: the model receives the structured denial as a tool output and may adapt inside the same Turn. The second denial in that Turn is turn-fatal. Because a run can therefore complete cleanly after a denial, a completed status alone no longer proves the worker never pushed at its boundary — so every bounded-write envelope and every workflow checkpoint carries this confession, present even when empty.
Absence is a schema violation, not an ambiguous silence. A completed status with a non-empty confession is a signal for the coordinator to check whether the worker's scope was correct, not a defect in the worker.
The confession is derived from the Log, which stays the source of truth: every
denial — recoverable or fatal — is a permission_decision Event with
decision: "deny" and gate: "write_policy".
One write_policy-gated denial is deliberately not confessed: the
bash_disabled kind. The shell being disabled is a property of the bounded
write mode rather than a write-allowlist violation (#218), it never strikes in
Turn, and confessing it would report a worker that merely tried to shell out
as if it had probed its write boundary. The confession classifies exactly the
events the strike counter classifies.
What each denial names as its target
Every entry carries a normalized_path, and every entry's normalized_path
is what the call aimed at — never where the refusal happened to stop. An
entry with no target at all is one the coordinator cannot reconcile against
the worker's transcript, so the key is populated for every kind:
matched_rule | Target in normalized_path | Also carries |
|---|---|---|
no_allow_match, deny_match, protected_path | the requested write path | — |
workspace_root_not_writable | the requested path ("." normalized) | — |
path_outside_workspace | the requested path; it never normalizes | — |
symlink_path_component | the requested path | symlink_component |
path_not_inspectable | the requested path | uninspectable_component |
outside_workspace (bash) | the requested command | token |
child_policy_override_unsupported | the tool name, "spawn_agent" | — |
unsupported_mutating_tool | the tool name | — |
The last two rows name a tool, not a path, because the denial has neither a
path nor a command: the worker was refused for handing a child a different
policy, or for calling a mutating tool the policy does not model. The target
is therefore ambiguous on its face — "spawn_agent" reads the same whether it
is a tool name or a file of that name — and it is disambiguated by the two
keys that are always present beside it: matched_rule (one of exactly those
two rules) together with tool. That pair is the contract; no separate
target-kind field is stamped, so a consumer that needs to know what it is
looking at reads matched_rule rather than guessing from the string.
Where a confinement walk stopped is a second fact about the same denial, and
it lives in its own key (symlink_component, uninspectable_component)
precisely so it can never be mistaken for the target. A write to
link/deep/out.txt refused at the link component confesses the full
requested path and reports symlink_component: "link" beside it; confessing
"link" as the target would name a write no call ever made.
Summary
Functions
The empty confession. Present, not absent, when nothing was denied.
Build the confession from an already-folded Session history.
Build the confession by folding session_id's Log under workspace.
The confession of a Log that could not be read. Well-formed, never a zero.
Functions
@spec empty() :: map()
The empty confession. Present, not absent, when nothing was denied.
Build the confession from an already-folded Session history.
Denials are reported in Log order. Each carries the denied tool, the
normalized path (or the denied command), the matched rule, the policy
identity, and a disposition of "recovered", "fatal", or "unresolved".
The fatal strike is the denial the Turn actually died on: the last denial of a
Turn that recorded a write_policy_denied turn_failed. Every other denial of
a Turn that reached a terminal event was survived, so it reads as
"recovered" — including denials in earlier Turns of the same Session, whose
strike counters reset.
A denial whose own Turn never reached a terminal event in the Log reads as
"unresolved". A Turn ends at its terminal event or at the user_message
that opens the next Turn, whichever comes first: an interrupted Turn (and a
Turn lost to a Task DOWN) records neither assistant_message nor
turn_failed, so only the boundary closes it. The Log is the source of truth
and an incomplete Log — cut off by a crash between the denial and the Turn's
terminal event, ended by an interrupt, copied partially, or folded while the
Session is still running — does not say whether the worker adapted. Reporting
such a denial as "recovered" on the strength of a later Turn's terminal
event would be a fail-open claim on missing evidence; "unresolved" tells the
coordinator to go read the Log.
Build the confession by folding session_id's Log under workspace.
An unreadable Log yields the unavailable confession rather than raising or
reporting zero: %{"status" => "unavailable", "error" => …, "denials" => []},
with no count key at all. "Nothing was denied" and "the evidence could not
be read" are different facts, and a coordinator that reads only the confession
must not be able to mistake the second for the first — that is the fail-open
the whole confession exists to close. The absent count makes the difference
impossible to miss for a consumer that sums counts.
A missing Log is not an unreadable one. Log.fold/2 folds a Session that
never wrote an Event to {:ok, []}, and a Session that recorded nothing
genuinely denied nothing, so it confesses %{"count" => 0, "denials" => []}
like any other clean run. unavailable is reserved for the cases Log.fold/2
actually reports as errors — a corrupt Log, an unsafe or rejected session id,
an unreadable file — plus a non-binary session_id or workspace.