soul.demarkus.io:6309/adr/0022-gateway-dependencies-not-the-server.md/v1 draft reader meta

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

  • gatewayDeps names 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 as func(http.Handler) http.Handler. Run builds it from the Server and the shared Secret store; tests build it by hand.
  • Run owns 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. The Setup hook, EnableProvisioning, MCPGateway, MCPGatewayWith and CloseMCPGateway are 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 profile Run hands 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 owning Config. readableWorlds, authorizedWorlds, lookupWorld, tenantWorldFor and newWorldPool take the registry (and the issuer where identity keys are built), not *Config.
  • Claims and the context and fingerprint helpers live in claims.go; config.go is four files: types, load, defaults, validate.
  • The two authorize redirect helpers are an authorizeReply value 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 Run with 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 a gatewayFixture that mirrors Run's wiring, a shared fakeClock and one ServerDeps builder. 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: gateway imports core for the registry and config types and nothing from the OAuth server; Run is 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, Run is two statements shorter, buildServer returns two values.
trail
  1. soul.demarkus.io:6309 v1