ADR-0006 — Thread registry.Deps through every seam; no global root pointer
- Status: Accepted + COMPLETE (2026-06-22).
di-singletonsreached 0 — every blocker-seam type below was converted the correct way (auth middleware factory, Diagnoses/TaskOn Deps-forms, bare helpers via injected resolvers, CLI local construction, the clone HTTP router de-locatored — both its PULL singleton and its PUSHSetServiceResolverglobal deleted, and finally theappmodule: theServiceRecorderthreaded throughComposeRunOptionsso no in-daemon install path drops service-row recording, the state-mirror + CLI readers onto an injectedresolveAppService, the ~15 daemon adapters onto a capturedroot *Services, and both app singletons deleted ind6e540757). The wave was adversarially verified (zero findings).app’sSet/GetRuntimeStackRepo+SetIngressIntentRecorderare SEPARATE singleton families (different names, not counted by the ratchet) and were intentionally left. Thearch-fitness.shratchet (now hard-zero) keeps it there. - Builds on: ADR-0001 (Pure DI at a
composition root). Enforced by
scripts/arch-fitness.shdi-singletons(→ 0).
Context
Section titled “Context”The DI sweep deleted 19 of 30 package singletons cleanly. The final ~9 modules
resisted because some of their readers are installed with no registry.Deps to
reach the composition root — five seam shapes:
- chi auth middleware —
auth.Authenticate/RequirePermissions/OptionalAuthenticateinstalled as bare package-function values across many routers (middleware.go). registry.Diagnoses—Diagnosis.Run func(ctx)carries no Deps (core/registry/types.go); used byvault/doctor.go.task.TaskOnhandlers —func(ctx, kwargs)signature, no Deps; used bynetwork,notification,healthcrons.- bare exported helpers reading a singleton —
cluster.GetNodeRegistry,vault.ResolveAutogenViaVault— called package-wide. - standalone-CLI bodies —
forward.RunForward(theshc forwardsubprocess).
Two ways to give these access to the root were considered: thread Deps properly,
or publish a single boot-time atomic.Pointer[Services] that the no-Deps sites read
ambiently.
Decision
Section titled “Decision”Thread registry.Deps through every seam — the way Kubernetes, Terraform, etcd,
and Docker do. Do NOT introduce a global root pointer / ambient root accessor.
A boot-published atomic.Pointer[Services] is precisely the service-locator
anti-pattern this whole migration removes (ADR-0001). Reintroducing it for the
last mile would regress the architecture at the finish line — the mature Go infra
projects are famous specifically for not doing this. They thread dependencies even
when it means changing a registration contract, because the alternative (ambient
global) is the thing they reject.
The fix per seam is a constructor/factory form — the registration takes a func
that receives deps and returns the handler, built at the wire site that holds
Deps:
| Seam | Mature-Go pattern | SHC fix |
|---|---|---|
| chi middleware | k8s genericapifilters.WithAuthentication(h, authn, …) — authenticator passed in | auth.Authenticate(svc) func(http.Handler) http.Handler, built at each router from d.Services |
registry.Diagnoses | k8s controllers: a struct constructed with deps; Reconcile closes over them | a Deps-carrying registration form; the diagnosis builder captures the resolved *Service |
task.TaskOn | Terraform ConfigureContextFunc returns a configured meta | a Deps-carrying handler-builder; the dispatcher (wired with the root) supplies Deps |
| bare helpers | k8s injects a NodeLister, never a cluster.GetNodeRegistry() global | delete the global-reader; inject a resolver at the caller |
| standalone CLI | each cmd/ binary has its own composition root | RunForward constructs its *Service locally |
The two existing lazyConfigRoot / lazyEventsRoot atomics are the sole, narrow
exception — they serve boot goroutines that genuinely run before newServices()
exists (runDBConfigReload, operation_event_bridge). They are documented, bounded,
and must not be extended to the seams above; no new ambient-root accessor is
permitted.
Consequences
Section titled “Consequences”- Churn is accepted, deliberately. Changing
registry.Diagnoses/task.TaskOnripples to all diagnoses/crons; the auth-middleware factory touches every router that installs auth. This is the cost of correctness and it is paid once. - End state:
di-singletons→ 0. Zero service-locator globals; every dependency flows from the composition root by construction — the Kubernetes/Terraform shape. - The
arch-fitness.shratchet keeps it there; a future re-introduced globalGetRuntimeService(or a new ambient root pointer) failsmake lint.
Alternatives rejected
Section titled “Alternatives rejected”- Boot-published
atomic.Pointer[Services]for the last mile — pragmatic but it is the service locator; rejected for regressing ADR-0001 at the finish line. - Leave the 9 behind the dual-write bridge at 11 — a real improvement, but stops short of the correct end state with no architectural reason to.