Story 2183: an error is allowed to say what broke — and the suite refuses to ship one that won’t

WHY. The catastrophe this Story prevents is a human (or an agent) paged at 3am, staring at a log line that names a failure without explaining it. This week’s Mira ws-handler incident is the exact shape: the gateway logged websocket_request_rejected with an error code and nothing else — not what the handler needed, not what it found, not what to do next. The debugger had to reverse-engineer the runtime config from the code. Same week, an enrich kick died on a raw boto InvalidAddress that never said which queue URL was bad. A mute error is a door welded shut: the code that raised it knew everything, and told you nothing. Story-2183 (sibling of pramaan-docs Chore-2182, agents mirror #2184) makes the error-message contract enforceable — every boundary error answers four questions, and a CI gate fails the suite the moment a new error class ships mute.

🛰 Satellite — the one sentence

A pure gate walks every shipped PramaanError subclass and fails the suite unless each one carries the stable half of the contract on the class — a real docstring (what failed) and a non-empty remediation string (what to do next) — while the runtime half (what was needed / what was found) rides the raised message; the two canonical anti-examples (websocket_request_rejected, the enrich-kick InvalidAddress) are rewritten to prove the shape, and remediation is surfaced in the boundary handlers’ operator logs so it reads straight off the line.

✈️ Airplane — a fault light with a printed fix, not just a blinking code

Picture the difference between two dashboard warning lights. The bad one blinks a number: ERR 47. You now own a scavenger hunt through the manual. The good one reads: “Coolant low — expected 2.0L, sensor sees 0.3L — top up before driving.” Four facts, and you are moving in seconds. The four facts split by how often they change. Two are fixed for the whole class of fault and can be stamped on the part at the factory: what this fault is (coolant, not oil) and what you generally do about it (top up). Two are specific to this failure and can only be read at the moment it happens: the numbers. So the contract puts the stable pair on the error class (docstring + remediation) and the runtime pair in the raised message. And because the factory can guarantee the stamped half, we put an inspector on the line: the CI gate refuses to let a part leave without its label.

🔬 Microscopic — the four moving parts

1. The contract, made a class attribute — remediation

PramaanError gains one field, remediation: str = "". Every concrete subclass sets it to the “what to do next” sentence for its class of failure — stable, so it belongs on the class, not re-typed at every raise site. The docstring, which all subclasses already had, carries “what failed.” The runtime message still carries the specifics (the id, the bad value, the expected shape). Nothing about how errors are constructed or mapped to HTTP changes — code and http_status are untouched, so the wire contract consumers key on is byte-identical.

2. The gate — a pure checker with teeth, proven both ways in one file

tests/unit/shared/errors/test_error_contract_gate.py walks PramaanError.__subclasses__() transitively, scoped to classes whose module starts with src. (so test fixtures and the inline mute class are out of scope), and asserts each has a substantive docstring and a non-empty remediation:

green on main   → every shipped subclass self-explains
red on a mute   → a bare-code class (only `code = "..."`) fails BOTH checks

The “red” half is proven in the same file without shipping a mute class into src: test_gate_rejects_a_bare_code_class defines a local _MuteError(PramaanError) and asserts the checker returns both violations. So the gate is demonstrably able to fail — not a green rubber-stamp. When the gate first ran it immediately caught two subclasses I hadn’t hand-listed (EmbeddingDimensionError, RerankError); that is the gate doing its job — it governs the whole tree, not a curated list.

3. Surfacing “what to do” where the debugger actually looks

A remediation nobody reads is dead weight, so it is emitted in the two boundary handlers as an operator-log field — not in the response body (that would change the wire shape). The shared http_handler.py adds err.remediation to its warning/error log context; the WebSocket gateway’s websocket_request_rejected line — the exact mute line from the incident — now carries err.message and err.remediation beside the code. This is precisely what pramaan-docs debugging Step 2a (Chore-2182) tells a debugger to read first: extract the structured error field before widening to env/config/code.

4. The targeted boundary sweep — the two named anti-examples + the terse WS bodies

Not codebase-wide (the contract lives on the classes now); a focused pass over the highest-traffic boundary messages:

Before / after — three exemplars

SiteBeforeAfter
router_ws.py gateway reject log logger.warning("websocket_request_rejected", extra={"error": exc.code}) — a code and nothing else. Same event + err.message and err.remediation on the line; every PramaanError now owns a remediation, so “what to do” is present.
enrich_kick.py SQS send Raises whatever boto raises — a raw InvalidAddress that never says which queue URL was bad. Wrapped: PramaanError("Enrich kick for document <id> failed: send_message to '<url>' raised InvalidAddress: ... Verify CONTEXT_ENRICH_QUEUE_URL resolves to the live enrichment queue's full URL.")
inference/errors.py tier-unavailable Docstring only; no stable “what to do.” remediation = "Request a currently-live tier (US/EU/India) or wait for the tier to come online; there is no silent fallback to another tier."

The ~20 touch points swept

13 PramaanError subclasses given a remediation (8 in errors/exceptions.py: NotFound, Forbidden, Unauthenticated, BadRequest, Conflict, Validation, Webhook, IdentityHat; 5 in inference/errors.py: the base + tier-unavailable, route-denied, adapter, consent-required; plus EmbeddingDimensionError and RerankError in context/), 3 terse _error_response bodies in router_ws.py (unknown-route, upstream-failed, internal-error), the websocket_request_rejected log line, the enrich_kick SQS wrap, and the http_handler log field.

What was deliberately not built

SkippedWhy it’s correct to skip
A class-level message_template mechanism with typed placeholders Errors here are constructed with a runtime message at the raise site — a template refactor would rewrite every construction and change behavior. The stable half (remediation) is enough to gate; the runtime half stays in the message, where the specifics already live.
Putting remediation in the HTTP response body That would change the wire shape consumers see. The AC is messages/remediation only, codes stable — so remediation goes to operator logs, not the response.
A codebase-wide rewrite of every raise site The issue scopes the sweep to the highest-traffic boundary errors. Once the contract lives on the classes, every raise inherits the stable half for free; only the two named anti-examples and the terse WS bodies needed hand rewriting.
Gating the separate PramaanRailsError tree That hierarchy subclasses Exception, not PramaanError, and is the agents rail’s concern — its mirror gate is Story-2184 in pramaan-agents.

How it was proven (system boundary, not just green units)

One sentence to remember. An error carries a fault light with a printed fix — what failed and what to do stamped on the class, what was needed and found in the message — and the CI gate is the inspector that won’t let a mute one leave the line.