# ADR 0141: A provider refusal is classified, not phrased

> Source: https://github.com/max-listov/stitchkit/blob/master/docs/decisions/0141-a-provider-refusal-is-classified-not-phrased.md

---

# ADR 0141 — A provider refusal is classified, not phrased

## Context

Three independently maintained consuming applications carry the same 69-line
file for turning a provider failure into something a person can read. Two copies
are byte-identical but for one `catch`; the oldest carries
`LIBRARY FILE: no project-specific imports` in its header — the author knew it
was a library and had nowhere to put it.

The runtime reported `provider_failure` and kept the provider's envelope, and
said nothing about *which* failure. Running out of credits, being rate limited
and naming a model that does not exist are three situations with three different
next moves — top up, wait, choose another model — and all three arrived as one
word. So each application derived the taxonomy again from its own traffic, and
they disagree on details that only production reveals.

This is not a domain model (→ ADR 0002): "the provider answered 402" is a fact
about the provider, not about anyone's business. The core is where that answer
arrives, and it is the only place that sees it.

## Decision

**The core classifies; the application phrases.**

`classifyProviderFailure` returns the reason, the status when the provider gave
one, whether the same request could work unchanged, and — the part that makes
the rest usable — **the evidence the answer rests on**. A status code is the
provider stating its own answer. A message match is us reading its prose, which
changes without notice and differs between providers. `none` is an honest
refusal to guess. A caller deciding whether to retry automatically needs to know
which of the three it got, and a classifier that hides that difference invites
exactly the automation that should not be built on prose.

The sentence stays with the application. Its tone, its language and its decision
about what to admit to a user are product choices; a core that wrote them would
be answering a question nobody asked it, and every consumer would immediately
need to override the wording anyway.

`retryable` separates waiting from rewriting: a rate limit can pass, and an
oversized prompt repeated unchanged fails identically forever. An unrecognised
failure is **not** retryable — a retry loop built on a guess is how one broken
request becomes a bill.

`isToolResultFailure` covers the other half of the copied file: a tool can
answer `200` with `{ error: … }`, and a loop reading only the transport counts
that as work done.

## Consequences

- Both are plain functions with no runtime dependency, which is the point: the
  application that hand-rolls its model loop is exactly the one that copied this
  file, and a primitive it cannot reach without adopting the whole runtime would
  not have helped it. → the same reasoning exported
  `normalizeOpenRouterUsage`.
- The message table is a fallback and is documented as fragile. It runs only
  when no status survived, and it reports itself as prose-derived.
- What did **not** come along: extracting an ORM's error text out of a tool
  failure. That is one consumer's persistence layer showing through, and it
  belongs to that consumer.
