# ADR 0139: A coding-tool refusal is something a model can act on

> Source: https://github.com/max-listov/stitchkit/blob/master/docs/decisions/0139-a-coding-tool-refusal-is-something-a-model-can-act-on.md

---

# ADR 0139 — A coding-tool refusal is something a model can act on

## Context

Every refusal the coding tools made was a plain `Error`. `toolResultFromError`
scrubs anything that is not an `AppError` down to a bare
`INTERNAL_SERVER_ERROR` — correct, because an internal cause must not leave the
process — so a missing file, an ambiguous snippet, a file that already exists
and a path outside the root all reached the model as the same empty failure.

Observed, not theorised: in a run of nine models on one task, a model met two of
these, concluded that writing files was unavailable to it, began writing
everything into the workspace root, and finally stopped writing code at all in
favour of prose about what it would have done.

The tools were unusable in a second way. `apply_patch` required a `baseSha256`
carried from the last read and defaulted to `dryRun: true`, so one edit was two
calls whose protocol the schema never showed. Half the models never managed an
edit; not finding a way to change a file, they rewrote whole files with
`write_file` and met its refusals too.

## Decision

**An ordinary outcome is a typed refusal; a host-level cause stays internal.**
The boundary is the layer, not the errno. A model is an operator inside the
workspace: a refusal phrased from a relative path and the facts of its own
request tells it nothing it could not read with `read_file`. So typed refusals
are constructed in the tool layer, from context that layer knows.
`contained-files.ts` — shared with harness resource discovery — stays
`AppError`-free, and everything it throws keeps being scrubbed. Default-deny:
a cause with no case in the tool layer is internal.

`ENOENT` on a path the model named is `NOT_FOUND`; `ENOENT` from a root that
vanished is not, and reaches nobody. `EACCES` and `ENOSPC` have no case and stay
generic. The artifact store returning an inconsistent `totalBytes` is a host
breaking its contract, not an outcome — also generic.

**The message travels inside the details it belongs to.** `toolResultFromError`
renders `details: appErr.details ?? { message }`, so structured details displace
the message entirely: the natural way to write an informative refusal — a count
plus a sentence — would have arrived as `{"occurrences":3}` with no sentence.
`codingRefusal` copies the message into the details, which makes that
unrepresentable rather than merely discouraged, and puts the *instruction* in
`hint`, where the Agent envelope carries it as `_hint`.

**`edit_file` replaces `apply_patch`.** One clean path, not two forms of one
mutation. The digest becomes optional — `oldText` is itself a freshness guard
for the region being changed — and `dryRun` defaults to off, so an edit is one
call. A caller wanting the whole-file guarantee still passes the digest
`read_file` returns, and `edit_file` returns the new one so the next edit chains
without re-reading.

Dropping the mandatory digest removed the check that made a stale computation
visible, so the window it guarded is closed rather than narrowed: the read, the
occurrence count and the construction of the new content all happen inside
`withCodingPathLock`. Without that, two concurrent edits of different snippets
in one file would each build a whole file from the same base and the second
write would erase the first. The lock is process-local — nine agents in one
process are covered; two processes over one workspace are not.

**`write_file` creates missing parents, and says what it created.** Every
mainstream tool set does, and the observed failure was that models do not read
refusals — they conclude. The walk that finds the missing directories runs
*before* authorization and reports them in the authorization payload, because a
host cannot authorize a mutation it has not been told about; the creation
happens after. Containment is unchanged: `mkdirat` has no `O_NOFOLLOW`, so the
guarantee comes from the `openDirectoryAt` that follows each create and refuses
a symlink. Auto-creation introduces one new risk — a typo becomes a successful
write into a tree nobody meant to make — so `createdDirectories` names what
appeared, which is the only signal by which a model catches its own `packags/`.

## Consequences

- The authorization union gains `edit`, `list` and `glob` and loses `patch`.
  The dangerous case is not the exhaustive matcher the compiler catches: a
  default-allow matcher silently authorizes operations the host never approved,
  and a default-deny one silently kills `edit_file`. The migration says so.
- `packages/core/tests/coding-tool-refusals.test.ts` enumerates the tools from
  `AGENT_CODING_TOOL_NAMES` and refuses one with no registered refusal, in the
  shape `option-effects` already uses. It asserts the *serialized* envelope, not
  the code alone — a gate checking only the code is green on exactly the
  message-displacement defect described above.
- The Darwin backend gains `createDirectoryAt`; it is the one part of this that
  cannot be proven on Linux, and the real-Darwin lane (→ ADR 0135) is where it
  is proven.
