ADR-0002: Plugin / Registry kernel — instance-based with default_registry + fixture isolation¶
Status: Accepted Date: 2026-05-17 Tags: plugin-architecture · registry · kernel · open-closed · phase-7-bake-test Related: 0003, 0004, 0013, production ADR-0031, production ADR-0028
Context¶
Production ADR-0031 commits the system to a plugin architecture where each (task × language × build-tool) tuple is a plugin and where Phase 7's distroless work must land "extension by addition" — zero edits to existing plugins or the kernel. Phase 3 is the first instantiation of that contract: it ships vulnerability-remediation--node--npm and the universal--*--* fallback, and its kernel becomes the closed-for-modification anchor that every later phase composes against (phase-arch-design.md §Component design C2, final-design.md §Synthesis ledger row 6).
The performance-first lens design proposed a module-level mutable singleton (_REGISTRY: dict = {} populated by @register_plugin at import time). Best-practices proposed an instance + default_registry + fixture-isolation pattern matching Phase 6.5's already-merged TaskClassRegistry. Security was silent on the registry shape. The critic's pattern review and critique.md §Pattern claims that don't survive scrutiny flagged the module-level mutable dict as the toolkit's "side effects at module import time" anti-pattern.
Options considered¶
- Option A — Module-level mutable singleton.
_REGISTRY: dict[PluginId, Plugin] = {};@register_pluginmutates it on import. Pattern: Registry, but applied as the toolkit's flagged anti-shape. Test isolation is impossible withoutmonkeypatchof module globals — fragile, leaks across test sessions. - Option B — Pure DI: every consumer constructs and passes its own
PluginRegistry. No default singleton; every call site explicitly threads the registry. Pattern: Dependency inversion taken to a literal extreme. Eliminates global state but adds 5–10 lines of plumbing at every call site for zero capability the CLI needs. - Option C — Instance with
default_registrymodule-level instance +@register_plugin(registry=...)parameter for fixture isolation. Production code uses the default; tests pass a freshPluginRegistry()instance. Pattern: Registry pattern as written in the toolkit — same shape as Phase 6.5'sTaskClassRegistry. Mirrorspytest's plugin model.
Decision¶
Adopt Option C. Ship class PluginRegistry in src/codegenie/plugins/registry.py with register(plugin), get(id), resolve(scope), all(). Ship one module-level default_registry: PluginRegistry instance and a def register_plugin(plugin, *, registry: PluginRegistry | None = None) helper that mutates the passed registry (or default_registry if None). Tests pass fresh PluginRegistry() instances; production code uses the default.
Tradeoffs¶
| Gain | Cost |
|---|---|
Matches Phase 6.5's TaskClassRegistry shape exactly — one less thing reviewers must hold in their head |
Two ways to register (with vs without explicit registry param); discoverability cost |
Test isolation is a one-line fixture (registry = PluginRegistry()); no monkeypatch of module globals |
The default_registry is still global state; a misbehaving test that forgets to use a fresh registry can pollute later tests |
@register_plugin decorator is the toolkit's textbook Registry shape — no eager validation, no side effects at registration |
Validation happens at resolve() time; a malformed plugin only surfaces at first dispatch |
Adding a new plugin = new directory + decorator call; zero edits to registry.py or any existing plugin (ADR-0031's "extension by addition" honored) |
The @register_plugin call must be at module-import time, which couples plugin loading to Python import order |
| Phase 7's distroless plugin slots in mechanically; the registry has no hardcoded plugin names to add to | The kernel's resolve() algorithm becomes a hot path — must stay O(plugins) and avoid eager work |
Pattern fit¶
Implements Registry pattern (toolkit §Run-shape patterns) exactly as the toolkit prescribes: "a registry is a dict; the decorator is def register(name): def wrap(cls): registry[name] = cls; return cls; return wrap. Stay that simple." Avoids the anti-pattern called out in the same section ("a registry that does more than registration — eager validation, side effects, cross-references at registration time. Keep it dumb; validate on use"). Also instantiates Open/Closed Principle at the file boundary — the kernel never imports plugins; plugins register on import (toolkit §Composition / coupling patterns).
Consequences¶
src/codegenie/plugins/registry.pyis closed for modification once Phase 3 ships. Phase 7 distroless addsplugins/distroless-migration--node--npm/and a decorator call; zero edits here.default_registryis aFinalmodule-level instance constructed once; replacement requires explicit DI (tests do this).tests/fence/test_kernel_frozen.pygit-diffs the kernel file list against an ADR-anchored allowlist; any unauthorized edit fails CI.PluginAlreadyRegisteredon collision;PluginExtendsCycle(chain)on cyclicextends;PluginRejected(integrity_mismatch)onPLUGINS.lockmismatch — every failure mode is a typed exception with exit code 4.- Resolution stays O(plugins) per workflow — 30 μs against 3 plugins per the architecture spec. Linear scaling is fine at the projected Phase-10 plugin count (~15).
- Phase 6.5's
TaskClassRegistryis the precedent; this ADR locks the precedent for every future plugin-shaped registry (signal kinds, dep-graph strategies, etc.). - Tests must use
registry = PluginRegistry()fixtures explicitly — aconftest.pyfixture is provided.
Reversibility¶
Medium. Moving from instance-based to pure-DI (Option B) is a mechanical refactor — replace every register_plugin(p) with register_plugin(p, registry=cli_registry) and pass cli_registry through the orchestrator's __init__. Moving to Option A (module-level mutable singleton) is also possible but loses test isolation and re-introduces the anti-pattern. The chosen shape is the most reversible because it carries both DI and singleton affordances simultaneously.
Evidence / sources¶
../phase-arch-design.md §Component design C2, §Design patterns applied row 1, §Patterns considered and deliberately rejected ("No DI container")../final-design.md §Synthesis ledger row 6(score 15/15) and §Pattern reconciliation row "Plugin / Registry"../critique.md §Pattern claims that don't survive scrutiny(module-level singleton flagged)- Phase 6.5's
src/codegenie/eval/task_class_registry.py— the precedent this ADR matches - production ADR-0031 — plugin architecture (the umbrella commitment)
- production ADR-0028 — task class introduction order
- design-patterns-toolkit.md §Registry pattern, §Open/Closed Principle