Skip to content

ADR-0048: Error Feedback Must Reach the User — the Silent-Failure Contract

Accepted Cross-Project Universal

Accepted 2026-09-02 (Commander ruling), as written. Ratified on the evidence of one day's crit review on lokalekeuze: eight of twenty-two PRs carried this ADR's exact defect — a failed state rendered as empty or complete, a malformed success response leaving a dialog open, a mutation hiding its refresh failure — with nothing enforcing the contract. First adoption is lokalekeuze (Level 1 gate over store state unions, LK-0322); the 2026-08-31 adoption order below (emmie first) stands for the fleet. Items 2–6 under What needs deciding remain open residuals and do not gate acceptance.

Proposed 2026-08-25. Amended 2026-08-31. Drafted on Commander directive after a fourth incident of the same class reached a paying customer. The 2026-08-31 amendment adds incident 5, widens rule 5, adds rule 8, and reorders adoption — see the amendment note at the foot. Awaiting ruling — see What needs deciding.

Date: 2026-08-25 · Amended: 2026-08-31

Context

Five incidents, three territories, fourteen months. Each was investigated separately, each was fixed locally, and none of the fixes prevented the next one — because the defect is not in any single layer. It is in the composition. Every layer behaved reasonably on its own; the error still reached the user as nothing — or, in incident 5, as a promise the system could not keep.

1. wijs WR-0425 (2026-07-15) — a fix that made feedback worse than the crash. A Medic replaced a production 500 (assert($resident), a no-op under zend.assertions=-1, reaching ResidentResolver::update() as a TypeError) with a graceful ResidentUnavailableException rendering 422 with {message} only, no errors key. Correct at the API layer. But resources/js/services/error.ts keys solely on response.data.errors, and the submit path had no catch — so the message-only 422 fell through to nothing. The modal simply did not close. The ally reviewer flagged it: "worse feedback than the crash it replaced." The backend-scoped dispatch never included a frontend leg, because nobody framed the change as a contract change.

2. entreezuil #301 — one truthiness bug, three surfaces, total silence.{} is truthy in JavaScript. A truthy check on the error bag sat at three sites, so a 401 {errors:{}} was simultaneously swallowed by useFormSubmit (no re-throw), rendered as nothing by useValidationErrors (empty bag → no fields), and had its fallback toast suppressed. Strictly worse than the bug the PR was fixing. The string corollary — found writing the regression matrix, missed by two independent reviews — is worse than silent: {errors:'nope'} is truthy, so Object.entries spread it character-by-character and rendered garbage under four phantom field names.

3. emmie — a message discarded twice, by two layers that each thought the other handled it.Handler::renderProductionException() returns new JsonResponse([], 500) — an empty body — for any CustomException whose response code is exactly 500. apps/middleware/toast.ts then replaces any 500 with "Er is iets misgegaan. Het development team is op de hoogte gesteld." A carefully-worded operator message on a 500 reaches nobody outside APP_DEBUG=true. Fixing either layer alone changes nothing. A docblock asserted the 500 was required so the error tracker would fire; that is false (reportable() never reads the HTTP status), and the false rationale cost the operator the message.

4. wijs WR-0956 (2026-08-25) — the one that reached the customer. Changing desk amounts on the first day of any appointment-slot window failed deterministically with a chk_appointment_slots_window violation. The admin saw: spinner stops, no toast, no inline error, modal still open — indistinguishable from a slow save. fs-http rejects on non-2xx; useLoadingState is try/finally with no catch, so the rejection was unhandled; services/error.ts needs an errors bag a 500 does not carry; message-only-error.ts gates on 422 only. A 500 produced zero feedback anywhere in the application.

The customer closed a location for a specific date, was shown no error, and reasonably believed it had worked. It had not. They reported it as "wordt niet correct verwerkt" — a data bug — and three of the five hypotheses that cost the investigation a full day existed only because the failure had left no trace at the moment it happened.

5. emmie prod-script (2026-08-31) — three correct layers composing into a lie. On app.emmie.nl — production, Script's own tenant, real care data — the deployed handler carries no framework-status mapping: renderProductionException() falls through to new JsonResponse([], 500) for any exception it does not recognise. Six framework types land there — ModelNotFoundException, AuthorizationException, TokenMismatchException, BackedEnumCaseNotFoundException, OriginMismatchException, RequestExceptionInterface — a missing record, a permission denial, an expired session. The frontend converts any 500 into "Het development team is op de hoogte gesteld." And Laravel's $internalDontReport lists precisely those six classes, so shouldntReport() short-circuits report() and the exception reaches no sink at all: not the error tracker, not stderr, not the Slack log channel. Two app-level classes fall into the same hole through the app's own $dontReportUserNotFoundException (thrown on login and on password change) and RegistrationLockedException.

