* [agent-network] Shared proto, OpenAPI schema, and generated types * [agent-network] Management: store, manager, synthesizer, policy engine, provider catalog, HTTP/gRPC API Adds the account-scoped agent-network module: provider/policy/budget CRUD and store, the reverse-proxy service synthesizer, policy selection + limit enforcement, the provider catalog (incl. Vertex AI and AWS Bedrock entries), and the management HTTP + proxy gRPC surfaces. * [management] Fix agent-network proxy-peer fan-out on affected-peer recompute The affected-peers resolver loaded only persisted reverse-proxy services, but agent-network services are synthesized on demand and never persisted. As a result the embedded proxy peer was never folded into the affected set when a client's group changed, so the proxy received no network-map update for a newly authorised client and rejected its handshake until a full resync (restart). loadProxyServices now merges the synthesized agent-network services (injected via a registration hook to avoid an import cycle), so proxy peers learn newly authorised clients immediately. * [proxy] Reverse-proxy middleware framework, chain, and request plumbing The per-target middleware chain (slots, dispatcher, mutation gate, metadata merger), body capture, access-log terminal sink, and the proxy wiring that builds + runs chains for synthesized agent-network services. * [proxy] LLM parsers, pricing, and builtin middlewares (OpenAI, Anthropic, Vertex AI, AWS Bedrock) Request/response parsers and SSE/event-stream metering, the embedded pricing table, and the builtin middleware set: request parser, router, policy limit-check/record, cost meter, guardrail, identity inject, response parser. Includes the path-routed providers — Google Vertex AI (keyfile:: service-account OAuth minting) and AWS Bedrock (bearer auth, invoke/converse/streaming, optional /bedrock prefix) — plus the Models allowlist and unmeterable-publisher deny. * [proxy] IPv6 in-place apply and TCP accept-loop hardening on netstack listeners * [agent-network] End-to-end test suite, module docs, and deployment preset * [agent-network] Fix codespell typos and exclude false positives - labelgen word pool: vermillion -> vermilion, racoon -> raccoon. - codespell ignore list: add flate (Go compress/flate package), recordin (a test-local identifier), and unparseable (a valid alternative spelling used consistently across identifiers + a metadata-value constant). * [management] Set LastSeen on injected proxy peer in realstack test (MySQL strict-mode) The injected embedded proxy peer had a PeerStatus with a zero LastSeen, which serializes to '0000-00-00' and is rejected by MySQL in strict mode (SQLite tolerates it). Set LastSeen to a valid time so SaveAccount succeeds on both engines. * [agent-network] Remove e2e shell-script suite from this branch The end-to-end shell scripts under scripts/e2e/ are maintained in a separate testing suite and are not part of this change set. * [agent-network] Polish module docs: remove internal review scaffolding, fix links, verify diagrams Strip PR-review framing, commit references, absolute paths, and stale internal references from the agent-network module docs; fix broken relative links; verify all diagrams against the current architecture. Remove the internal AI-reviewer prompt file. * [management] Refine session expiration handling to support 3-state encoding for SSO deadlines * [agent-network] Relocate agentnetwork package to internals/modules Move management/server/agentnetwork (and its catalog/, labelgen/, types/ subpackages) to management/internals/modules/agentnetwork, alongside the reverse-proxy module, and rewrite all importers. Pure relocation: package names, the synthesizer + affectedpeers registration hook, and store access (shared store.Store) are unchanged, so no import cycle is introduced (affectedpeers still depends only on the agentnetwork/types leaf). * [agent-network] Co-locate HTTP handlers in the module (RegisterEndpoints) Move the agent-network HTTP handlers from server/http/handlers/agentnetwork into the module at internals/modules/agentnetwork/handlers (package handlers) and rename the entrypoint AddEndpoints -> RegisterEndpoints, matching the reverse-proxy module convention. Wiring in http/handler.go updated accordingly.
24 KiB
proxy/middleware-framework — generic plugin system
Risk level: High — every proxied request transits this chain. Budget exhaustion, panic recovery, or chain-close bugs hit the hot path for all targets, not just agent-network ones. Backward-compat impact: Additive at the proxy. The
middlewareandbodytappackages are new (proxy/internal/middleware/middleware.go:1,proxy/internal/middleware/bodytap/request.go:13); existing proxy targets keep working until a chain is bound to them viaManager.Rebuild.
This module is the framework only — no LLM/agent-network domain knowledge is required, since every example built into it is generic.
Module boundary
This module is the framework only: slots, chains, registry, dispatcher, accumulator, body-tap, output filters. No middleware implementation lives here — those land in proxy/internal/middleware/builtin/* (covered in module 31). The package contract is:
- The proxy hands a
Managerto its config-apply path. The synth pushes per-pathPathTargetBindinglists (proxy/internal/middleware/manager.go:26) intoManager.Rebuild, which resolves each spec via theRegistry/Resolver(proxy/internal/middleware/registry.go:81-121) and produces an immutableChainkeyed byserviceID|pathID(proxy/internal/middleware/manager.go:410-412). - The reverse-proxy handler captures the request body via
bodytap.CaptureRequest, callsChain.RunRequest, applies returned mutations (already filtered bychain.applyMutations), forwards to the upstream behind abodytap.CapturingResponseWriter, then callsChain.RunResponseandChain.RunTerminal. - Middlewares are inert plugins that receive a deep-cloned
Inputand return anOutputwhose decision/mutations are clamped by the dispatcher'sfilterOutput(proxy/internal/middleware/dispatcher.go:149-172).
Everything that crosses the framework boundary in either direction is value-typed and deep-copied — middlewares cannot mutate the live request directly, and the framework cannot inadvertently leak middleware-owned slices into the request hot path.
Files
| Path | Role |
|---|---|
proxy/internal/middleware/middleware.go |
Middleware + Factory interfaces. |
proxy/internal/middleware/types.go |
Slot, FailMode, Decision, all limit constants, Input/Output/Mutations/UpstreamRewrite/AuthHeader value types. |
proxy/internal/middleware/spec.go |
Apply-time Spec (validated wire shape + runtime-injected fields) and Clone. |
proxy/internal/middleware/registry.go |
Registry (factory map, RWMutex) and Resolver (Spec → bound Middleware). |
proxy/internal/middleware/manager.go |
Manager, chainTable reverse index, Rebuild/Invalidate*, async chain close. |
proxy/internal/middleware/chain.go |
Chain.RunRequest/RunResponse/RunTerminal, mutation gating, cloneInputFor. |
proxy/internal/middleware/chain_test.go |
Metadata threading, LIFO response order, rewrite gating, UserGroups propagation, terminal accumulation. |
proxy/internal/middleware/dispatcher.go |
Timeout/panic recovery, fail-mode, error classification, filterOutput. |
proxy/internal/middleware/decision.go |
RenderDenyResponse, deny-code regex, status clamp. |
proxy/internal/middleware/headerpolicy.go |
Compile-in header denylist + FilterHeaderMutations. |
proxy/internal/middleware/bodypolicy.go |
ValidateBodyReplace / ApplyBodyReplace smuggling guards. |
proxy/internal/middleware/keys.go |
Metadata key namespace constants. |
proxy/internal/middleware/metadata.go |
Accumulator — allowlist, per-mw/per-request byte caps, redaction. |
proxy/internal/middleware/metrics.go |
OTel instrument bundle (proxy.middleware.*). |
proxy/internal/middleware/redaction.go |
Scan — PEM/JWT/AWS/bearer/Luhn-validated CC patterns. |
proxy/internal/middleware/bodytap/request.go |
Capture + replay reader, Budget semaphore, bypass reason codes. |
proxy/internal/middleware/bodytap/response.go |
CapturingResponseWriter (tee with PassthroughWriter for Flusher/Hijacker preservation). |
Slot model
Three slots, declared per-middleware exactly once (proxy/internal/middleware/types.go:27-41):
SlotOnRequest(Slot=1) — runs before the upstream call, in registration order. MayDecisionDeny, may emitMutations(header add/remove, body replace,UpstreamRewrite) when bothSpec.CanMutateandMiddleware.MutationsSupported()are true. May emit metadata. Each middleware in the slot sees metadata that earlier ones in the same slot just emitted (proxy/internal/middleware/chain.go:144-178) — this is how the framework gives middlewares an intra-slot side channel without a global bag.SlotOnResponse(Slot=2) — runs after the upstream returns, in reverse registration order. Cannot deny (clamped indispatcher.filterOutput,proxy/internal/middleware/dispatcher.go:153-157). May still mutate response headers in principle, but the current chain only forwardsRewriteUpstreamfrom on_request, so on_response mutations are observe-only in practice. Threads the same per-slot metadata view as on_request.SlotTerminal(Slot=3) — runs after every on_response middleware has emitted, in registration order. Sees the full accumulated bag plus prior terminal emissions (chain.go:221-245). Cannot deny, cannot mutate (dispatcher.go:168-170). Designed for sinks (access log, metrics push, audit emitter).
Splitting a feature across slots (e.g. "parse on the way out, ship on terminal") is the explicit architectural choice — types.go:7-15 and types.go:22-25 make it clear no middleware participates in more than one slot.
Architecture & flow
Chain dispatch
sequenceDiagram
autonumber
participant H as proxy HTTP handler
participant BT as bodytap.CaptureRequest
participant CH as Chain
participant DI as Dispatcher
participant MW as Middleware (per slot)
participant US as Upstream
participant CW as CapturingResponseWriter
H->>BT: CaptureRequest(r, cfg, budget)
BT-->>H: body[], truncated, release()
H->>CH: RunRequest(ctx, r, Input, Accumulator)
loop on_request, registration order
CH->>CH: cloneInputFor(in, OnRequest)
CH->>DI: Invoke(ctx, spec, mw, call)
DI->>MW: mw.Invoke(callCtx, in)
MW-->>DI: Output{decision, metadata, mutations?}
DI->>DI: filterOutput (clamp deny, gate mutations)
DI-->>CH: filtered Output
CH->>CH: Accumulator.Emit (allowlist + caps + redact)
alt DecisionDeny
CH-->>H: denied, merged, rewrite
else allow
CH->>CH: applyMutations(r, m) and capture rewrite
end
end
CH-->>H: nil, merged, rewrite
H->>US: ProxyRequest (with rewrite/mutations applied)
US-->>CW: bytes (streamed, tee'd into cap-bounded buf)
CW-->>H: passthrough complete
H->>CH: RunResponse(ctx, Input{RespBody:CW.Body(),...}, acc)
loop on_response, REVERSE order (LIFO)
CH->>DI: Invoke (same wrappers)
end
H->>CH: RunTerminal(ctx, Input{Metadata:full bag}, acc)
H->>BT: release() + CW.Release()
Body-tap mechanics (request + response)
flowchart LR
subgraph req[Request capture — bodytap.CaptureRequest]
R0[r.Body] --> R1{cfg.MaxRequestBytes > 0?\nUpgrade absent?\nContent-Type allowed?\nCL <= cap?}
R1 -- no --> R2[bypass = reason\nbody = nil\nr.Body untouched]
R1 -- yes --> R3[Budget.Acquire(cap)]
R3 -- denied --> R4[bypass=BypassBudget]
R3 -- ok --> R5[io.LimitReader(r.Body, cap+1)\nio.ReadAll]
R5 --> R6{len > cap?}
R6 -- truncated --> R7[viewable = buf[:cap]\nr.Body = replayReadCloser{buf, tail}]
R6 -- whole --> R8[r.Body = NopCloser(bytes.Reader(buf))\nclose original]
R7 --> R9[(release captured\nbudget on req end)]
R8 --> R9
end
subgraph resp[Response capture — CapturingResponseWriter]
W0[client] -.-> CW[Write(p)]
CW --> P1[PassthroughWriter.Write(p)\n— bytes leave to client first]
P1 --> P2{!stopped?}
P2 -- yes --> P3{remaining = cap - buf.Len()}
P3 --> P4[buf.Write(p[:take])\nset truncated if take<n]
P2 -- no --> P5[silent drop into the tee\n(client write already done)]
end
The body-tap is the highest-leak-risk surface in this module; three details matter:
- Request capture is "read-and-replay", not "read-and-forward".
CaptureRequestalways swapsr.Bodyfor either abytes.Reader(whole body fit) or areplayReadCloserthat replays the captured prefix then drains the remaining stream from the original body (bodytap/request.go:178-201). This means the upstream still sees the full body even when the tap truncates. The originalr.Bodyis not closed in the truncated branch —replayReadCloser.Close()only closes the tail (bodytap/request.go:199-201), which is the same reader, so close once on request end is correct, but reviewers should confirm the upstream proxy always reads to EOF (otherwise the tail is leaked). - Response capture is a write-through tee.
CapturingResponseWriter.Writeforwards to the underlying writer first (bodytap/response.go:116-117), then tees intobufunder its own mutex. Client never blocks on the tee.Flusher/Hijackerare preserved via the embeddedresponsewriter.PassthroughWriter. SSE/chunked streams flow through untouched; middlewares only see the bounded prefix. - Budget is a single shared semaphore.
Managerconstructs onebodytap.Budgetat startup (manager.go:138-144, default256 MiBfrombodytap/request.go:39). Every capture pre-acquires its fullMaxRequestBytes/MaxResponseBytesfrom the budget regardless of actual body size; that prevents a flood of small captures from collectively exceeding the cap, but it also means a misconfiguredMaxRequestBytes = 1 MiBwith 256 concurrent requests already exhausts the default budget. Reviewers should sanity-check the operator-facing defaults that ship with synth-service.
The framework explicitly aborts capture (and increments proxy.middleware.capture_bypass_total) before reading the first byte when Upgrade/Connection: upgrade is set (bodytap/request.go:120-125), when the content-type isn't in the allowlist (bodytap/request.go:126-128), or when the advertised Content-Length already exceeds the cap (bodytap/request.go:131-133). This is the right place to make sure WebSocket upgrades and large file uploads never reach the buffer.
Public contracts
Middlewareinterface (middleware.go:14-36):ID(),Version(),Slot(),AcceptedContentTypes(),MetadataKeys(),MutationsSupported(),Invoke(ctx, *Input) (*Output, error),Close().MetadataKeys()is the closed set the middleware is allowed to emit — the accumulator drops anything outside it (metadata.go:71-75).Closemust be idempotent (called even whenInvokewas never reached).Factoryinterface (middleware.go:44-47):ID(),New(rawConfig []byte) (Middleware, error).RawConfigis opaque JSON bytes on the wire (spec.go:6-12); each factory owns its own typed config.Decisiontype (types.go:59-69):Allow=0,Deny=1,Passthrough=2. Default-zero is permissive — important because every middleware that omitsDecisiongetsAllow. Dispatcher clampsDenytoPassthroughoutsideSlotOnRequest(dispatcher.go:153-157).Mutations(types.go:196-201):HeadersAdd/HeadersRemove(filtered throughheaderpolicy.go),BodyReplace(gated throughbodypolicy.go), andRewriteUpstream.RewriteUpstreamis last-write-wins within the on_request slot (chain.go:170-172, locked down byTestChain_RunRequest_LatestRewriteWins).- Metadata propagation keys (
keys.go): all keys live in a single file and follow^[a-z][a-z0-9_-]*(\.[a-z0-9_-]*)+$(metadata.go:8). Framework-injected error tagging usesmw.<id>.error_kind(keys.go:81) so operators can distinguish framework-emitted entries from middleware-emitted ones.
Invariants
- Per-request context isolation.
cloneInputFordeep-copies every mutable field (Headers,RespHeaders,Metadata,Body,RespBody,UserGroups,UserGroupNames) before each invocation (chain.go:286-308). A misbehaving middleware that mutatesin.Headersonly corrupts its own copy. - Body-tap bounded by capture limit. Request side uses
io.LimitReader(r.Body, limit+1)(bodytap/request.go:152) — the+1is how the code detects truncation (bodytap/request.go:160); the surfaced buffer is sliced back down tolimit. Response side stops teeing oncebuf.Len() >= cap(bodytap/response.go:121-133). Neither side can grow the buffer past the configured cap. - Headers/body redaction order. Accumulator runs
Scan(value)before counting cost (metadata.go:81-82), so the byte budgets are computed against post-redaction sizes.Scanorder is PEM → JWT → AWS key → bearer → Luhn-validated CC (redaction.go:25-51) — the comment block inredaction.go:8-13is explicit that this is best-effort, not DLP. - No middleware can starve the chain. Every invocation runs inside
context.WithTimeout(ctx, clampTimeout(spec.Timeout))in a separate goroutine (dispatcher.go:51-94), with the deadline race-selected against the result channel. A blocked middleware fires the timeout path, gets fail-mode'd, andIncError(kind=timeout). Timeouts are clamped to[10ms, 5s](types.go:80-86,dispatcher.go:174-185). - Panic recovery.
recover()captures the panic, logs only the type + a 4 KiB stack prefix (no panic value — avoids leaking secrets the middleware was processing), and produces apanicErrorthat flows through fail-mode (dispatcher.go:64-76). - Chain immutability + atomic swap.
chainTableis cloned on everyRebuild/Invalidate*and swapped viaatomic.Pointer(manager.go:44-69,manager.go:221-300). Readers (ChainFor) are lock-free; writers serialise onwriteMu. The retired chain isClose-d in a background goroutine bounded bychainCloseTimeout = 2 * MaxTimeout(manager.go:21-22,manager.go:326-346), so in-flight invocations finish on the old chain after the swap.
Things to scrutinize
Correctness
- Chain ordering deterministic from synth output?
Manager.buildChainiteratesb.Specsin slice order and appends tobound(manager.go:366-391);NewChainthen partitions by slot but preserves slice order within each slot (chain.go:50-60). So order on the wire = order observed at runtime. Synth must therefore emit specs in the intended execution order — there is no per-specPriorityfield. Worth flagging. - Decision short-circuit semantics.
RunRequestreturns immediately onDecisionDeny(chain.go:164-167) with the metadata accumulated so far plus thedenied.Metadata. Callers that ignoremergedon deny will lose framework-injectedmw.<id>.error_kindentries. The proxy runtime is the only caller; confirm it always feedsmergedinto the access log on the deny path as well. UpstreamRewriteAuthHeaderbypass (types.go:218-235). TheAuthHeader/StripHeadersfields intentionally bypass the header denylist on the basis that the proxy itself rewrites auth. The denylist still blocks middleware-emittedHeadersAdd: Authorization=.... This is a delicate carve-out — review the runtime consumer to confirm only the trusted upstream-build path unpacksAuthHeader, never the genericapplyMutationsloop.replayReadCloser.Closeonly closes the tail (bodytap/request.go:199-201). The replay buffer doesn't own a resource, so this is correct, but it conflates "replay finished" with "underlying body closed". If a callerClose()s without reading to EOF, the original body is closed but the captured prefix is lost; harmless for the proxy path (upstream always reads to EOF) but worth a doc-comment.
Security
- Body-tap memory bounds. Discussed above — bounded by
MaxBodyCapBytes = 1 MiBper direction (types.go:77) and the sharedBudget(default 256 MiB). The concerning case is the deep-copy incloneInputFor(chain.go:300-306): every middleware invocation gets its own copy ofBodyandRespBody. A chain of N middlewares with a 1 MiB body allocates N MiB of transient bytes per request. WithMaxMiddlewaresPerChain = 16(types.go:103) that's up to 16 MiB extra per in-flight request. Worth pricing into the budget model. - Header redaction completeness.
denyHeaders(headerpolicy.go:5-17) covers the auth/forwarding family and framing (Content-Length,Transfer-Encoding,Trailer).denyHeaderPrefixescoversX-Authenticated-*,X-Forwarded-*,X-Remote-*,X-NetBird-*. Notably absent:Range,If-Match/If-None-Match(mutation could cause cache poisoning),Origin/Referer. Not necessarily wrong, but worth a deliberate decision. - Metadata key collisions across middlewares. The accumulator has no cross-middleware uniqueness check; two middlewares with the same key in their allowlist can both emit it, and both copies land in
merged(metadata.go:51-99). Downstream consumers must tolerate duplicates. Worth documenting. - Deny rendering.
RenderDenyResponseonly allows codes matching^[a-z][a-z0-9._-]{0,63}$(decision.go:9), redacts/truncates message + detail values, capsDetailsat 8 entries (decision.go:42-50), clamps status to[400,499]\{401}(decision.go:65-73). The deny body type is fixed; middlewares cannot inject arbitrary JSON.
Concurrency
- Per-request state vs shared state in factories. Each
Factory.Newis called once per chain build; the returnedMiddlewareinstance is shared across all requests for that chain.Invokemust be reentrant. The framework does not enforce this — a buggy middleware that holds per-call state on the struct will silently race. Suggest a// Invoke must be safe for concurrent usedoc on the interface. chainTableclone-on-write is correct, butaddChain/removeChainmutate the cloned table before the swap (manager.go:71-108), and they're called underwriteMu. Readers only ever see the post-swap pointer. Good.Chain.inflightWaitGroup.Run*doesAdd(1)/Done()(chain.go:142-143,chain.go:194-195,chain.go:225-226);Closewaits on it bounded by ctx (chain.go:75-85). One concern: a newRunRequestcanAdd(1)afterClosestarted waiting if the caller still holds a stale chain pointer.WaitGroupdoes not panic on this if the count was already > 0 atWaittime, but it does panic ifAddhappens afterWaitreturns and anotherWaitruns.Closeis documented one-shot, so single-Waitis fine, but callers must drop the chain reference before callingClose. Worth a code comment nearClose.- Goroutine leaks.
Dispatcher.Invokespawns one goroutine per call and always writes to a buffered (cap=1) channel (dispatcher.go:62-76), so even if the timeout fires the goroutine completes its send and exits. No leak. closeChainsAsyncdetaches retired chains into a goroutine (manager.go:326-346). IfManageris never GC'd this is fine, but there's no shutdown hook to wait on outstanding closes. Reviewers should confirm the proxy shutdown path explicitly drains in-flight requests before tearing downManager, or accept that the last chain-close round may be cut short on exit.
Performance
- Allocations per request.
cloneInputForallocates new slices forHeaders,RespHeaders,Metadata,Body,RespBody,UserGroups,UserGroupNames— once per middleware per request. For a typical 5-middleware chain on a 1 KiB body that's ~10 small slice allocs plus oneBodycopy each. Not a hot-path crisis, butsync.Poolfor the per-callInputwould be a natural follow-up. - Accumulator allocates a fresh
allowSetperEmitcall (metadata.go:55-58). One per middleware per slot pass = up to 48 per request. Cheap, but worth noting. - Regex cost.
Scanruns five regex passes on every accepted metadata value (redaction.go:25-51). Bounded byMaxMetadataValueBytes = 4 KiBso worst case is small.
Observability
- Per-middleware metrics.
proxy.middleware.requests_total{middleware,target_id,outcome}(metrics.go:34-41),duration_ms,invocations_total,errors_total{kind},metadata_rejected_total{reason},header_mutation_blocked_total{header},capture_bypass_total{reason}. Comprehensive surface; operators can alert onerrors_total{kind=panic}anderrors_total{kind=timeout}separately. Latency histogram is in milliseconds with default OTel buckets — for a 10ms–5s timeout range default buckets cover OK, but a custom bucket set centred on 1–500ms would resolve the agent-network response-parser tail better. - Decision logs. Panic logs (
dispatcher.go:69) includerequest_id, type, and stack but not the panic value (safe).Chain.Closelogs middleware-close errors at debug (chain.go:91).applyMutationslogs body-replace rejections at warn (chain.go:278). No log on the deny path itself — by design, since the access-log terminal middleware is expected to record outcomes.
Test coverage
| Test file | Locks down |
|---|---|
proxy/internal/middleware/chain_test.go:77 |
RunRequest threads metadata across on_request middlewares (regression for the "later mw can't see earlier mw's emissions" bug). |
chain_test.go:110 |
RunResponse reverse-order threading. |
chain_test.go:142 |
cost_meter-shaped scenario: response_parser registered after cost_meter still emits before cost_meter sees the bag (guards the cost.skipped=missing_tokens regression). |
chain_test.go:178 |
UpstreamRewrite last-write-wins. |
chain_test.go:206 |
No middleware emits → nil rewrite. |
chain_test.go:224 |
Rewrite filtered when CanMutate=false. |
chain_test.go:245 |
Input.UserGroups propagates verbatim through cloneInputFor. |
chain_test.go:304 |
Terminal middlewares see the full accumulated bag + prior terminal emissions. |
Gaps worth raising with the author:
- No direct test for
Dispatcher.Invoketimeout / panic / fail-mode behaviour at the framework level (covered indirectly by built-in tests, but a unit test pinningerrors_total{kind=...}labels would be cheap insurance). - No test for
bodytap.CaptureRequesttruncated replay (the upstream-sees-full-body invariant is exactly the kind of thing a regression would silently break). - No test for
Budgetexhaustion behaviour under concurrency. - No test for
Manager.InvalidateMiddleware+LiveServiceCheckrace (the auth-revocation race the comment atmanager.go:33-38calls out is the load-bearing reason forLiveServiceCheck).
Known limitations / explicit non-goals
- No middleware-to-middleware RPC. Side-channel is metadata only.
- No streaming body inspection. Middlewares see a bounded prefix; SSE / chunked parsing happens against that prefix in the response middleware.
- No per-spec priority. Order is registration order in the spec slice.
- No retry / circuit-breaker on middleware errors. Fail-mode is binary (open/closed) and per-spec.
- Mutations cannot rewrite the request URL path or query — only
RewriteUpstreamcan change scheme/host (+ optional path replacement, seetypes.go:218-235). - Redaction is best-effort. Explicitly documented in
redaction.go:8-13. Not a DLP solution.
Cross-references
- Upstream wire shape: ../modules/10-shared-api.md (Spec/RawConfig encoding from management).
- Built-in middlewares using this framework: ../modules/31-proxy-middleware-builtin.md.
- Runtime wiring (where
Manager,Chain, andbodytapare consumed by the HTTP handler): ../modules/33-proxy-runtime.md. - End-to-end request flow including capture + chain dispatch: ../01-end-to-end-flows.md.
- Top-level architecture: ../00-overview.md.