Story S2-01 — PluginRegistry kernel + Plugin/Adapter/RecipeEngine Protocols¶
Step: Step 2 — Plugin Registry kernel, manifest schema, loader, resolver
Status: Done — GREEN 2026-05-18 (phase-story-executor; see _attempts/S2-01.md for the per-AC evidence table + gate log)
Effort: M
Depends on: S1-01 (PluginId, PluginScope), S1-02 (PluginScope.parse)
ADRs honored: ADR-0002, ADR-0004, ADR-0010, ADR-0011 (framing only — PLUGINS.lock rejection raised by loader in S2-03), production ADR-0031
Validation notes (2026-05-18)¶
Hardened by phase-story-validator (see _validation/S2-01-plugin-registry-kernel.md for full audit). Key changes:
- Closed the
PluginResolutionsymbol gap (S2-04 owns the sum type) by shipping a minimal placeholdersrc/codegenie/plugins/resolution.pyhere somypy --strictresolves theresolve()return annotation today (Consistency F1). - Pinned the fence-test introspection mechanism —
Protocoldoesn't populate__abstractmethods__; AC-7 now usesdir(Plugin) - dunders+__annotations__(Test-Quality F10 / Consistency F6). - Made every previously prose-only test concrete with named identities, mutation-resistant assertions, and explicit fixture cleanup (Test-Quality F1–F6, F10).
- Mandated typed
.name: PluginIdattributes onPluginAlreadyRegistered/PluginNotRegisteredso loader S2-03 and exit-code-4 formatters consume a structured field, not anargs[0]string (Coverage F6, Test-Quality F4/F6). - Specified the
make_fake_pluginfixture shape inline so the executor doesn't improvise; the singlePluginId(str)boundary lift lives in the fixture (Test-Quality F8, Consistency F9). - Reduced
AdapterProtocol to one attribute (no methods this story); freezing deferred to S7 (Design F3, Coverage F8, Consistency F8). - Added the 4th-registry rule-of-three observation as a
Notesparagraph + module docstring, mirroringdepgraph/registry.py:30-38so the kernel-extract opportunity survives (Design F1). - Added
tests/fence/__init__.pyto Files-to-touch — S2-01 is the first story to populatetests/fence/(Consistency F5).
Context¶
Step 2 lands the closed-for-modification ADR-0031 kernel that every Phase 3+ plugin slots into. This story is the kernel split: the Protocols plugins implement (Plugin, Adapter, RecipeEngine) live in src/codegenie/plugins/protocols.py, and the kernel that holds them (PluginRegistry + module-level default_registry + register_plugin helper) lives in src/codegenie/plugins/registry.py. Mirrors the three existing decorator-registry precedents in this codebase (src/codegenie/probes/registry.py, src/codegenie/indices/registry.py, src/codegenie/depgraph/registry.py) — same default_registry placement, same fresh-instance fixture story, same typed collision exception. The four-method Plugin surface (manifest, build_subgraph, adapters, transforms) is the load-bearing freeze ADR-0004 names; the fence test in this story is the structural enforcement.
This story ships only the kernel — register, get, all are real; resolve ships as a typed stub that raises NotImplementedError("resolved in S2-04"). Manifest parsing (S2-02), integrity-checked filesystem loading (S2-03), and the resolver/extends/fallback algorithm (S2-04) all sit downstream. src/codegenie/plugins/resolution.py ships here with a minimal placeholder class PluginResolution: ... so the resolve() return-type annotation resolves under mypy --strict today; S2-04 expands the placeholder into the ConcreteResolution | UniversalFallbackResolution sum type per S1-03-tagged-union-outcomes.md:378.
Scope reminder. High-level-impl.md Step 2's done-criteria are the union of S2-01..S2-04. S2-01 owns: register/get/all + Protocol declarations + collision/isolation tests + Plugin fence test. Resolver / loader / PLUGINS.lock / property-tested resolution totality belong to S2-03 / S2-04.
References — where to look¶
- Architecture:
../phase-arch-design.md §Component design C2— public interface forPlugin,PluginRegistry,default_registry,register_plugin; failure modes (PluginAlreadyRegistered, exit code 4). NOTE: arch line 466 declaresregister(self, plugin: Plugin) -> None; this story tightens to-> Pluginper theprobes/registry.py:139precedent (and to makeregister_plugin'sreturn pluginnatural) — Notes §3 captures the divergence.../phase-arch-design.md §Design patterns applied row 1— Registry pattern; per-test fresh-instance isolation.../phase-arch-design.md §Patterns considered and deliberately rejected— "No DI container"; "No module-level mutable singleton".../phase-arch-design.md §Anti-patterns avoidedrows — "Side effects in constructors / module import time"; "Stringly-typed identifiers".- Phase ADRs:
../ADRs/0002-plugin-registry-kernel-instance-with-default-singleton.md— Option C is the decision;default_registryis a module-levelPluginRegistryinstance, not adict;@register_plugin(plugin, *, registry=None)is the helper shape; tests pass freshPluginRegistry()instances.../ADRs/0004-plugin-private-capabilities-via-tccm.md— thePluginProtocol surface is exactly four members (manifest,build_subgraph,adapters,transforms); nocve_feed_parsers(). Fence test asserts the count.../ADRs/0010-domain-modeling-discipline-scope-sum-type-and-newtypes.md—PluginIdfrom S1-01; no rawstrfor domain IDs; typed exception payloads.../ADRs/0011-honest-framing-capability-sandboxedpath-pluginslock.md— framing only (loader S2-03 owns the integrity-check exception).- Production ADRs:
../../../production/adrs/0031-plugin-architecture.md§Plugin manifest, §Discovery and resolution — the umbrella commitment this kernel implements.- Existing code (precedent to mirror):
src/codegenie/probes/registry.py— class shape, decorator shape,default_registryplacement, fresh-instance fixture pattern, dual-error message naming both collidingmodule.qualnames (line 154-158). Mirror its API discipline verbatim except where the story explicitly notes a divergence (Notes §3).src/codegenie/indices/registry.py:26-31— sibling explicit-deferral docstring for the rule-of-three kernel-extract; this story'sregistry.pycarries the same paragraph updated to N=4.src/codegenie/depgraph/registry.py:30-38— same precedent applied a second time; mirrors verbatim.tests/unit/test_registry.py(probes) andtests/unit/depgraph/test_registry.py— the canonical test idioms (identity-tupleall()assertion,restore_default_registryautouse fixture, dual-name collision message).src/codegenie/result.py—Result[T, E]used by the manifest loader (S2-02 consumer) — referenced here only soPlugin.manifesttype aligns once S2-02 lands.src/codegenie/types/identifiers.py—PluginIdis added in S1-01; import it.src/codegenie/probes/base.py— Protocol/ABC pattern for the "contract is frozen" discipline.- Sibling validation framings:
docs/phases/02-context-gather-layers-b-g/stories/_validation/S1-02-freshness-check-registry.md,S1-08-registry-heaviness-runs-last.md,S1-10-depgraph-strategy-registry.md— the established hardening conventions for decorator-registries in this codebase.
Goal¶
Ship the Phase 3 plugin kernel: three Protocols (Plugin, Adapter, RecipeEngine) in protocols.py (Plugin's surface is fence-frozen here per ADR-0004; Adapter and RecipeEngine are shipped with the minimum surface this story needs — freezing them is deferred to S5 / S7); one PluginRegistry class + default_registry instance + register_plugin helper in registry.py; typed exceptions for collision and lookup failure carrying .name: PluginId attributes; per-test fixture isolation enforced by an autouse default_registry-snapshot guard.
Acceptance criteria¶
- [ ] AC-1 — Protocol declarations.
src/codegenie/plugins/protocols.pyexports three@runtime_checkableProtocols:Plugin: exactly four non-dunder member names —manifest(attribute, annotatedPluginManifestbehind aTYPE_CHECKINGforward reference until S2-02 lands the model),build_subgraph(self, registry: "PluginRegistry") -> "PluginSubgraph",adapters(self) -> dict[PrimitiveName, Adapter],transforms(self) -> dict[TransformKind, RecipeEngine]. Nocve_feed_parsers. No other public attributes/methods.Adapter: a single attributeprimitive: PrimitiveName. No methods declared in this story (full surface lands in S7 per ADR-0032). Surface explicitly NOT frozen — Out-of-scope names the deferral.RecipeEngine:kind: TransformKind;def applies(self, cve: "CveRecord", bundle: "Bundle") -> "Applicability";async def apply(self, plan: "RecipePlan", ctx: "ApplyContext") -> "RecipeOutcome". Bodies stubbed via...; full surface fence lands in Step 5.
- [ ] AC-2 —
PluginRegistrypublic surface.src/codegenie/plugins/registry.pyexports aPluginRegistryclass with exactly four public methods:register(self, plugin: Plugin) -> Plugin— registers and returns the plugin unchanged. Duplicateplugin.manifest.nameraisesPluginAlreadyRegistered(name).get(self, name: PluginId) -> Plugin— returns the registered plugin or raisesPluginNotRegistered(name).all(self) -> tuple[Plugin, ...]— returns plugins in registration order, as an immutable tuple.resolve(self, scope: "PluginScope") -> "PluginResolution"— raisesNotImplementedError;"S2-04"MUST appear instr(exc)(this is the forward-reference contract S2-04's executor will grep on).
- [ ] AC-3 —
register_pluginhelper.register_plugin(plugin: Plugin, *, registry: PluginRegistry | None = None) -> Plugin:- Returns the same
plugininstance (assert register_plugin(p, registry=r) is p). - With
registry=None, mutatesdefault_registry; withregistry=<instance>, mutates that instance. (Equivalent to(registry or default_registry).register(plugin).) - Used as a function call from each plugin's
api.py(e.g.,PLUGIN = register_plugin(MyPlugin())); NOT as a class decorator (plugins are instances, not classes — see Notes §3).
- Returns the same
- [ ] AC-4 — Typed exception payloads.
src/codegenie/plugins/errors.pydefines:PluginAlreadyRegistered(Exception)with a typedname: PluginIdattribute;__init__acceptsname: PluginId, the existing registration'smodule.qualname, and the new registration'smodule.qualname; the formatted message names both — mirrorsprobes/registry.py:154-158.PluginNotRegistered(Exception)with a typedname: PluginIdattribute; message includes the missing name.- Placeholders
PluginExtendsCycle(Exception)andPluginRejected(Exception)(raised by S2-03 / S2-04; live here so the exception hierarchy is one file). - Tests assert
exc_info.value.name == PluginId("..."), NOT"..." in str(exc_info.value)alone.
- [ ] AC-5 —
all()preserves registration order. Registering three plugins with namesvulnerability-remediation--node--zeta,vulnerability-remediation--node--alpha,vulnerability-remediation--node--muin that order,registry.all()returns them as the identity tuple(zeta_plugin, alpha_plugin, mu_plugin)— NOT alphabetic, NOT a set, NOT a list. Names chosen so alphabetic ≠ insertion order. - [ ] AC-6 — Cross-test isolation (both controls). Two assertions, both required:
- Positive control: in a test using fresh
PluginRegistry()instancereg_a,register_plugin(p, registry=reg_a)thenassert reg_a.all() == (p,). - Negative control: a separate test using fresh
PluginRegistry()instancereg_b(constructed independently) assertsreg_b.all() == ()ANDp not in reg_b.all(). - An autouse session-scoped fixture in
tests/unit/plugins/conftest.pysnapshotsdefault_registry.all()at session start and re-asserts byte-identical equality at session end. Any test that omits theregistry=kwarg pollutes the default and fails the assertion (ADR-0002 §Consequences row 7). Function-scoped restoration handled by a separaterestore_default_registryfixture (mirrortests/unit/test_registry.py:20-40precedent).
- Positive control: in a test using fresh
- [ ] AC-7 —
PluginProtocol surface fence test.tests/fence/test_plugin_protocol_frozen.pyasserts ALL of:{n for n in dir(Plugin) if not n.startswith("_")} == {"manifest", "build_subgraph", "adapters", "transforms"}(exact-set equality)."manifest" in Plugin.__annotations__.inspect.isfunction(Plugin.build_subgraph)(likewise foradapters,transforms).- Test does NOT use
Plugin.__abstractmethods__—Protocol(especially@runtime_checkable) populates that differently than ABCs; relying on it would either fail outright or pass trivially.
- [ ] AC-8 — Runtime-checkable smoke. A test asserts
isinstance(make_fake_plugin(name="example--noop--*"), Plugin) is TrueANDisinstance(object(), Plugin) is False. Sufficient evidence the@runtime_checkabledecoration is in place; downstream tests / fixtures rely on this. - [ ] AC-9 —
PluginResolutionplaceholder.src/codegenie/plugins/resolution.pyships with a single declaration:class PluginResolution: ...(or equivalent type alias placeholder). A one-line module docstring states "Placeholder; S2-04 expands toConcreteResolution | UniversalFallbackResolutionsum type."mypy --strictresolves theresolve()return annotation against this symbol. - [ ] AC-10 — Default-singleton smoke. A test calls
register_plugin(plugin)with NOregistry=kwarg and assertsplugin in default_registry.all(). Cleanup is guaranteed via a function-scoped autouserestore_default_registryfixture intests/unit/plugins/conftest.py(snapshot pre-test, restore post-test). This exercises theregistry or default_registryfallback that AC-3 names; without this test, the singleton branch is dead-code-covered. - [ ] AC-11 — Lint / type clean.
ruff check,ruff format --check,mypy --strictclean onsrc/codegenie/plugins/{__init__,protocols,registry,resolution,errors}.py,tests/unit/plugins/{__init__,conftest,test_registry,test_protocols}.py,tests/fence/{__init__,test_plugin_protocol_frozen}.py, andtests/fixtures/plugins/{__init__,fake_plugin}.py.
Implementation outline¶
- Test fixture spec (write first — every other test depends on it). Create
tests/fixtures/plugins/__init__.py(empty) andtests/fixtures/plugins/fake_plugin.py:Thefrom __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING from codegenie.types.identifiers import PluginId if TYPE_CHECKING: from codegenie.plugins.protocols import Adapter, Plugin, RecipeEngine from codegenie.plugins.registry import PluginRegistry @dataclass(frozen=True) class _FakeManifest: name: PluginId @dataclass(frozen=True) class _FakePlugin: manifest: _FakeManifest def build_subgraph(self, registry: "PluginRegistry") -> object: raise NotImplementedError("test fake") def adapters(self) -> dict[object, "Adapter"]: return {} def transforms(self) -> dict[object, "RecipeEngine"]: return {} def make_fake_plugin(*, name: str) -> "Plugin": # Single PluginId(str) boundary lift lives here. Callers pass raw str # for convenience; PluginId discipline (ADR-0010) preserved. return _FakePlugin(manifest=_FakeManifest(name=PluginId(name))) # type: ignore[return-value]# type: ignore[return-value]is acceptable here because the fixture is a test-time stand-in for the not-yet-shippedPluginManifestPydantic model (S2-02). When S2-02 lands, this fixture upgrades to satisfy the fullPluginManifestshape and the ignore can come off. - Create
src/codegenie/plugins/__init__.py(empty, namespace marker) andsrc/codegenie/plugins/resolution.py(placeholder per AC-9). Document the placeholder is a forward-reference target only. - Create
src/codegenie/plugins/errors.pyper AC-4.PluginAlreadyRegistered.__init__takes(name: PluginId, existing: str, duplicate: str)and storesself.name = name; message:f"duplicate plugin name {name!r}: {existing} and {duplicate}".PluginNotRegistered.__init__takes(name: PluginId); storesself.name = name. - Create
src/codegenie/plugins/protocols.py:- All three Protocols
@runtime_checkable(perf cost is paid at test-fixture time, not the hot path; this is the same trade-offruntime_checkableProtocols carry across the codebase). - Use
from __future__ import annotations; forward-refPluginManifest,PluginScope,PluginResolution,Adapter,RecipeEngine,PluginSubgraph,CveRecord,Bundle,Applicability,RecipePlan,ApplyContext,RecipeOutcomeunderTYPE_CHECKING. Plugin.manifest: PluginManifestis the attribute; the other three are method signatures. Resist the urge to add any fifth member — AC-7's fence test will fail loud if you do.
- All three Protocols
- Create
src/codegenie/plugins/registry.py:- Module docstring mirrors
src/codegenie/depgraph/registry.py:30-38verbatim, updated to "this is the 4th registry of the decorator-registry family" (see Notes §6). The opportunity is recorded; the extract is NOT done here. class PluginRegistry: privateself._plugins: dict[PluginId, Plugin] = {}(dict-of-PluginId→Plugin; CPython 3.7+ insertion-order preservation is the order contract).register(plugin): collision check byplugin.manifest.name in self._plugins; raisesPluginAlreadyRegistered(name, existing_qualname, duplicate_qualname)with bothmodule.qualnames formatted into the message — mirrorprobes/registry.py:154-158.get(name)/all()/resolve(scope)per AC-2.resolveraisesNotImplementedError("resolve() lands in S2-04; the universal-fallback algorithm is not yet implemented").default_registry: Final[PluginRegistry] = PluginRegistry()module-level.Finalis intentional per ADR-0002 §Consequences (tightening ofprobes/registry.py:238precedent; see Notes §2).def register_plugin(plugin: Plugin, *, registry: PluginRegistry | None = None) -> Plugin:(registry or default_registry).register(plugin); return plugin. Thin wrapper; no validation here.
- Module docstring mirrors
- Create
tests/fence/__init__.py(empty marker — S2-01 is the first story to populatetests/fence/) andtests/fence/test_plugin_protocol_frozen.pyper AC-7. - Create
tests/unit/plugins/__init__.py(empty marker). Createtests/unit/plugins/conftest.pywith TWO fixtures:plugin_registry(function-scoped): returnsPluginRegistry().restore_default_registry(function-scoped, autouse): snapshotdefault_registry._plugins.copy()pre-test, restore post-test. Used to make AC-10's default-singleton test safe under repeated runs.- Optionally: a
_default_registry_session_guard(session-scoped, autouse) that assertsdefault_registry.all() == ()at session start and session end — the load-bearing ADR-0002 §Consequences row 7 assertion (Notes §4).
- Write the red test (
test_collision_raises), watch it fail (import-error oncodegenie.plugins), implement, watch it pass. Then add the rest of the TDD plan's tests in order.
TDD plan — red / green / refactor¶
Red — failing test first¶
Test file path: tests/unit/plugins/test_registry.py
import pytest
from codegenie.plugins.errors import PluginAlreadyRegistered
from codegenie.plugins.registry import PluginRegistry, register_plugin
from codegenie.types.identifiers import PluginId
from tests.fixtures.plugins.fake_plugin import make_fake_plugin
def test_collision_raises():
"""Re-registering a plugin name into the same registry is an exit-4
failure mode (ADR-0002 §Consequences). The kernel must refuse, loudly —
AND name both colliding registrations' module.qualname in the message
(mirrors probes/registry.py:154-158)."""
registry = PluginRegistry()
plugin = make_fake_plugin(name="vulnerability-remediation--node--npm")
register_plugin(plugin, registry=registry)
duplicate = make_fake_plugin(name="vulnerability-remediation--node--npm")
with pytest.raises(PluginAlreadyRegistered) as exc_info:
register_plugin(duplicate, registry=registry)
assert exc_info.value.name == PluginId("vulnerability-remediation--node--npm")
# Both colliding qualified names appear in the message:
assert "_FakePlugin" in str(exc_info.value) # appears twice — existing + duplicate
assert str(exc_info.value).count("_FakePlugin") == 2
Why it fails: codegenie.plugins doesn't exist yet — ImportError on the test's first line.
Green — minimal pass¶
- Create the package,
errors.py,protocols.py,registry.py,resolution.py. PluginRegistry.registerchecksif plugin.manifest.name in self._plugins: raise PluginAlreadyRegistered(...).register_plugin(plugin, *, registry=None)delegates to(registry or default_registry).register(plugin)and returnsplugin.
Resist the urge to wire resolve here — raise NotImplementedError("resolve() lands in S2-04; ...") is correct.
Required follow-on tests (one per AC; pin each with the named identity)¶
def test_all_returns_registration_order(plugin_registry: PluginRegistry) -> None:
"""AC-5 — identity tuple ordering (catches return (), set, sort mutants)."""
a = make_fake_plugin(name="vulnerability-remediation--node--zeta")
b = make_fake_plugin(name="vulnerability-remediation--node--alpha")
c = make_fake_plugin(name="vulnerability-remediation--node--mu")
register_plugin(a, registry=plugin_registry)
register_plugin(b, registry=plugin_registry)
register_plugin(c, registry=plugin_registry)
assert plugin_registry.all() == (a, b, c)
def test_register_plugin_returns_plugin_unchanged(plugin_registry: PluginRegistry) -> None:
"""AC-3 — return identity (catches `return None` mutant)."""
p = make_fake_plugin(name="vulnerability-remediation--node--npm")
assert register_plugin(p, registry=plugin_registry) is p
def test_register_plugin_default_singleton_path(restore_default_registry: None) -> None:
"""AC-10 — `register_plugin(plugin)` with no kwarg mutates default_registry.
The restore_default_registry autouse fixture handles cleanup."""
from codegenie.plugins.registry import default_registry
p = make_fake_plugin(name="vulnerability-remediation--node--npm")
register_plugin(p) # no registry= kwarg
assert p in default_registry.all()
def test_get_returns_registered_plugin(plugin_registry: PluginRegistry) -> None:
"""AC-2 — get round-trip."""
p = make_fake_plugin(name="vulnerability-remediation--node--npm")
register_plugin(p, registry=plugin_registry)
assert plugin_registry.get(PluginId("vulnerability-remediation--node--npm")) is p
def test_get_unknown_raises_plugin_not_registered_with_typed_name(
plugin_registry: PluginRegistry,
) -> None:
"""AC-4 — typed exception payload, not just stringified message."""
from codegenie.plugins.errors import PluginNotRegistered
with pytest.raises(PluginNotRegistered) as exc_info:
plugin_registry.get(PluginId("vulnerability-remediation--node--npm"))
assert exc_info.value.name == PluginId("vulnerability-remediation--node--npm")
def test_resolve_stub_names_s2_04(plugin_registry: PluginRegistry) -> None:
"""AC-2 — forward-reference contract; S2-04 will grep for this substring."""
with pytest.raises(NotImplementedError, match="S2-04"):
plugin_registry.resolve(scope=None) # type: ignore[arg-type]
def test_runtime_checkable_protocols_match_fakes() -> None:
"""AC-8 — @runtime_checkable smoke; downstream fixtures rely on isinstance."""
from codegenie.plugins.protocols import Plugin
p = make_fake_plugin(name="example--noop--*")
assert isinstance(p, Plugin) is True
assert isinstance(object(), Plugin) is False
def test_fresh_registries_are_isolated() -> None:
"""AC-6 — both positive AND negative control (catches `all() == ()`-always mutant)."""
reg_a = PluginRegistry()
reg_b = PluginRegistry()
p = make_fake_plugin(name="vulnerability-remediation--node--npm")
register_plugin(p, registry=reg_a)
assert reg_a.all() == (p,) # positive — A has it
assert reg_b.all() == () # negative — fresh B is empty
assert p not in reg_b.all() # belt and suspenders
Fence test (tests/fence/test_plugin_protocol_frozen.py) per AC-7:
import inspect
from codegenie.plugins.protocols import Plugin
def test_plugin_protocol_has_exactly_four_members() -> None:
"""ADR-0004 §Consequences: Plugin Protocol surface is exactly four members.
Adding a fifth or removing one fails this assertion. Do NOT rely on
Plugin.__abstractmethods__ — Protocol does not populate it like ABCs do."""
members = {n for n in dir(Plugin) if not n.startswith("_")}
assert members == {"manifest", "build_subgraph", "adapters", "transforms"}, (
f"Plugin Protocol surface drifted from ADR-0004 freeze: {members}"
)
assert "manifest" in Plugin.__annotations__
assert inspect.isfunction(Plugin.build_subgraph)
assert inspect.isfunction(Plugin.adapters)
assert inspect.isfunction(Plugin.transforms)
Refactor¶
- Module docstrings:
protocols.pycites ADR-0004's four-member freeze and names the fence test;registry.pymirrorsdepgraph/registry.py:30-38updated to N=4 (Notes §6);errors.pydocuments the structured.nameattribute as the loader-S2-03 contract. @runtime_checkabledecorators on all three Protocols; thePlugindecorator carries an inline# AC-8 — see test_runtime_checkable_protocols_match_fakescomment so reviewers can grep.PluginRegistryis__slots__-able but keep it a plain class unlessmypy --strictcomplains — precedentprobes/registry.pydoesn't use__slots__(Notes §8).from __future__ import annotationseverywhere in the new modules +TYPE_CHECKINGimports for cross-S1- / cross-S2- forward references.
Optional property test (only if executor has budget)¶
hypothesis is a dev dep. The strongest hardening of AC-5's order contract is a property test that generates N≥2 distinct plugin names, registers them, asserts reg.all() returns the identical tuple. Three sibling registries skipped this; AC-5's three-element identity-tuple test catches the dominant mutants. Add only if cheap.
Files to touch¶
| Path | Why |
|---|---|
src/codegenie/plugins/__init__.py |
Namespace marker (empty). |
src/codegenie/plugins/errors.py |
PluginAlreadyRegistered, PluginNotRegistered with typed .name: PluginId; placeholders PluginExtendsCycle / PluginRejected. |
src/codegenie/plugins/protocols.py |
Three @runtime_checkable Protocols; four-member Plugin surface (ADR-0004). |
src/codegenie/plugins/registry.py |
PluginRegistry, default_registry: Final[PluginRegistry], register_plugin helper. Module docstring carries 4th-registry rule-of-three observation (Notes §6). |
src/codegenie/plugins/resolution.py |
Placeholder class PluginResolution: ... so the resolve() return annotation resolves under mypy --strict today. S2-04 expands. |
tests/fixtures/plugins/__init__.py |
Test fixture package marker. |
tests/fixtures/plugins/fake_plugin.py |
make_fake_plugin(name=str) helper; single PluginId(str) boundary lift. |
tests/unit/plugins/__init__.py |
Test package marker. |
tests/unit/plugins/test_registry.py |
TDD red test + named follow-on tests (one per AC). |
tests/unit/plugins/conftest.py |
plugin_registry fresh-instance fixture + restore_default_registry autouse fixture + optional session-scoped default_registry-snapshot guard. |
tests/fence/__init__.py |
First population of tests/fence/ — empty marker. |
tests/fence/test_plugin_protocol_frozen.py |
Four-member Plugin surface assertion (ADR-0004); explicit non-use of __abstractmethods__. |
Out of scope¶
PluginManifestPydantic model — handled by S2-02. This story uses a minimal forward-referenced placeholder soPlugin.manifestis type-shaped. Themake_fake_pluginfixture uses a_FakeManifestfrozen dataclass with aname: PluginIdfield; S2-02 will upgrade the fixture to satisfy the full model.AdapterProtocol surface freeze — only theprimitive: PrimitiveNameattribute ships here. Methods land in S7 with the first concreteAdapterimplementation (ADR-0032 language-search adapters). No fence test forAdapterin this story — by intent, the surface is not yet stable enough to freeze.RecipeEngineProtocol surface freeze — the method signaturesapplies/applyship here for Step 5 consumers, but the surface is not fence-frozen. Step 5 owns the freeze.PluginResolutionsum-type expansion —src/codegenie/plugins/resolution.pyships a placeholder; S2-04 expands toConcreteResolution | UniversalFallbackResolutionper ADR-0003.- Filesystem walk /
PLUGINS.lockintegrity check — handled by S2-03. - Resolution algorithm (specificity, precedence, extends walk, universal fallback) — handled by S2-04.
resolve()raisesNotImplementedError("S2-04")here on purpose. - TCCM composition /
provides/requires— Step 3. @register_signal_kind,@register_recipe— different kernels; Steps 5 / 6.- Concurrent registration from multiple threads / coroutines — registration is import-time and single-threaded by construction (mirrors
probes/registry.py). No threading lock; no async semantics. If a future story needs it, an ADR amendment is the path. - Module-reload semantics —
importlib.reload()-ing a plugin module that callsregister_pluginMUST raisePluginAlreadyRegistered(the desired behavior — reload is a developer-only operation and a duplicate registration is correctly an error). No special-casing. - Kernel-extract across the four registries —
probes/registry.py,indices/registry.py,depgraph/registry.py, and thisplugins/registry.pyall implement related-but-divergent dispatch. The shared surface (register/get/all/ typed-collision-error) is a kernel-extract candidate (rule-of-three threshold crossed at N=3, now N=4). Still deferred (resolve()machinery dominates LOC; Rule 2 — three similar lines is better than premature abstraction). See Notes §6.
Notes for the implementer¶
§1 — Kernel must stay tiny¶
ADR-0002 explicitly carries the toolkit's "Stay that simple. … no eager validation, no side effects at registration." Resist the urge to add logging, metrics, or import-time validation here. Validation belongs in resolve() (S2-04) and the loader (S2-03). The register() method does ONE thing: collision-check then append. The register_plugin() helper does ONE thing: pick the registry then delegate then return.
§2 — Mirror probes/registry.py deliberately¶
Same docstring shape, same default_registry placement, same fresh-instance fixture story, same dual-name collision message (line 154-158 of the precedent). Reviewers shouldn't have to context-switch between two registry idioms.
default_registry: Final[PluginRegistry] = PluginRegistry() is intentionally tighter than the precedent (probes/registry.py:238's default_registry = Registry()). ADR-0002 §Consequences row 2 names Final as the intended posture — production code uses the singleton; replacement requires explicit DI through register_plugin(..., registry=...). The precedent is loose; this story tightens it.
§3 — @register_plugin is a function call, NOT a class decorator¶
Three existing registries use a class-decorator shape (@register_probe, @register_index_freshness_check(name), @register_dep_graph_strategy(eco)). This story uses register_plugin(plugin, *, registry=None) — a function call from each plugin's api.py (PLUGIN = register_plugin(MyPlugin())).
Why the asymmetry is intentional, not oversight: plugins are instances that carry a manifest + composed state, not classes / functions. A class decorator would need to instantiate the class at module-import time with no arguments, breaking the manifest-carrying contract. ADR-0002 §Decision pins this shape. Do NOT add a @register_plugin dual-shape wrapper to chase symmetry — the asymmetry is a feature.
Return-type divergence from arch: phase-arch-design.md §Component design C2 line 466 declares register(self, plugin: Plugin) -> None and line 469 all(self) -> list[Plugin]. This story tightens to -> Plugin (per probes/registry.py:139 precedent and to make register_plugin's return plugin natural) and -> tuple[Plugin, ...] (per probes/registry.py:189 precedent + immutability convention). File a follow-up cleanup to update arch C2.
§4 — Cross-test isolation mechanism¶
The load-bearing assertion ADR-0002 §Consequences row 7 names is: after the unit-tests run, default_registry.all() == (). Implement as TWO fixtures in tests/unit/plugins/conftest.py:
- Function-scoped autouse
restore_default_registry: snapshotdefault_registry._plugins.copy()pre-test; restore post-test. Mirrortests/unit/test_registry.py:20-40. Needed because AC-10's test deliberately callsregister_plugin(plugin)without a registry kwarg. - Session-scoped autouse
_default_registry_session_guard: capturedefault_registry.all()at session start and re-assert byte-identical equality at session end. This catches any test that escaped the function-scoped restore (e.g., crashed mid-cleanup).
Do NOT add an unregister_for_tests() method to PluginRegistry. The sibling indices/registry.py:198-208 ships that explicitly because production code registers into default_registry; in this phase, the first production registration is S2-03's loader, not anything in S2-01's scope. When S2-03 lands, reconsider — but not here.
§5 — PluginId newtype boundary¶
The single boundary lift from str to PluginId lives inside make_fake_plugin. Tests pass name="..." (raw str for convenience); the fixture wraps with PluginId(name) exactly once. Production code (PluginManifest.from_yaml, S2-02) will do its own typed parse. Never construct a Plugin whose manifest.name is a raw str — that's an ADR-0010 violation any reviewer will catch.
§6 — Rule-of-three observation: NOW four registries¶
This is the 4th decorator-registry in the codebase:
src/codegenie/probes/registry.py(Phase 0 + Phase 2 ADR-0003 amendments) —for_taskfilter + LRU +sorted_for_dispatch(heaviness, runs_last).src/codegenie/indices/registry.py(Phase 2 S1-02) — total dispatch viadispatch_all.src/codegenie/depgraph/registry.py(Phase 2 S1-10) — single dispatch +has_strategyquery.src/codegenie/plugins/registry.py(this story) —register/get/all+resolve(scope)+extends-walk (in S2-04).
Both indices/registry.py:26-31 and depgraph/registry.py:30-38 explicitly document the rule-of-three threshold and defer the kernel-extract because dispatch shapes diverge. The deferral still holds at N=4 — resolve()'s specificity / precedence / extends-walk logic dominates this kernel's LOC; the shared surface (register / get / all / typed-collision-error) is a small fraction. Pure-Rule-2 application.
Mirror the deferral pattern. Add a module-docstring paragraph to plugins/registry.py that:
- Names all four precedents and their dispatch divergences.
- Pins the extract trigger: "lift a shared KernelRegistry[K, V] base when N=5 OR when a new registry needs only the common surface (register / get / all / typed-collision-error)."
- Cites this story file as the audit anchor.
The paragraph IS the deferral — without it, the next registry's author won't see the prior three's reasoning and will either (a) re-derive the extract argument from scratch or (b) silently re-implement a 5th copy.
§7 — Step 2 done-criteria is the union of S2-01..S2-04¶
High-level-impl.md §Step 2 done-criteria (lines 64-70) names: resolver tests (extends cycle, depth 4 / 5), loader tests, PLUGINS.lock mismatch, property-tested resolution totality, fresh-fixture isolation, mypy --strict clean. S2-01 owns ONLY: register / get / all / collision / cross-test isolation / Plugin fence test / mypy --strict for the kernel modules. Resolver and loader done-criteria belong to S2-03 / S2-04. A reader who treats Step 2 done-criteria as a single-story checklist will think this story is under-scoped; it isn't.
§8 — Deliberately not adopted¶
These are correct YAGNI applications, listed so future PRs don't second-guess:
__slots__onPluginRegistry— not adopted; precedentprobes/registry.pydoesn't use it. Hot-path cost is zero (registration is import-time); footprint cost is one entry per registry per process. Add only if profiling shows a real cost.- Dual-shape decorator for
register_plugin— not adopted; rationale in §3 above. - DI container (
punq,dependency-injector) — explicitly rejected inphase-arch-design.md §Patterns considered and deliberately rejected. Plugin loading is one filesystem walk +importlib.import_module; a DI container is 5× the indirection for zero capability. - Module-level mutable
_REGISTRY: dict = {}— the toolkit's "side effects at module import time" anti-pattern. ADR-0002 explicitly rejects this in favor of an instance-with-default_registryshape.
The fence test (AC-7) is the structural enforcement ADR-0004 §Consequences names. Use the exact-set-equality + __annotations__ shape from the TDD plan — be explicit; reviewers will read this test as the canonical "the kernel is closed" assertion.
Cross-test pollution (default_registry leaking) is the exact failure mode ADR-0002 §Tradeoffs warns about. The cross-test assertion is not optional belt-and-suspenders; it's an ADR consequence.