The intersection is total because both sets encode the same idea — framework exceptions with known HTTP semantics. The handler failed to map them; the framework had already decided not to report them. Nobody wrote a bug. The result is a system in which a user is told the development team has been notified, and in which it is structurally impossible for the development team to have been notified.

It surfaced because a user reported the toast and no notification arrived. The reporter could add nothing, and neither could the record — the failure had left no trace anywhere, by construction. The fix already existed on development (EMMIE-0715 adds the mapping); the deployed branch was 3689 commits behind it.

The shape, stated once. A silent failure on a write path is worse than the loud error it replaces: the loud error at least surfaces something, and it surfaces it to the person who can still retry. A silent one converts a five-second retry into a customer support ticket weeks later, and by then the evidence is gone. Every one of the five incidents above was cheap to fix and expensive to find.

Options Considered

A. Fix each territory's error layer as incidents arrive. What we have been doing. Five incidents, five local fixes, no reduction in rate. Each fix is correct and none of them generalises, because the next occurrence is in a different layer of a different territory.

B. One shared error-handling package in the Armory. Attractive, and wrong as the primary move. ADR-0043 established that error handling is territory-local by design — every territory feeds validation through its own service, and a shared component that imports an error service couples to one territory. The variation is real (Laravel handler shapes, toast systems, form libraries, three different frontend framework generations). A package can own the predicate; it cannot own the policy.

C. A per-territory arch test with no shared doctrine. Enforceable, but each territory would invent its own idea of what "feedback reached the user" means, and the emmie cases (3 and 5) show the failure spanning two and three layers respectively — more than a single-layer test can see.

D. Doctrine that names the invariant, plus a mechanical floor per territory, plus one shared predicate. Chosen. The invariant is universal; the mechanism is local; the one genuinely duplicated piece of logic (the bag check) moves to the Armory.

Decision

1. The invariant — no error may reach the user as nothing

Every failed request must produce user-visible feedback, on every path, without exception. "The user" means the person who took the action, at the moment they took it. A report() to an error tracker is not feedback. A console error is not feedback. A modal that fails to close is not feedback.

This is a fail-loud floor, not a quality goal: a path that can fail silently is a defect regardless of how unlikely the failure is judged to be.

2. The error layer needs a terminal fallback that claims everything unclaimed

Each territory's frontend error layer MUST have a terminal handler that fires for any error response its specific handlers did not claim — explicitly including 5xx, network failures, and timeouts. Specific handlers (field-validation bags, domain-refusal toasts) are refinements of the fallback, never a replacement for it.

The wijs shape was: services/error.ts claims 422-with-errors, message-only-error.ts claims 422-without-errors, and nothing claims the rest. Two handlers, each correct, and a hole between them that swallowed a customer's write for weeks. Enumerate what you handle and then handle the remainder; do not enumerate and stop.

3. A submit path that awaits must catch

