Skip to content

ADR-0037: fs-http Middleware Guarding by Default

Accepted fs-packages Universal

Date: 2026-07-08 Supersedes: the consumer-side half of the fs-http Middleware Sync Contract (Architectural Principle #8, "every consumer MUST wrap its own middleware bodies") — see § Decision. Status note: Accepted by the Commander 2026-07-08 (chose "Level C" over B/A; ADR text reviewed + accepted same day). Guard-by-default shipped as 0.6.0 to npm 2026-07-11; fleet adoption of ^0.6.0 is in flight (8 territories PR'd — kendo/wijs/entreezuil/ublgenie/town-crier/isms/BIO/emmie). On ^0.6.0 the Principle #8 consumer-guard obligation is satisfied by default (register* auto-wrap guarded(); {guard:false} opt-out); the obligation remains in force only on consumers still below 0.6.0.

Context

@script-development/fs-http invokes its request / response / response-error middleware as synchronous, un-caught, un-awaited loops directly inside the axios interceptors. A throwing middleware body therefore escapes the interceptor:

  • Success path (request / response loops): a throw turns a resolved 200 into a rejected promise.
  • Error path (response-error loop): a throw propagates before Promise.reject(error), masking the real API error with the middleware's own throw.

The prior decision (2026-05-13, held at n=2)

When this was first surfaced (Surveyor M2, R4/R5), a library-side try/catch was proposed and rejected by the Commander: "loud library errors > silent ones — silent middleware failure is worse than a loud throw." The library stays sync-only and loud; the resolution was a consumer obligation — every fs-http consumer MUST wrap its own middleware bodies (try/catch + fail-safe swallow). The rejection was re-held at n=2 (2026-06-15, kendo WR-0078, which independently re-derived the mechanism and guarded both kendo middleware).

What changed since

Two developments reopen the question:

  1. guarded() shipped in fs-http 0.5.0 — a shared, consumer-side primitive that wraps a middleware body: try { fn(arg) } catch (e) { onError(e) }, onError defaulting to a loud console.error. It is loud swallow: the failure is surfaced, the request is not corrupted.
  2. The WR-0290 fleet wave proved the obligation is unmet by default. Every consumer territory (ublgenie, emmie, entreezuil, BIO, kendo) was found latently exposed — the consumer-guard obligation is exactly the kind of always-required, silent-if-forgotten discipline that a fleet forgets. The remediation wave existed because opt-in guarding does not hold in practice.

The reframe that unlocks the reversal

The 2026-05-13 decision was a choice between two options:

  • silent swallow — rejected (invisible failures), and
  • loud throw — chosen (at least visible).

But a loud throw still corrupts the request outcome — it rejects a resolved 200 on the success path and masks the real API error on the error path. "Loud" was the lesser evil, not a good outcome.

guarded() is a third option that did not exist in 2026-05-13: loud swallow — surface the failure (console.error / error tracker) and let the request complete correctly. It serves the exact value the original ruling protected — be loud, never silentwithout corrupting the request. Making it the default is therefore not a reversal of the ruling's values; it is an upgrade to the option that serves those values better, which was previously unavailable.

The remaining question the Commander posed: "now we need to think about when to add guarded and when not — when should it always be the case?" The fleet evidence answers it: it is always the case. No legitimate "a middleware should reject the request by throwing" case has been found across the fleet (transform, toast, error-bag, loading, redirect/challenge middleware all either must-not-throw or perform side effects, never throw-to-abort).

Decision

fs-http guards middleware bodies by default. Consumers opt out, deliberately, not in.

  1. Auto-wrap at registration. registerRequestMiddleware, registerResponseMiddleware, and registerResponseErrorMiddleware wrap the supplied function in guarded() internally. Every registered middleware is loud-swallow-protected without the consumer doing anything.

  2. Per-call opt-out. Each register*Middleware(fn, opts?) accepts { guard: false } to register the body unguarded (throws propagate) — the deliberate escape hatch for a future case that genuinely wants propagation. No such case exists today; the option is cheap insurance against a one-way door, not a feature with a known consumer.

  3. Service-level error routing. createHttpService(baseURL, { onMiddlewareError }) sets the handler passed to the internal guarded() for every middleware on that service, so a consumer can route the loud signal to an error tracker (Sentry, kendo-error-tracker) instead of console.error. Per-call override remains possible if needed. The default stays a loud console.error.

  4. guarded() stays exported. It remains a public export for the { guard: false } + manual-wrap case and for consumers on older fs-http. The default path simply applies it for you.

This supersedes the consumer-side obligation in Principle #8: the contract becomes "the library guards middleware by default; a consumer opts out deliberately" rather than "every consumer MUST wrap its own middleware bodies." The library is still loud — it never silently swallows; it surfaces via onMiddlewareError/console.error. The sync-only, un-awaited nature of the loops is unchanged (async middleware remains out of contract; guarded() is sync).

Options Considered

OptionMechanismReverses 2026-05-13?Forget-surfaceVerdict
A — enforce opt-inKeep guarded() opt-in; add a Level-1/2 rule (arch test / phpstan-warroom-style) failing CI on an unguarded register* callNoCI catches it, but a per-territory rule to build + deploy across every consumer stack (TS and the mixed fleet); a new consumer without the rule is exposed againRejected — pushes the same always-required discipline onto every consumer's CI instead of fixing it once at the source
B — per-service switchcreateHttpService(url, { guardMiddleware: true }) wraps everything on that serviceNo (default stays loud/unguarded)Shrinks from per-registration to per-service, but a new service created without the switch is silently unguarded again — the WR-0290 failure mode survivesRejected — reduces but does not eliminate the forget-surface; safe path is still not the default
C — default-on, opt-outregister* auto-guards; {guard:false} + onMiddlewareErrorYes (values preserved — see reframe)~ZeroChosen — correct-by-default; fixed once at the substrate; the loud-swallow reframe removes the original objection

Consequences

Positive

  • The always-required guard is applied by construction; the WR-0290 class of latent exposure cannot recur.
  • One fix at the most-depended-on substrate replaces a standing per-consumer obligation and the per-territory enforcement that Option A would have required.
  • The library remains loud (never silent) and no longer corrupts request outcomes on a middleware side-effect bug.

Costs / risks

  • Behavior change to the fleet's most-depended-on package. This is a real contract change, not a mechanical bump. It ships as an fs-http minor and carries the pre-1.0 peer-range cascade (fs-loading, fs-adapter-store, fs-cached-adapter-store, and now fs-form widen peers).
  • Existing explicit guarded() wraps become redundant-but-harmless. The WR-0290 consumer guards (and fs-form's own guarded() in PR #148) become double-wraps: the inner guarded() catches, the outer (now auto-applied) never fires. Correct, just vestigial. Strip in a later, unhurried cleanup — not a blocker.
  • Mock parity (ADR-0017). Consumers that mock @script-development/fs-http in page integration tests must mirror the auto-guard (or, more simply, the mock's register* can stay a passthrough since tests drive middleware directly — confirm per consumer).
  • The {guard:false} escape hatch is YAGNI-adjacent. Kept deliberately as insurance against a one-way door; documented as "no known consumer."

Enforcement (ladder). The default is the enforcement (Level 1-equivalent: correct by construction). A residual arch test could assert no {guard:false} without a justifying comment, but that is optional hardening, not required by this decision.

Migration

  1. Land the fs-http minor: auto-guard in the three register* functions; {guard:false} option; onMiddlewareError service option; tests (100% coverage, 90% mutation; note the loud-swallow paths produce equivalent mutants under guarded() — the fs-form precedent, ~92% expected).
  2. Cascade-widen peers on the dependent packages; publish.
  3. Update fs-packages CLAUDE.md § Middleware Sync Contract and war-room CLAUDE.md Principle #8 to the new contract ("guarded by default; opt out").
  4. Later, unhurried: strip the now-redundant explicit guarded()/hand-rolled try-catch wraps across the fleet (WR-0290 residue) — tracked separately, not gating this ADR.

References

  • fs-packages CLAUDE.md § Middleware Sync Contract; war-room CLAUDE.md Architectural Principle #8.
  • deferred.md [adr] fs-packages-fs-http-async-aware-middleware-rejection-doctrine — the full 2026-05-13 → 2026-07-08 decision thread.
  • WR-0290 (fleet guarded() adoption wave); kendo WR-0078 / PR #1538 (n=2 re-derivation).
  • ADR-0021 (Canonical PHPStan Rules) — the Option-A enforcement vehicle, had it been chosen.

Architecture documentation for contributors and collaborators.