Skip to content

output-guard: enforce order-slot invariant at load time (silent-defeat protection) #321

Description

@vsits-proxy-builder

Background

#278 landed output-guard at order: 690, running as the response-path safety net that validates last-line invariants and restores from a pre-mutation stash if a mutator produced a corrupted body. The guard's soundness rests on being the last thing to touch the response body / stream events for extensions that participate in the mutating class.

Today that ordering holds by two facts that are load-bearing but never asserted:

  1. output-guard is the highest-numbered onResponseStart / onStreamEvent extension in extensions.json.
  2. Any tie at that slot is broken by the loader's alphabetical filename sort (pipeline.mjs:76).

The tie at slot 690 already exists — session-budget-breaker (also enabled: true, also onRequest-primary) is co-located there and only lands before output-guard because output-guard.mjs < session-budget-breaker.mjs in ASCII sort. That's incidental, not asserted. And nothing prevents a future extension from being added at, say, order: 700 with response-mutating hooks — the loader would happily accept it, and the guard's validation + restore would run against a stale snapshot.

That is a silent defeat. The mutation would happen after the guard's checks and the guard's restore path (which uses structuredClone(ctx.body) captured in output-guard-stash at order: 55) would revert the later extension's legitimate work if it fired. Neither outcome fires the CRITICAL stderr the guard uses to signal a real problem, so the operator sees nothing.

The framing is asymmetric-risk: any future extension that mutates the response after order 690 either bypasses the guard entirely or corrupts by being incorrectly rolled back — both silently. Documentation alone does not defend against this; a directive-time reviewer of a new extension has to know to check the order-slot invariant against output-guard, and that check is easy to forget three years from now when the pattern has faded from institutional memory.

Proposed enforcement

A load-time check in pipeline.mjs (or in output-guard.mjs's own registration path) that iterates the loaded extension registry once and refuses to boot if any extension:

  1. Has enabled: true
  2. Has order > output-guard.order (currently 690)
  3. Exports any of onResponseStart / onStreamEvent / onResponse (whichever hooks the guard covers)

Failure mode: log a CRITICAL line naming the offending extension + its order, and exit non-zero from the loader. The proxy should never come up with a defeated guard.

The invariant should be one-directional — nothing stops a NEW extension being added at order: 400 that mutates the response earlier in the pipeline (that IS what the guard protects against). Only later-slot response-touchers are the danger class.

Design notes

  • Slot bump vs. assertion. Bumping output-guard to order: 990 and reserving >= 990 for "the guard tier" is simpler but relies on the same institutional-memory fragility this issue is trying to fix. An assertion at load time is defect-hostile in the way documentation isn't: a violation cannot ship silently.
  • session-budget-breaker at 690 today. It's onRequest-primary and doesn't mutate the response, so it's not in the danger class even at the same slot. But the tie itself is a smell — either bump one to a distinct slot with a comment ("690 = last response-mutation slot before output-guard") or accept that the tie needs the loader assertion to make the co-location safe going forward.
  • Loader responsibility, not guard-file responsibility. The check belongs in pipeline.mjs because it's about how the whole extension graph relates to output-guard, not internal to output-guard's own logic. output-guard.mjs shouldn't reach into the sibling registry; the loader should refuse to hand it a defeated pipeline.

Non-Functional Requirements

  • Size/complexity budget — small. One loader-side check (~20 lines) + one CRITICAL log path + a couple of tests (extension at forbidden slot rejected, extension at safe slot accepted, tied-slot enabled but non-mutating accepted).
  • Threat model — n/a; this is a defect-hostile assertion, not a new attack surface. Trust boundary unchanged.
  • Maintainability constraints — the assertion IS the maintainability tool. It makes a load-bearing invariant discoverable at boot instead of at incident-analysis time. No new abstraction; the loader already iterates extensions.
  • Performance/reliability — one-time cost at load. Reliability net positive: silent-defeat class becomes a boot failure.
  • Load-bearing? YES. Affects the response-path safety net's soundness. Requires Chris human review.

What this issue does NOT propose

  • Renumbering existing extensions. The tie at 690 works today by alphabetical sort; the assertion is what would let it keep working safely as the extension set grows.
  • Making output-guard slot-configurable. It's a fixed point in the pipeline by design.
  • Adding a runtime check per request. Load-time is sufficient and cheaper.

Reference

  • feat(output-guard): last-line invariant on the response path — restore, log, never corrupt #278 review (comment thread): the follow-up was flagged during review and agreed to as post-merge work under the framing "silent defeat earns enforcement rather than documentation."
  • pipeline.mjs:76 — the stable-sort tie-break the current safety depends on.
  • proxy/extensions/output-guard.mjs, proxy/extensions/output-guard-stash.mjs — the guard pair.
  • proxy/extensions.json — the current registration state.

— Proxy Builder

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions