Skip to content
SHC Docs

SHC Daemon DI Migration — Master Plan

STATUS: DONE / SUPERSEDED (2026-06-24). The Stage-C cutover has since LANDED. The GetRuntimeService/SetRuntimeService singletons and the dual-write bridge are deletedrg 'func (Set|Get)RuntimeService' internal over 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 the AdapterInstaller post-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 note project_di_migration, and the quality bar in docs/contributing/engineering-standards.md.

Three headline errors have been corrected inline below (each marked [CORRECTED]) so this record does not actively mislead:

  1. Not “exactly five edges.” The real cross-module runtime-lookup edge count is ~16 across 7 modules (it forgot ca→vault and hud→{schedule,task} entirely, and backup→operation is a different singleton, GetRuntimeRegistry).
  2. The composition root lives in composition.go, not services.go. The type Services struct and its accessors/dual-writers are in internal/daemon/composition.go; services.go/services_extra.go/services_seams.go hold the wireModule* wiring functions, not the struct. There is no buildServices(deps) function.
  3. Stage C is NOT done. [OBSOLETE 2026-06-24 — Stage C IS done.] The Stage-C deletes have landed: the dual-write bridge and the GetRuntimeService/SetRuntimeService singletons are gone (ratchet hard-zero). Only the AdapterInstaller post-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 to vault.GetRuntimeService)
  • job→vault (job/deployment_vars.go; same resolver shape)
  • metrics→vault (metrics/rpc_utility.go; same resolver shape)
  • ca→vault (ca/store.gothe plan forgot ca entirely)
  • app→{config,dns,docker} (app/dns_target.go, app/dns_lifecycle.go, app/router.go)
  • hud→schedule (hud/adapters/schedules.goforgotten)
  • hud→task (hud/adapters/tasks.go, hud/workers.goforgotten)
  • backup→operation is actually operation.GetRuntimeRegistry() in backup/router.go (~:400, :483), not GetRuntimeService

(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.

LEAVES (dependsOn=[]): audit auth config dns docker events export forward health
inspect leader logs network notification schedule shell storage tenant user
vault operation clone cluster
DEPENDENTS: connections→[vault] job→[vault] metrics→[vault]
backup→[operation] app→[config,dns,docker]

[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-Wire Tier-1 publish (wireModuleServices/Extra/Seams),
  • the in-Start Tier-2 SetRuntimeService calls (~11 subsystems),
  • the AdapterInstaller race-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 + buildServices that constructs every service via its current constructor AND still calls SetRuntimeService(s.Xxx) — the dual-write bridge: old GetRuntimeService path stays live, nothing breaks. Author the new consumer ifaces (connections.VaultService, job.VaultResolver, backup OperationStreamer). Extend the Resolver to carry *Services.
  • Stage B (interiors, PARALLEL waves): per module, change the constructor to take deps, thread *Service into router/command/cron builders, replace self-lookups with the field. Bridge keeps SetRuntimeService alive, 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 + every state.go/register.go singleton + 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.

  • 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).
  • 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 SetServiceResolver closures 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.