ADR-0007: @register_provenance_adapter stores adapter classes, not instances¶
Status: Accepted Date: 2026-05-19 Tags: di · registry · open-closed · critic-bp3 · adr-0032 Related: 0006, 0004, production ADR-0032, production ADR-0038, Phase 3 ADR-0010
Context¶
Best-practices' lens design for @register_provenance_adapter called cls() at decorator-application time and stored the resulting instance in the registry. The critic landed BP-3 against this: constructing an instance at decorator time forces every adapter to have a no-arg constructor and forbids constructor injection — but production ADR-0032 (the precedent for adapter protocols) and ADR-0031 want adapters to receive dependencies (e.g., an SBOM reader, a logger, an image-manifest cache). Storing instances at decorator time also performs the worst possible work at the worst possible time: every plugin module's import becomes load-bearing because every adapter's __init__ runs then.
final-design.md §Synthesis ledger departure #2 and final-design.md §Component design §3 are explicit: the registry stores classes, not instances. Construction happens lazily in assemble_provenance, with DI-aware kwargs honored by an AdapterFactory.
Mirrors how the Phase 3 RecipeEngine registry (Phase 3 ADR-0009) and the @register_dep_graph_strategy registry handle adapter contribution.
Options considered¶
- Option A — Store instances at decorator time (
_REGISTRY[key] = cls()). Best-practices' original position. Pattern: Singleton-at-import. Forces no-arg__init__; runs work at import; DI-hostile; "construct at the worst time" anti-pattern. - Option B — Store classes; consumers construct on every call (
_REGISTRY[key] = cls; adapter = cls()at eachattribute()call site). Pattern: Class-as-token. Defers construction to call time; allows DI; trivial cost. - Option C — Store classes plus a separate
AdapterFactoryProtocol that handles DI kwargs (sbom_reader,logger,image_manifest_cache). Pattern: Class-as-token + Factory. The factory honors well-known DI kwarg names if the adapter's__init__declares them; otherwise plaincls().
Decision¶
Adopt Option C. _REGISTRY: Final[dict[ProvenanceAdapterId, type[VulnProvenanceAdapter]]] = {} stores classes. @register_provenance_adapter(*, layer, ecosystem) writes _REGISTRY[(layer, ecosystem)] = cls — no instance construction at import time. assemble_provenance(...) accepts an optional adapter_factory: AdapterFactory | None = None parameter; the default factory inspects cls.__init__'s signature and passes a known closed set of DI kwargs (sbom_reader, logger, image_manifest_cache) if the adapter declares them. Otherwise it falls back to cls(). Adapters that need other dependencies must either declare them as well-known kwargs (ADR amendment to the closed set) or accept the default. The factory is overridable for test isolation.
Tradeoffs¶
| Gain | Cost |
|---|---|
Adapters use constructor injection — sbom_reader, logger, image_manifest_cache are passed in at dispatch time, not pulled from globals |
The closed DI-kwarg vocabulary (sbom_reader, logger, image_manifest_cache) is load-bearing across all adapters; growing the vocabulary requires an ADR amendment |
Module import is fast: only _REGISTRY[key] = cls runs at import; no __init__ work — fixes critic BP-3's "worst time to do work" complaint |
Per-call adapter construction (a few __init__ calls per assemble_provenance) is a small cost — measured ≤ 1 ms per non-matching adapter; acceptable per the perf envelope (≤ 50 ms uncached overall) |
Test isolation is clean: a pytest fixture snapshots and restores _REGISTRY per test; the adapter_factory parameter allows substituting a deterministic fixture factory in tests |
The factory parameter adds optional complexity at the call site; resolved by defaulting to the production factory |
isinstance(cls(), VulnProvenanceAdapter) runtime check at decorator time (best-practices' approach) is eliminated — Protocols' @runtime_checkable only checks method names, not signatures, so the check gave false safety. Synthesis relies on mypy --strict at registration site instead |
Without the runtime check, a mis-typed adapter that has the right method names but wrong signatures lands in the registry and fails at call time. Mitigated: mypy --strict is the CI gate; the failure mode is loud (typed ValidationError or TypeError), not silent |
Mirrors the established pattern from Phase 3 RecipeEngine registry, @register_dep_graph_strategy, @register_signal_kind — engineers read four examples and know the shape |
One more example to keep consistent; the convention is now a load-bearing repository pattern |
Pattern fit¶
Implements Plugin / Registry (production ADR-0031) plus Class-as-token + Factory (toolkit §Composition / coupling — Dependency injection via factory): the registry holds types, not instances; the factory constructs at dispatch time with the dependencies the call site has. Also instantiates Lazy construction — work is deferred from import time to call time, where context (the workflow's sbom_reader, logger) is available. Mirrors production ADR-0032's adapter-protocol shape verbatim plus the lazy-construction discipline.
Consequences¶
_REGISTRY: Final[dict[ProvenanceAdapterId, type[VulnProvenanceAdapter]]] = {}lives insrc/codegenie/primitives/vuln_provenance/registry.py.@register_provenance_adapter(*, layer: Layer, ecosystem: Ecosystem)writes_REGISTRY[key] = cls(nocls()call). Duplicate registration raisesRegistryErrorat import time.AdapterFactoryProtocol lives next to the registry; its default implementation inspectscls.__init__.__annotations__and matches well-known kwarg names from the closed set.- Adapter
__init__signatures may declare any subset of{sbom_reader, logger, image_manifest_cache}as kwargs; the factory passes what the adapter requests. The closed kwarg set is an additive enum-shaped vocabulary; growing it requires an ADR amendment. - A pytest fixture (
tests/conftest.pyfor the primitive tree) snapshots_REGISTRYper test and restores it on teardown — test isolation pattern mirrors Phase 2's@register_index_freshness_checkfixture. tests/unit/primitives/vuln_provenance/test_registry.pyasserts: duplicate registration raisesRegistryError; non-conforming class registration is rejected bymypy --strictat type-check time; lookup by(Layer, Ecosystem)works; the registry is empty after fixture teardown.- Integration test
tests/integration/test_provenance_assembly_via_plugins.pyproves the full plugin-load → adapter-registration →assemble_provenance(...)→ typed-result path with both Phase 3 and Phase 7 plugins loaded. - The performance-first adapter Protocol extension with
cost_band + applies_when([critic Perf-5]) is structurally closed off: the registry stores plaintype[VulnProvenanceAdapter]andassemble_provenancedoes not inspectcost_bandorapplies_when. Adding them would require editing the Protocol — which would be a kernel-contract amendment per ADR-0039.
Reversibility¶
Medium. The "classes not instances" decision is internal to the registry module and could be reversed by changing _REGISTRY to store instances — but every adapter would need a no-arg constructor migration, and the DI-friendly contract would break for every adapter currently using it. The closed DI-kwarg vocabulary is harder to reverse: once Phase 8+ adapters depend on the set, removing a name forces an ADR amendment plus consumer migration.
Evidence / sources¶
../final-design.md §Synthesis ledger row 11 (score 14/15) + departure #2, §Component design §3 (registry stores classes)../phase-arch-design.md §Component design §4(@register_provenance_adapter+_REGISTRY), §Component design §3 (VulnProvenanceAdapterProtocol)../critique.md §Attacks on the best-practices design §3(BP-3 —cls()at decorator time is DI-hostile), §Pattern claims that don't survive scrutiny (Protocol misused as a contract guard)- production ADR-0032 — DepGraphAdapter Protocol
- production ADR-0031 — Plugin architecture
- Phase 3 ADR-0010 — Domain modeling discipline