awaiting a request whose rejection only stops a spinner is a silent failure by construction. Every submit path either catches and surfaces, or delegates to a helper that does. A shared submit composable (useLoadingState, useFormSubmit, fs-form's guard) MUST make the error path explicit — it may swallow, re-throw, or expose an error ref, but that choice is declared, not left to a finally block.

4. Error-bag checks test non-empty, at one shared predicate

Never truthiness. A bag check MUST test for a non-empty own-keyed plain object, and must behave correctly for {}, null, a non-object, an array, and a string.

It lives at exactly one predicate per territory — the entreezuil triplication is what let three sites disagree, and the "duplicated predicate" Minor caused the Major. Where a territory consumes @script-development/fs-http, that predicate SHOULD come from the Armory rather than be re-hand-rolled (see Enforcement).

5. A status must match the exception's semantics — and must never be special-cased into discarding the body

5a. Status before body. An exception's rendered status MUST match its semantics. A terminal fall-through to 500 is correct only for exceptions with no known semantics. A handler that bypasses the framework's own mapping — on Laravel, overriding render() outside debug skips prepareException(), its only caller — inherits the obligation to reproduce that mapping in full.

This clause is upstream of the body rule and was added because rule 5b alone passes incident 5 unchanged: the status was wrong first, and blanking the body merely destroyed the last evidence of it. A 404 rendered as 500 is a defect even with a perfect body, because every downstream layer — the toast ladder, the error sink's report filter, the on-call judgement about severity — reads the status, not the intent.

5b. Body. No handler may return an empty body for an error status, and no frontend may replace a server-supplied message with generic advice on the basis of status alone. If a territory's toast layer has a per-status branch, that branch renders data.message when one is present and falls back to generic text only when it is absent.

Corollary, because it has already cost us once: "it must be a 500 so the error tracker fires" is false on Laravel. reportable() fires for every exception passing shouldReport() and never reads the HTTP status; only $dontReport suppresses tracking. Do not justify a status choice with it.

6. A new error shape is a contract change spanning two layers

When a backend change introduces a new status, a new envelope key-set, or an exception that renders differently, the dispatch scope MUST include the frontend render path — in the same PR, or as an explicitly stacked follow-up. Not "someone will notice."

The order-drafting form: "does the frontend surface this new envelope? Trace the error middleware — what keys does it match? Trace the submit path — is there a catch?" This is Liaison territory: the error envelope is a frontend↔backend contract like any other.

7. Review lens

On any PR that adds or changes a thrown exception's render(), a controller's error status, or an error-middleware branch, ask "how does the user SEE this?" before approving. On any error-envelope branch, ask "what does this do with an empty bag?" — if the answer is "treats it as real errors", it is a Major.

8. Never promise an outcome the code cannot verify

User-facing error copy may state only what the request itself establishes. "Het development team is op de hoogte gesteld", "uw gegevens zijn wel opgeslagen", "we hebben uw melding ontvangen" are guarantees. They are permitted only where the response carries evidence of them — a reference id the client renders, which exists because the sink accepted the report and not otherwise. Absent that evidence, the copy says what is true: something failed, and here is what to do next.

This ranks above silence, not beside it. A silent failure terminates the system's record. A false notification claim terminates the user's search — and the user is the only remaining sensor. Someone shown nothing eventually files a support ticket; someone told "we already know" waits, and the report never comes. That is why incident 5 ran on a live production tier for months with nobody noticing, and why incident 5 was reported at all only because one user distrusted the message.

This is § The Claim Gate applied to production copy — an artifact that asserts a guarantee is not evidence the guarantee holds — and it inherits the same corollary: when copy and behaviour diverge, fix the copy as well as the mechanism. Quietly making the notification work under the old wording erases the evidence that the promise was ever empty.

There is a structural asymmetry underneath this rule, and it is not going away. Every error sink worth having is asynchronous and swallow-on-failure by design — telemetry must never block or break the request that produced it. That design is correct. It is also exactly why the UI cannot speak for the sink: at the moment the toast renders, no layer knows, or can know, whether the report will be delivered. The honest copy is therefore the only available copy.

Enforcement

Per the escalation ladder, pushed as low as each mechanism allows:

LevelMechanismScope
1 — arch/unit testA per-territory spec asserting the terminal fallback fires for an unhandled status (5xx, network error, timeout) and produces user-visible output. Teeth-proved: delete the fallback, watch it go red.Every territory with a frontend error layer
1 — regression matrixThe bag predicate covered against {}, null, non-object, array, string. Run RED against unmodified source first — a test that never failed proves nothing.Every territory
2 — static analysisA phpstan-warroom-rules rule forbidding an empty body on an error status (new JsonResponse([], 5xx) and equivalents). The emmie shape is AST-visible.Laravel territories
1 — handler mapping testA spec asserting every framework exception with known HTTP semantics renders with that status and a non-empty body — ModelNotFound→404, Authorization→403, TokenMismatch→419, and the rest of the framework's own list. Teeth-proved: delete one match arm, watch it go red. Any handler overriding render() outside debug owes this, because it has silently opted out of prepareException().Laravel territories with a custom production renderer
1/2 — notification-claim banThe greppable half of rule 8: user-facing copy asserting a notification, a report, or a save. Enumerate the phrases per locale (op de hoogte, notified, is opgeslagen, hebben we ontvangen) and fail on a new one. The judgement half — is this sentence a guarantee? — stays review, forever.Every territory
ArmoryThe bag predicate exported from @script-development/fs-http (or a sibling), so the one genuinely shared piece of logic is written once. Shared primitive over hand-guard.fs-http consumers
4 — territory doctrineEach territory's CLAUDE.md names its terminal fallback and its single bag predicate by path, so the next agent finds them instead of adding a third.Every territory

The Level-1 test is the load-bearing one. Doctrine did not prevent incidents 2, 3 and 4 — a fallback that is never seen to fire is not known to work.

Consequences

Accepted:

  • More noise on genuinely transient failures. A network blip that previously failed silently now shows a toast. This is the correct trade: a user who sees a spurious error retries; a user who sees nothing does not.

  • Wider dispatch scope on backend error changes. A "small backend fix" that changes an error shape is now a two-layer change and costs more. That is the true cost, previously being paid by users instead.

  • Per-territory duplication of the fallback. Rule 2 is implemented separately in every territory. Deliberate — option B was rejected because error policy is territory-local; only the predicate is shared.

  • A mis-statused exception suppresses the sink's own denominator. Incident 5's excluded classes are the most frequent user-facing failures on that tier, so the error tracker's low group count read as health. Any judgement of "is this territory's error rate acceptable?" drawn from that sink was measuring the wrong population — § The Null-Result Gate corollary 4, arriving by a route no instrument check would have caught, because the instrument was working perfectly on the population it was given.

Not accepted, and worth stating: this ADR does not mandate a shared error-handling package, a common envelope shape across territories, or a uniform toast system. Those are separate decisions and none of them is required to close the silent-failure class.

Known open surface at proposal time (wijs, from WR-0956): useLoadingState produces an unhandled rejection at every call site, and no handler claims 5xx. Both are in scope for the first adoption leg and were deliberately excluded from PR #298 to keep that fix reviewable.

What needs deciding

  1. Ratification — accept as written, or amend first.
  2. Adoption order — revised 2026-08-31. Proposed: emmie first, then wijs, then entreezuil (already point-fixed at #301 — needs the arch test, not the fix), then the rest on next-touch. The reorder is incident 5: emmie's prod-script tier is actively making a false promise to users today, and its prod-customers tier carries no error tracker in the deployed code at all — both on the fleet's highest-compliance territory (ISO 27001 + AVG + NEN 7510). wijs still has an open incident, but it also has a known reproduction and a fix in flight, which is the weaker claim on scarce attention.
  3. The Armory predicate — is fs-http the right home, or does it want a sibling package? fs-http is the transport; a bag predicate is arguably error-domain, not transport.
  4. Whether the Level-2 PHPStan rule is worth building now or waits for a second Laravel-side occurrence. Today the empty-body shape is confirmed on emmie only (n=1) — and note that it would not have caught incident 5, whose defect is rule 5a (wrong status), not 5b (empty body).
  5. Rule 8's enforcement split. Is the greppable phrase ban worth a CI gate now, or does it ride territory doctrine until a second occurrence? Counter-argument against building it: a gate that catches only the phrases we already know reads as coverage it does not have, and rule 8's expensive half is unenforceable either way.
  6. Does rule 5a want its own mechanism, and at which level? A custom production render() that fails to reproduce the framework's mapping is AST-visible in principle, but the mapping is version-coupled to the framework and would rot on every major. A Pest handler-mapping test (Level 1, listed above) may be the honest ceiling — it is territory-local but it cannot silently drift out of step with the framework it runs against.

Amendment note — 2026-08-31

Incident 5 (emmie prod-script) was found while investigating a user report of the notification toast firing with no notification arriving. It changed three things in this ADR and is worth recording as a governance result, not only a technical one:

  • Rule 5 was too narrow. It forbade discarding the body and said nothing about assigning the wrong status, so the incident that most cleanly demonstrates the ADR's own composition thesis would have passed it.
  • Rule 8 did not exist. The ADR protected users from seeing nothing and was silent on users seeing a false guarantee — the more expensive failure, because it disables the last remaining sensor.
  • The ADR's incident set was self-selecting. Incidents 1–4 were all found because someone eventually noticed a missing effect. Incident 5 was found only because a user distrusted the reassurance they were given. A defect class whose symptom is reassurance has no natural discovery path, which is an argument for the Level-1 tests over any amount of doctrine.

Architecture documentation for contributors and collaborators.