ADR 0022: The gateway takes its dependencies, not the Server
Status: accepted (2026-09-21). Canonical copy in git at docs/adr/0022-gateway-dependencies-not-the-server.md. Part of the architecture quality plan, track 3d-3a; the study and the owner's decisions (F10, F11) are in the 3d proposal.
Context
The broker package is one package of about 29,000 lines. Its 2026-08 sweep named four seams (core, OAuth server, MCP gateway, storage), and the plan schedules the split as track 3d-3. Before the directories can move, the code has to stop reaching across the seams.
The MCP gateway held a *Server and read through it: the config, the world registry, the write token store, the clock, the provisioner, the auth and rate limit middleware. The Server in turn held the gateway's state: the write token store, the production QUIC pool, the provisioner and the gateway profile, which newMCPGateway wrote back into the Server as a side effect so /me/install could scope worlds the way the gateway did. The memory broker enabled provisioning through a Setup hook that mutated the Server after construction. NewServer took seven positional arguments, four of them nil in most tests. Config carried six *Ref methods that resolve Secret locations for stores that will live in three different packages, and several helpers took *Config only to reach the registry inside it.
Decision
gatewayDepsnames everything the gateway needs: the world registry, the issuer, the public URL, the MCP config, a clock, a logger, the write token store, the provisioner (nil in static mode), and the auth and rate limit middleware asfunc(http.Handler) http.Handler.Runbuilds it from the Server and the shared Secret store; tests build it by hand.Runowns the gateway's lifecycle: it builds the write token store and the QUIC pool, closes the pool after the MCP listener drains, and wires the provisioner when the binary supplies a bucket backend (RunOptions.Buckets) and the config enables provisioning. TheSetuphook,EnableProvisioning,MCPGateway,MCPGatewayWithandCloseMCPGatewayare gone.- The Server keeps only the OAuth surface and the management API. It learns the gateway profile's tenant scoping at construction (
ServerDeps.TenantScoped) from the same profileRunhands the gateway, so the two cannot diverge. NewServer(cfg, ServerDeps): the signer, verifier, store, optional discovery and id_token signer, logger and scoping are named fields.- Secret locations are free functions in
secret_refs.go, next to the pinned Secret names and keys, so each future package calls them without owningConfig.readableWorlds,authorizedWorlds,lookupWorld,tenantWorldForandnewWorldPooltake the registry (and the issuer where identity keys are built), not*Config. Claimsand the context and fingerprint helpers live inclaims.go;config.gois four files: types, load, defaults, validate.- The two authorize redirect helpers are an
authorizeReplyvalue carrying the validated redirect URI and client state.
Consequences
- Broker behaviour, tool output, wire goldens and log text are unchanged; the memory broker's "provisioning enabled" line now comes from
Runwith the same fields. One ordering change at startup: the Secret store is built before OIDC discovery, so a broker with both misconfigured reports the store first. - Test fixtures are one file,
helpers_test.go, with agatewayFixturethat mirrorsRun's wiring, a sharedfakeClockand oneServerDepsbuilder. Tests set the clock on the fixture instead of on the Server after the fact. - The directory split (3d-3b) can now be a near pure move:
gatewayimportscorefor the registry and config types and nothing from the OAuth server;Runis the one place that knows all four packages. - Lint ratchet 123 to 121 entries:
NewServer's argument count and the authorize helpers' argument count are gone,Runis two statements shorter,buildServerreturns two values.