ADR-0017: ext_proc processing contract
Status
Accepted
Context
Inline mode (ADR-0012) terminates the client connection and adds an HTTP
hop. For Envoy-based gateways (Envoy AI Gateway, agentgateway / kgateway,
Istio) routeD can instead join the filter chain as an
envoy.service.ext_proc.v3.ExternalProcessor and mutate the request in
place: no extra hop, no connection termination, and response streaming never
passes through routeD at all. Both ingress modes must agree on every
decision semantic (ADR-0007 header trust, the BLOCK envelope, dry-run, the
X-Routed-* response headers), so the implementation must share the inline
pipeline rather than re-implement it.
Decision
One pipeline, two ingresses
crates/ingress-extproc reuses routed-ingress-inline's decision pipeline
(decide_bytes, rewrite_body, the BLOCK envelope, the X-Routed-* header
writer) over the shared AppState. In --mode extproc the binary serves
the ext_proc gRPC service on --extproc-addr and the decision / feedback /
health / metrics APIs on --http-addr (the same axum routes minus the
proxy fallback); the upstream client exists but is never contacted.
Decisions are recorded with mode label extproc.
Required filter configuration
processing_mode: request headersSEND, request bodyBUFFERED, response headersSEND, response body / trailersNONE.allow_mode_override: true: for non-routed requests (wrong method or path) the server answers the header phase with amode_overridethat skips the body and response phases, so pass-through traffic costs one header exchange.failure_mode_allow: false: fail closed, consistent with the inline mode's readiness behaviour (docs/integration/).message_timeoutof at least one second: the body-phase response includes classification.
Per-phase behaviour
One gRPC stream corresponds to one HTTP request; the stream is the unit of state (buffered body, made decision).
- Request headers: non-routed -> strip inbound
x-routed-*(ADR-0007) and skip the rest. Routed -> continue, expect the body. A routed request withend_of_stream(no body) is decided over empty bytes and gets the same 400 the inline mode produces. - Request body (buffered; chunks tolerated and accumulated up to
max_body_bytes, over -> immediate 413): run the pipeline.BLOCK->ImmediateResponse403 with the shared OpenAI envelope.- Dry-run ->
ImmediateResponse200 with the decision JSON. ROUTE-> body mutation with the rewritten JSON (model + injected parameters, every other byte preserved) plus removal of inboundx-routed-*.PASS_THROUGH-> header removal only.
- Response headers: the stored decision becomes
X-Routed-*response headers (overwrite semantics), so callers see exactly what inline mode sends. Response bodies are never sent to routeD; SSE / streaming integrity is Envoy's concern in this mode.
Consequences
- Behaviour parity between modes is structural (same functions), not aspirational; the protocol tests drive the generated Envoy client against the real tonic server.
- Envoy buffers routed request bodies (as inline mode does); response streaming is zero-copy through Envoy.
- The generated protos come from the
envoy-typescrate (tonic 0.14 bindings); no vendored proto tree to maintain.
Alternatives considered
- Vendoring the envoy proto closure and compiling with protox: kept as the
fallback if
envoy-typesever lags the workspace's tonic; not worth the maintenance while the crate tracks tonic promptly. - Setting
X-Routed-*on the mutated upstream request: rejected; inline mode deliberately strips routed headers toward the gateway (ADR-0007) and parity wins. Gateways that need the decision usePOST /v1/decide. FULL_DUPLEX_STREAMEDbody mode: unnecessary; routed requests are small JSON bodies and BUFFERED matches inline's read-then-decide semantics.