# `Pixir.Permissions.WriteDenials`
[🔗](https://github.com/Ranvier-Technologies/pixir/blob/main/lib/pixir/permissions/write_denials.ex#L1)

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.

# `empty`

```elixir
@spec empty() :: map()
```

The empty confession. Present, not absent, when nothing was denied.

# `from_history`

```elixir
@spec from_history(list()) :: map()
```

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.

# `from_session`

```elixir
@spec from_session(String.t(), String.t()) :: map()
```

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`.

# `unavailable`

```elixir
@spec unavailable(term()) :: map()
```

The confession of a Log that could not be read. Well-formed, never a zero.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
