SHC Daemon DI Migration — Master Plan
STATUS: DONE / SUPERSEDED (2026-06-24). The Stage-C cutover has since LANDED. The
GetRuntimeService/SetRuntimeServicesingletons and the dual-write bridge are deleted —rg 'func (Set|Get)RuntimeService' internalover non-test Go returns 0, and the di-singletons arch-fitness ratchet is a hard-zero (scripts/arch-fitness.sh). The ONLY surviving piece by design is theAdapterInstallerpost-publish hook (internal/modules/backup/state.go,internal/modules/config/state.go), kept deliberately, not pending removal. The “still live” / “NOT done” / “in-progress” assertions further down this file (and the[CORRECTED — Stage C is NOT done.]block) predate that cutover and are now historically false — read them as a record of the pre-cutover state.STATUS (original): SUPERSEDED / HISTORICAL (kept as a record of the original plan). This was the forward-looking plan authored before the work landed. It is retained verbatim in spirit, but several of its headline claims were wrong or have since been overtaken by the actual implementation. Do not treat this file as the current architecture. For ground truth read, in order: the live composition root and its migration comment in
internal/daemon/composition.go(type Services struct), the project memory noteproject_di_migration, and the quality bar indocs/contributing/engineering-standards.md.Three headline errors have been corrected inline below (each marked [CORRECTED]) so this record does not actively mislead:
- Not “exactly five edges.” The real cross-module runtime-lookup edge count is ~16 across 7 modules (it forgot
ca→vaultandhud→{schedule,task}entirely, andbackup→operationis a different singleton,GetRuntimeRegistry).- The composition root lives in
composition.go, notservices.go. Thetype Services structand its accessors/dual-writers are ininternal/daemon/composition.go;services.go/services_extra.go/services_seams.gohold thewireModule*wiring functions, not the struct. There is nobuildServices(deps)function.Stage C is NOT done.[OBSOLETE 2026-06-24 — Stage C IS done.] The Stage-C deletes have landed: the dual-write bridge and theGetRuntimeService/SetRuntimeServicesingletons are gone (ratchet hard-zero). Only theAdapterInstallerpost-publish hook remains, kept by design. The text below describing these as “still live” is pre-cutover.Verified against HEAD (DI composition root, scope collapse, ent ORM) — 2026-06-22.
Replace 29 per-module GetRuntimeService/SetRuntimeService singletons (~300 call
sites) with hand-wired constructor injection at one composition root — the mature
Go pattern (k8s/prometheus/etcd/cockroach/hashicorp). Keep the init()-time
registry idiomatic; keep log and the event-bus ambient. Produced by a 30-agent
mapping + synthesis workflow (wf_e0152f62-470), verified against the tree.
Key finding — far more tractable than the site count suggests
Section titled “Key finding — far more tractable than the site count suggests”[CORRECTED] The original plan claimed the only cross-module
*.GetRuntimeService() calls inside module packages were “exactly five
edges.” That headline was wrong. Re-derived from the tree at HEAD, the real
cross-module runtime-lookup surface is ~16 edges across 7 modules (verify
with grep, not this doc). The original five were under-counted in three ways:
it forgot ca→vault outright, it forgot hud→{schedule,task} outright, and its
backup→operation edge is not a GetRuntimeService call at all but
operation.GetRuntimeRegistry() (a different singleton). The corrected
inventory:
connections→vault(connections/creds.go; now an injectable resolver that defaults tovault.GetRuntimeService)job→vault(job/deployment_vars.go; same resolver shape)metrics→vault(metrics/rpc_utility.go; same resolver shape)ca→vault(ca/store.go— the plan forgot ca entirely)app→{config,dns,docker}(app/dns_target.go,app/dns_lifecycle.go,app/router.go)hud→schedule(hud/adapters/schedules.go— forgotten)hud→task(hud/adapters/tasks.go,hud/workers.go— forgotten)backup→operationis actuallyoperation.GetRuntimeRegistry()inbackup/router.go(~:400, :483), notGetRuntimeService
(Original mistaken claim, kept for the record: “exactly five edges: connections→vault creds.go:46,62; job→vault deployment_vars.go:59; metrics→vault rpc_utility.go:31; backup→operation router.go:399,482; app→{config,dns,docker}.”)
No real import cycles — every back-edge (app↔docker/events/ingress, storage↔cluster)
already lives in internal/daemon/* + internal/subsystems/impl/* adapters, broken
by consumer-defined interfaces that already exist (app.DockerInspector,
ServiceRecorder, IngressIntentRecorder). Everything else is a self-lookup
(a module finding its own singleton) → converts to a method receiver / field.
Dependency DAG (waves)
Section titled “Dependency DAG (waves)”LEAVES (dependsOn=[]): audit auth config dns docker events export forward health inspect leader logs network notification schedule shell storage tenant user vault operation clone clusterDEPENDENTS: connections→[vault] job→[vault] metrics→[vault] backup→[operation] app→[config,dns,docker]Composition root
Section titled “Composition root”[CORRECTED] A Services struct owns every wired service. As built, the
struct, its accessors, and its dual-writers live in
internal/daemon/composition.go (type Services struct) — not in
services.go as the original plan implied, and there is no buildServices(deps)
function. The services are wired (and dual-written to their legacy singletons)
by the wireModuleServices / wireExtraModuleServices / wireModuleSeams
functions in services.go / services_extra.go / services_seams.go, called
from the pre-Wire step in daemon.go. When finished, it is intended to replace:
- the pre-
WireTier-1 publish (wireModuleServices/Extra/Seams), - the in-
StartTier-2SetRuntimeServicecalls (~11 subsystems), - the
AdapterInstallerrace-closer.
[CORRECTED — not yet done] The original plan asserted the AdapterInstaller
was already “deleted (seams pass in ServiceOptions at build).” It is not
deleted. At HEAD it is still registered and live: registerBackupAdapterInstaller
and registerConfigAdapterInstaller in services_backup_config.go call
backup.SetAdapterInstaller / configmod.SetAdapterInstaller (the hook types
still exist in backup/state.go and config/state.go). Removing it is a
Stage-C delete that has not happened.
Subsystems’ Start keeps runtime work (docker SDK client, queue worker) but receives
the pre-built *Service via the lifecycle Resolver/DepsBuilder instead of
NewService+SetRuntimeService. A few (docker/cluster/vault) build their live client
in Start → daemon passes a factory and captures the result into s.Xxx.
Execution staging — the dual-write bridge makes every step ship green
Section titled “Execution staging — the dual-write bridge makes every step ship green”- Stage A (spine, sequential, Wave 0): add
Services+buildServicesthat constructs every service via its current constructor AND still callsSetRuntimeService(s.Xxx)— the dual-write bridge: oldGetRuntimeServicepath stays live, nothing breaks. Author the new consumer ifaces (connections.VaultService,job.VaultResolver, backupOperationStreamer). Extend the Resolver to carry*Services. - Stage B (interiors, PARALLEL waves): per module, change the constructor to take
deps, thread
*Serviceinto router/command/cron builders, replace self-lookups with the field. Bridge keepsSetRuntimeServicealive, so unconverted siblings + daemon adapters keep working. Land module-by-module; parallelizable in worktrees within a wave (Wave 1 = 23 leaves; Wave 2 = connections/job/metrics; Wave 3 = backup/clone/app). - Stage C (cutover, sequential, Wave 4): rework router-builder closures to close over
s.Xxx, delete the dual-write calls + everystate.go/register.gosingleton + the AdapterInstaller. One PR, one boot + e2e test.
[OBSOLETE 2026-06-24 — Stage C HAS landed; the paragraph below is pre-cutover
history.] The Stage-C deletes are done: the dual-write bridge is gone,
composition.go no longer calls <x>.SetRuntimeService(...), and no module
defines GetRuntimeService/SetRuntimeService (non-test rg → 0; di-singletons
ratchet hard-zero). Only the AdapterInstaller post-publish hook survives, kept
by design. The original pre-cutover paragraph follows.
[CORRECTED — Stage C is NOT done.] As of HEAD the DI conversion phase is
complete (every module is on the composition root), but none of the Stage-C
deletes have landed. The dual-write bridge is still live — composition.go
still calls <x>.SetRuntimeService(...) from its setters (dozens of them); all
30 modules still define GetRuntimeService/SetRuntimeService; there are still
hundreds of non-test GetRuntimeService call sites; and the AdapterInstaller
is still registered (see above). The “both paths live” window of Stages A–B is
therefore still open, and the risky atomic flip of Stage C remains future work.
Conversion order
Section titled “Conversion order”- Wave 1 (23 leaves, parallel): start with user (reference impl), docker, config, dns (the app deps); then audit/auth/events/export/forward/leader/logs/ network/notification/schedule/shell/tenant/task/health/inspect/operation; heavy: vault (26 sites), storage (late cluster setters — keep them), cluster (double-publish/teardown).
- Wave 2 (parallel, after vault): connections, job, metrics (each one vault edge).
- Wave 3 (parallel, LAST): backup (operation iface + ~15 seams), app (config/dns/docker adapters + recorders — convert its deps first), clone (Providers bundle built last).
Effort + top risks
Section titled “Effort + top risks”- Wave 0 ~1-2 days; Wave 1 ~1 week (parallel ~6-way); Wave 2 ~1-2 days; Wave 3 ~1 week; Wave 4 ~1-2 days.
- Highest risk: (1) composition-root keystone (unix-socket router walk + lazy
SetServiceResolverclosures capture the locator at chi.Walk/Start — rework atomically in Stage C; bridge shrinks the risk); (2) app (largest module, 2 singletons, setter-heavy root — must be last); (3) backup (3 singletons + the operation edge + 15 seams). - Keep the setters for storage↔cluster (
SharedManager/LeaderInfo/SharedOpForwarder) and cluster’s replaceable handle — eager args would deadlock ordering.
Spine-anchoring files: internal/daemon/{composition.go,daemon.go,services.go,services_extra.go,services_seams.go,services_backup_config.go} ([CORRECTED] composition.go is the type Services struct home — added; the services*.go files hold the wireModule* wiring), internal/subsystems/lifecycle/{lifecycle.go,registry.go}, the ~11 internal/subsystems/impl/*.go Start methods that call SetRuntimeService.