Repository navigation
feat(middleware): add streaming v2 HTTP hooks and deprecate the unary request hook #3307
Description
User Story
As a supervisor middleware author, I want one streaming HTTP hook that supports headers-only, whole-body, and incremental inspection, so I can handle small requests and large uploads with the same contract I use for responses.
Problem Statement
SupervisorMiddleware.EvaluateHttpRequest is unary. It gets one buffered HttpRequestEvaluation and returns one HttpRequestResult. Request and replacement bodies are capped at 4 MiB. A stage that only needs headers still goes through the buffered path.
The v1 response hook, HttpResponsePreReturn.Evaluate (#3073, #3074), is already a bidirectional stream with preflight, body units, trailers, and session end. That leaves request and response middleware on two different processing models.
Parent: #2565.
Impact / Why This Matters
Middleware authors can't look at request headers without whole-body buffering, and can't process bodies over the inspection limit incrementally. Operators either accept the buffering latency and size cap, bypass inspection where fail-open allows it, or run another proxy. Bypassing loses the inspection. Another proxy duplicates policy and credential-boundary work.
Scope change
This issue originally asked for a breaking replacement before 0.1.0, with no compatibility path. 0.1.x shipped with the unary hook, so PR #4359 takes a different route:
- It adds HTTP session hooks next to the v1 hooks instead of replacing them.
- It covers responses too.
HttpSessionMiddleware.EvaluateResponsereplacesHttpResponsePreReturn.Evaluateon the same terms, so both directions share one lifecycle. - v1 hooks are deprecated for removal in 0.2.0. Removing them is a follow-up and not part of this issue.
Design (as implemented in #4359)
RPCs. A new service, HttpSessionMiddleware, served on the same endpoint as SupervisorMiddleware. EvaluateRequest serves HTTP_REQUEST at PRE_CREDENTIALS, and EvaluateResponse serves HTTP_RESPONSE at PRE_RETURN. Both are bidirectional streams, and each has its own event and result messages with the same lifecycle. The HttpRequestPreCredentials.Evaluate name from the original proposal didn't survive.
Version selection. A service selects session hooks for all of its HTTP bindings by listing openshell.supervisor-middleware.http-session in both supported_capabilities and required_capabilities. The bindings and operations don't change, and no RPC, message, or enum value carries a version.
Preflight. One stream per stage and HTTP message. The first event is a preflight with the head, phase, context, config, permitted and removed body modes (each removed mode has a reason), limits, and declared body length. A response preflight also carries the request headers as the request chain forwarded them. The stage returns continue_without_body, inspect (BUFFERED or STREAM), or a denial (deny for requests, block_delivery for responses), with optional header mutations. A stage gets its preflight once the previous stage settles its output headers, so it picks a mode for the representation it will receive.
BUFFERED. One body up to the stage payload limit (max 4 MiB). The stage returns unchanged or a replacement, where empty bytes delete the body, plus late header and trailer mutations. That stage's output waits for its result; later stages can still stream.
STREAM. Ordered input chunks (64 KiB max) and one input_end with trailers. The stage emits its own output with output_start, output_chunk, and finish. Input and output counts are independent, and there is no total size or time cap. 30 seconds of middleware stall fails the stage. 30 seconds without upload progress from the sandbox returns 408 request_timeout.
Commitment. Request output streams upstream during upload unless the route needs the full body: a BUFFERED stage, SigV4, body credential rewrite, forward proxy, JSON-RPC/MCP/GraphQL, HTTP/1.0, or Upgrade. In those cases the supervisor buffers output (up to 10 MiB for SigV4, 256 KiB for credential rewriting, 4 MiB otherwise) and reports the limit to STREAM stages as limits.max_output_body_bytes. Body-aware endpoints re-check each replaced body against policy. Credentials are injected after all middleware mutations. Until every stage finishes, the supervisor withholds the final byte that would complete the message. If a stage fails after the head went upstream, the supervisor closes the upstream connection and never replays.
Responses. When a response stage might inspect bodies, the supervisor sends Accept-Encoding: identity upstream, so the sandbox can't make responses uninspectable by asking for compression. Encoded, bodyless, partial, and no-transform responses get no body modes.
Header boundaries. Request headers whose value carries a credential placeholder are hidden from session hooks and can't be changed. Stages that change a body own integrity headers such as ETag and Digest.
Failure. Always fail-closed. The gateway rejects on_error: fail_open for session hook services. Explicit denials get 403 middleware_denied. Errors, timeouts, bad results, and lifecycle violations get 403 middleware_failed for requests and 502 response_delivery_failed for responses, with a platform-owned failure_code. After forwarding starts, the supervisor closes the upstream connection or aborts delivery instead.
Compatibility. Gateways and supervisors have rejected unmet required capabilities at Describe since v0.1.0, so older peers refuse a session hook service instead of calling it through v1 RPCs. Registration rejects a manifest that lists the capability as supported but not required. A chain can't mix hook versions: the gateway rejects such policies, and the supervisor fails mixed chains closed with middleware_hook_versions_mixed.
Uninspectable traffic. Unchanged from main. tls: skip tunnels, h2c, unsupported tunnels, raw TCP, and SQL passthrough never reach middleware. A selected fail_closed entry refuses them, and an all-fail_open chain lets them through with a finding. A fail_closed entry still can't select a tls: skip endpoint. An earlier iteration of #4359 gave middleware a way to allow these connections; it was dropped before merge.
Acceptance Criteria
Covered by #4359. Check these off when it merges:
- The protobuf contract documents preflight results, body modes, ordered results, replacements including empty bytes, trailers, session end, invalid transitions, and diagnostic limits, the same way for requests and responses.
- Headers-only and STREAM requests can go past the old 4 MiB limit without whole-request buffering. BUFFERED stays bounded and sends nothing upstream before the stage approves.
- Chains keep policy order, transformations, explicit denial, and fail-closed behaviour without replay or lost input.
- Partial forwarding, downstream errors, cancellation, disconnects, and policy reload have defined, tested outcomes.
- Backpressure, the concurrency caps, per-result timeouts, the BUFFERED deadline, and cleanup bound resource use, including for slow or unknown-length uploads.
- Tests cover bodyless and empty requests, Content-Length and chunked bodies, trailers,
Expect: 100-continue, size-changing transformations, oversized input and replacements, invalid results, and failures before and after forwarding. - Tests keep body-aware policy checks after transformation, credential non-disclosure, protected-header validation, and correct upstream framing. OCSF events leave out bodies, credentials, query secrets, and free-form middleware reasons.
- The
e2e:middleware-http-sessionsuite runs the content guard example with session hook request and response stages in both body modes. Existing v1 middleware e2e coverage still passes. - RFC 0009, the Supported Operations docs page, the middleware config docs, and the relevant skills describe the new contract and the v1 deprecation. The docs include a migration guide and the gateway-first upgrade order.
Follow-up, out of scope here:
- Remove
EvaluateHttpRequest,HttpResponsePreReturn.Evaluate, and their request-only messages in 0.2.0, and list the removal in the 0.2.0 upgrade notes.
Alternatives Considered
Breaking replacement before 0.1.0. The original plan, overtaken by the 0.1.x release. A deprecation window gives running services a release to migrate.
Versioned operations and RPCs (HTTP_REQUEST_V2, EvaluateHttpRequestV2). An earlier iteration of #4359. Older peers rejected the unknown operations, but each contract version would add enum values and RPC names. A required capability gets the same refusal from older peers without versioning the contract.
Unary compatibility adapter on a shared runner (#2431's original proposal). More supervisor code to keep alive an API we plan to delete. Separate v1 engines plus a no-mixing rule are easier to remove in 0.2.0.
Wrap the buffered request in a stream. Changes the transport shape but keeps the buffering and size limits.
One universal inspection RPC for requests and responses. We kept two RPCs because requests and responses differ in metadata, mutation rights, policy checks, and commitment. They still share the lifecycle and most of the runtime.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsIn progress
Activity
0.1.0#25650.1.0#2565github-actions commentedon Oct 6, 2026
This issue has had no activity for 14 days and is now marked stale. It may be closed in 7 days if there is no further activity. Comment or remove the state:stale label to keep it open.
[-]feat(middleware)!: replace unary HTTP request evaluation with a streaming hook[/-][+]feat(middleware): add streaming v2 HTTP hooks and deprecate the unary request hook[/+]