# The error contract

Every refusal is one document — RFC 9457 `application/problem+json`, the shared `Problem`
schema in [`conformance/openapi.yaml`](../../conformance/openapi.yaml) — and it carries one field that is
the contract: **`code`**.

## Classify by `code`. Never by status, never by message.

`code` is a **string on the payload**, and that is the whole design. Until 11 August every
service-layer refusal in Rulework reached the practitioner as a generic 500, *"An
unexpected error occurred"* — because `normalizeError` classified with `instanceof`, and
the server build holds ten independent definitions of `ApplicationServiceError`, one per
Turbopack chunk. `instanceof` asks *"was this made by my copy"*, and across a bundle
boundary the answer is always no (RUL-160).

An error that crosses a process, a bundle, a queue or a wire is **data, not an instance**.
A string survives every one of those boundaries; identity survives none of them. A
registered `Symbol.for` brand is the obvious alternative and it fails the same way — the
global symbol registry is per agent, so a worker thread or an edge realm gets its own.

A handled refusal presenting as a crash teaches her the product is unreliable, and it makes
a real crash indistinguishable from a deliberate one for whoever reads the logs afterwards.

## `status` is not the contract, because codes collapse onto it

Three codes answer **409**: `CONFLICT` ("the record state forbids this"),
`VERSION_CONFLICT` ("someone else wrote first") and `IDEMPOTENCY_CONFLICT` ("this key was
used for different input"). Those are three different next actions for the caller, and a
test asserting 409 passes on all three. `401`, `403` and `500` each carry two codes.

When you write a test, **hold the status constant across every case in the fixture so it
cannot be what passes, and vary only `code`** — the technique that proved out on RUL-160.

## The mapping lives in one place, and it is checked

Every code's HTTP status and its one permitted sentence are in `x-rulework-error-map` under
`components` in the spec. **This document does not repeat the table**, and neither should
any other: a retyped mapping drops members and erases the comments that explain the rows.
RUL-175's own table was corrected three times before it was right, and each correction had
deleted a decision someone had already reasoned through.

`sh conformance/conformance.sh` re-derives the table by putting a real error through the real
classifier — `toTRPCError` composed with tRPC's own `getStatusCodeFromKey` — and fails on
any disagreement, so the spec cannot drift from the code in either direction. Three rows
are load-bearing and each was got wrong once; the spec comment records why.

## What the body may contain

`type`, `title`, `status`, `detail`, `instance`, `code`, `requestId`, `isRetriable`, `validationIssues`, `documentation`, `recovery`. That set is enforced
as an **allowlist**, not a list of banned words — a denylist only catches the leak whose
name someone thought of. No table name, type name, internal id or stack trace.

`detail` is free text and cannot be checked mechanically. It is still bound by the rule
below.

## Rules the checks cannot enforce

Recorded here because they are real requirements, and marked plainly as unchecked:

- **Every message is addressed to a practitioner, not a maintainer.** The vocabulary is
  closed and curated — `SAFE_MESSAGES`, fourteen sentences, safe by construction. The best
  of them names an action: *"This project has no default tax. Set one in Project settings,
  then try again."*
- **A refusal that is secure and unactionable is still a defect.** Every 4xx must name
  either what to change or why nothing can be. RUL-162 found a cross-project caller told
  *"Select a studio to continue"* while being in a studio with nothing to pick.
- **`detail` must not name a record the caller may not read.** It may quote ids the caller
  supplied; it must not disclose ones they did not.

## Recovering from a refusal

Every problem includes a public `documentation` URI and a code-owned `recovery` action. Repair invalid input using the permitted `validationIssues` field paths; refresh and reconcile a version conflict before another write. On an unknown write outcome, inspect saved state. `refresh_without_repeating_write` means the command already succeeded. Authentication, access and configuration actions require resolving that prerequisite before retrying. For retryable transport or rate-limit failures, preserve the logical request and respect `Retry-After`. The `reconcile_original_request` action means compare the original key and payload before choosing whether a genuinely new request is intended.

## Unreadable images

`IMAGE_UNREADABLE` (HTTP 422) means the image pixels could not be decoded. Upload a fresh copy of the original image. This error is non-retriable, with recovery `replace_image`; re-uploading identical damaged bytes, or changing metadata to bypass deduplication, will not repair it. Storage or network failures remain `UPSTREAM_UNAVAILABLE` (HTTP 503).
