You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
#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:
output-guard is the highest-numbered onResponseStart / onStreamEvent extension in extensions.json.
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:
Has enabled: true
Has order > output-guard.order (currently 690)
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.
Background
#278 landed
output-guardatorder: 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:
output-guardis the highest-numberedonResponseStart/onStreamEventextension inextensions.json.pipeline.mjs:76).The tie at slot 690 already exists —
session-budget-breaker(alsoenabled: true, alsoonRequest-primary) is co-located there and only lands beforeoutput-guardbecauseoutput-guard.mjs<session-budget-breaker.mjsin ASCII sort. That's incidental, not asserted. And nothing prevents a future extension from being added at, say,order: 700with 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 inoutput-guard-stashatorder: 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 inoutput-guard.mjs's own registration path) that iterates the loaded extension registry once and refuses to boot if any extension:enabled: trueorder > output-guard.order(currently 690)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: 400that 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
output-guardtoorder: 990and reserving>= 990for "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-breakerat 690 today. It'sonRequest-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.pipeline.mjsbecause it's about how the whole extension graph relates tooutput-guard, not internal tooutput-guard's own logic.output-guard.mjsshouldn't reach into the sibling registry; the loader should refuse to hand it a defeated pipeline.Non-Functional Requirements
What this issue does NOT propose
output-guardslot-configurable. It's a fixed point in the pipeline by design.Reference
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