Validation report — S2-04 — Plugin resolver: (specificity, precedence, name) ordering + extends walker + UniversalFallbackResolution¶
Validated: 2026-05-18
Validator: phase-story-validator skill (autonomous run via story-validation-corrector scheduled task)
Verdict: HARDENED
Story file: docs/phases/03-vuln-deterministic-recipe/stories/S2-04-plugin-resolver-extends.md
Context brief¶
S2-04 is Step 2's payoff: it lands the ADR-0003 resolution algorithm that maps a PluginScope (orchestrator intent from repo-context.yaml) to a typed PluginResolution = ConcreteResolution | UniversalFallbackResolution. The story replaces the NotImplementedError("S2-04 …") stub S2-01 shipped on PluginRegistry.resolve, walks extends chains with cycle + depth-4 caps, composes TCCM and adapters left-to-right (later-wins), and proves totality via Hypothesis.
Critical context surfaced during validation:
- The
ManifestScope → PluginScopelift is THIS story's responsibility. Per_validation/S2-02-plugin-manifest-pydantic.md §Arch amendments, S2-02 shipsPluginManifest.scope: ManifestScope(rawstr | list[str]per dim — the production-ADR-0031 canonical YAML shape), NOT the post-liftPluginScope. Phase-arch §Data model line 755 incorrectly typed it asPluginScope; that's the arch follow-up still pending. S2-04 is the load-bearing lift point, and the lift is fan-out via cross-product (a manifest withlanguages: [node, python]produces twoPluginScopecandidates). The original story calledplugin.scope.matches(scope)directly — wrong on three counts: (a)plugin.scopedoesn't exist (plugin.manifest.scopedoes), (b) it's aManifestScope, not aPluginScope, (c) the call ignores list fan-out entirely. - S1-02's
PluginScope.matchessignature is keyword-only(*, task, language, build)withstrparams, NOTmatches(other: PluginScope). Per_validation/S1-02-plugin-scope-sum-type.md §K-F3, the kernel stays task-class-agnostic andmatchestakes raw strings. Story originally used the wrong signature. - Arch §Data model line 779 names
ConcreteResolution.matched_scope: PluginScope— the lifted scope that filtered through. Original story omitted this field from the AC; the property test invariantresolution.matched_scope.matches(...)depends on it. UNIVERSAL_FALLBACK_IDsentinel discipline. ADR-0003 §Decision step 3: "if the head plugin's id isuniversal--*--*". The string"universal--*--*"will appear in: (1) the resolver's narrowing check, (2)S7-03's real fallback plugin manifest, (3)make_universal_fallback()test fixture, (4) the loader's startupdefault_registry.get(...)check (ADR-0003 §Consequences). The original story inlined the literal; hardened to a module-levelFinal[PluginId]constant with an AST single-source-of-truth scan.candidates_consideredsemantics. ADR-0003 §Consequences row 5: "concrete plugins were filtered out — operator can see which concrete plugins were filtered out and why." Original story said "filtered-but-not-chosen concrete plugins" with two contradictory interpretations: (a) tail of the sorted matches list, (b) all concrete plugins in the registry whose lifted scopes did NOT match the incoming scope. Per ADR-0003 §Consequences row 5 and the intended debug surface, interpretation (b) is correct — and is now pinned in AC-10.
Load-bearing siblings + precedents:
- _validation/S2-02-plugin-manifest-pydantic.md — ManifestScope shape; arch follow-up that S2-04 produces a ResolvedManifest whose scope: PluginScope is the lifted form.
- _validation/S2-03-plugin-loader-integrity.md — tagged-union sum-type for errors (PluginRejected seven-variant); AST-source-scan fences for chokepoint discipline; Strategy seam via Protocol (PluginVerifier). This story's hardening reuses the AST-scan pattern (single-source-of-truth for UNIVERSAL_FALLBACK_ID; absence of NotImplementedError).
- _validation/S2-01-plugin-registry-kernel.md — register -> Plugin return type; _visited discipline foreshadowed; fresh-instance fixture for test isolation.
- _validation/S1-02-plugin-scope-sum-type.md — matches(*, task, language, build) -> bool signature; __str__ round-trip; AST module-purity scan precedent; Hypothesis strategies for scope_dims() (reuse for resolver property strategy).
- _validation/S1-03-tagged-union-outcomes.md — discriminator-on-kind; assert_never AST exhaustiveness scan precedent; EscalationReason literals including "plugin_extends_cycle".
- ADR-0003 line 18 (Option C): "the kernel has no if plugin.id == 'universal--*--*': branch outside the resolver" — informs the UNIVERSAL_FALLBACK_ID single-source-of-truth scan.
- ADR-0031 (production) §Inheritance and override: "later wins on collision" for extends chain composition — the test pin in AC-15 #6.
Original story strengths (preserved):
- Correctly cited ADR-0003 / ADR-0002 / ADR-0010 / production ADR-0031 / production ADR-0009 across References, Notes, and the Implementation outline.
- Goal correctly identified the four load-bearing commitments: tagged-union return, universal-as-registered-plugin,
extendswalker with cycle+depth check, Hypothesis totality property. - Out-of-scope cleanly separated S3-01 (real TCCM), S7-02 (real adapters), S7-03 (universal HITL subgraph), S6-01 (event emission), S5-01 (per-plugin RecipeRegistry).
- Honest framing: cycle is a startup-time concern, not a per-resolve concern (preserved in Notes §"Property test is the headline assertion").
- Notes correctly warned "do not parameterize the universal name" (now elevated to a typed Final constant + AST scan).
- Implementation outline §2's
_visited=None → fresh; depth is len(_visited)was conceptually right but the mutable-default antipattern made it brittle (hardened tofrozenset[PluginId] | tuple[PluginId, ...]thread-through).
Original story weaknesses (resolved):
plugin.scopeshape wrong (Consistency C-F1,block). Per S2-02 hardened,plugin.manifest.scopeis aManifestScope(rawstr | list[str]), not aPluginScope. Story bypassed the lift entirely.matchessignature wrong (Consistency C-F2,block). S1-02 shipsmatches(*, task: str, language: str, build: str) -> bool(keyword-only,strparams); story usedplugin.scope.matches(scope)passing aPluginScope.- List fan-out missing (Consistency C-F3 / Coverage Cov-F1,
block).ManifestScope.languages: str | list[str]allows multi-value; story silently ignored. A plugin withlanguages: [node, python]must produce twoPluginScopecandidates. matched_scope: PluginScopemissing fromConcreteResolutionAC (Coverage Cov-F2 / Consistency C-F4,block). Arch §Data model line 779 names it; property test invariant depends on it.candidates_consideredsemantics ambiguous (Coverage Cov-F3 / Consistency C-F5,block). Original "filtered-but-not-chosen concrete plugins" admits two readings; ADR-0003 §Consequences row 5 disambiguates to "in registry but didn't match the incoming scope".- Magic-string
"universal--*--*"(Design-Patterns DP-F1,block). Inlined in the algorithm; should be a typedFinal[PluginId]sentinel. Compounds with themake_universal_fallbackfixture (also references the same string) and with S7-03 (real plugin manifest). - Only one red test for ten distinct AC paths (Test-Quality TQ-F1,
block). S2-03 precedent (AC-7 parametrized) was clearly the right pattern; story had one test. - No mutation-resistance for left-to-right
extendsmerge (Test-Quality TQ-F2,block). Refactor §3 mentioned "later wins on collision" but no concrete test would catch a right-to-left mutant. - No "only universal registered" test (Coverage Cov-F4,
block). Edge case: empty concrete set, universal alone. Per the algorithm, sort produces a length-1 list with universal as head → narrows toUniversalFallbackResolution. Original story did not pin this. - No "extends target not registered" test (Coverage Cov-F5,
harden).A extends BwhereBis not registered; what raises? Hardened:PluginNotRegisteredpropagates fromregistry.get; AC pins the propagation. _visited=Nonemutable-default antipattern (Design-Patterns DP-F2,harden). Recursion threads a private parameter; better to usefrozenset+tupleaccumulator for the path.PluginResolutionplaceholder module location ambiguous (Consistency C-F6,harden). S2-01 shippedsrc/codegenie/plugins/resolution.pywithclass PluginResolution: ...placeholder. Story did not pin where the real definitions land. Hardened:resolver.pyis the source-of-truth;resolution.pybecomes a one-line re-export.PluginRegistryCorruptedconfusion (Consistency C-F7,harden). ADR-0003 §Consequences line 50 names a spanning event; phase-arch §C2 line 483 lists the exception family. Story conflated them; hardened to: this story raises the typed exception; S6-01 maps to the event.- Sort key as a free function or method? (Design-Patterns DP-F3,
harden). Story's_sort_key(plugin) -> tuple[int, int, str]ignored the lifted scope (which carries the relevantspecificity). Hardened:_sort_key(c: ScopedCandidate)— scope-on-the-candidate is the right shape. - Magic-number
4cap (Design-Patterns DP-F5,harden). Hardcoded depth cap; mutation-target. Hardened to_MAX_EXTENDS_DEPTH: Final[int] = 4with AST scan asserting the literal4appears at most once. assert_neverexhaustiveness AST scan not pinned (Test-Quality TQ-F3,harden). S1-02 / S1-03 precedent uses AST AC; story only mentioned in Refactor.- Module purity AST scan not pinned (Test-Quality TQ-F4,
harden). S1-02 AC-21 / S2-03 precedent. Resolver is kernel code; hardened to AC-13 with explicit allowlist. extends_chainordering ambiguity (Coverage Cov-F6,harden). Original AC said "leaf ispluginitself" but didn't pin the ordering of the rest (root→leaf or extends-order). Hardened: extends walked first applies first;extends_chain[-1] is plugin; test pins via tuple equality.- Hypothesis strategy underspecified (Test-Quality TQ-F5,
harden). Original Notes warned "strategy must not generateuniversal--*--*as concrete" but no AC pinned it. Hardened: AC-16 explicitly names the.filter()+assume()and themax_examples=200; deadline=Nonesettings.
Stage 2 — Four critic reports (single-agent synthesis)¶
Following Rule 6 (token-budget discipline), the four critics were synthesized into a single analysis pass rather than spawning four parallel agents. The framing remained four-lens. Severity tags: block (story must change), harden (story improved by change), nit (cosmetic).
Coverage critic — 6 findings¶
| ID | Severity | Title | Resolution |
|---|---|---|---|
| Cov-F1 | block | ManifestScope → PluginScope lift missing — story calls plugin.scope.matches(...) against a type that doesn't exist |
Applied — AC-7 (lift_manifest_scope with 4-case parametrized fan-out) + AC-8 (_lift_candidates flat-map) + Notes §"Why a Final[PluginId] sentinel". List fan-out is the cross-product. |
| Cov-F2 | block | ConcreteResolution.matched_scope: PluginScope missing from AC |
Applied — AC-4 lists matched_scope: PluginScope explicitly; AC-16 property invariant depends on it. |
| Cov-F3 | block | candidates_considered semantics ambiguous |
Applied — AC-10 pins to "alphabetized tuple of concrete plugin names in registry that did NOT match the incoming scope, excluding UNIVERSAL_FALLBACK_ID". AC-15 #13 test exercises ordering + exclusion. |
| Cov-F4 | block | "Only universal registered" edge not tested | Applied — AC-15 #10 test_only_universal_registered_returns_universal_fallback; AC-9 step 3 explicitly returns fallback when matches is empty AND universal is in registry. |
| Cov-F5 | harden | "Extends target not registered" behavior not pinned | Applied — AC-15 #12 test_extends_missing_target_raises_plugin_not_registered; Notes §"Why PluginNotRegistered propagates" documents the rationale (loader pre-check is the right place; resolver propagates). |
| Cov-F6 | harden | extends_chain ordering ambiguous (root→leaf vs extends-order vs leaf→root) |
Applied — AC-15 #7 asserts extends_chain[-1] is plugin; Notes §"Left-to-right extends merge" articulates the chain order (extends[0] first, ..., plugin last); mutation kill-list M17 catches reversal. |
Test-Quality critic — 5 findings¶
| ID | Severity | Title | Resolution |
|---|---|---|---|
| TQ-F1 | block | One red test for ten distinct AC paths | Applied — AC-15 enumerates 13 concrete named tests; TDD plan "Green follow-on" lists each by name (mirrors S2-03 hardened precedent). |
| TQ-F2 | block | No mutation-resistance for left-to-right extends merge |
Applied — AC-15 #6 test_extends_chain_composes_tccm_and_adapters_left_to_right pins both composed_adapters (single-level later-wins on Foo; Bar added) AND composed_tccm.provides (one-level-deep per-key later-wins). Mutation M5 catches right-to-left. |
| TQ-F3 | harden | assert_never exhaustiveness AST scan not pinned |
Applied — AC-14 introduces tests/unit/plugins/test_resolver_exhaustiveness.py with AST scan + a _dispatch_example mypy-typechecked helper. S1-02 AC-14 precedent. |
| TQ-F4 | harden | Module purity AST scan not pinned | Applied — AC-13 introduces tests/unit/plugins/test_resolver_purity.py with an explicit allowlist (no os / pathlib / logging). S1-02 AC-21 precedent. |
| TQ-F5 | harden | Hypothesis strategy underspecified | Applied — AC-16 names concrete_plugin_name() strategy with .filter() + assume() to forbid UNIVERSAL_FALLBACK_ID; @settings(max_examples=200, deadline=None); exhaustive match over PluginResolution at the assertion site (so adding a future variant breaks the property's assert_never). |
Consistency critic — 7 findings¶
| ID | Severity | Title | Resolution |
|---|---|---|---|
| C-F1 | block | plugin.scope doesn't exist; plugin.manifest.scope: ManifestScope does (S2-02 hardened) |
Applied — AC-7 + AC-8 + AC-9 step 2 reference plugin.manifest.scope and route through lift_manifest_scope. Validation notes header documents the lift seam. |
| C-F2 | block | matches(*, task, language, build) -> bool keyword-only (S1-02 hardened) |
Applied — AC-9 step 2 + Notes §"Incoming scope discipline" pin the keyword call form; _unpack(ScopeDim) -> str converts the incoming PluginScope dims for the call. |
| C-F3 | block | ManifestScope list fan-out (S2-02 AC-3) |
Applied — AC-7 fan-out is the cross-product over the three dims; AC-15 #5 parametrized table pins 4 specific cases. |
| C-F4 | block | ConcreteResolution.matched_scope: PluginScope (arch §Data model line 779) |
Applied — see Coverage Cov-F2. |
| C-F5 | block | candidates_considered semantics (ADR-0003 §Consequences row 5) |
Applied — see Coverage Cov-F3. |
| C-F6 | harden | PluginResolution module location undeclared |
Applied — Notes §"Module placement" pins resolver.py as source-of-truth; resolution.py becomes a one-line re-export to preserve the S2-01-shipped import path. |
| C-F7 | harden | PluginRegistryCorrupted exception vs event conflated |
Applied — AC-9 step 4 raises the typed exception; Out-of-scope expanded with "spanning-event emission deferred to S6-01"; Notes §"Module placement" cross-references. |
Design-Patterns critic — 6 findings¶
| ID | Severity | Title | Resolution |
|---|---|---|---|
| DP-F1 | block | Magic-string "universal--*--*" inlined |
Applied — AC-2 introduces UNIVERSAL_FALLBACK_ID: Final[PluginId]; AST single-source-of-truth scan (tests/static/test_universal_fallback_id_single_source.py) asserts at most two occurrences in the codebase (resolver + fixture). Compounds with S7-03's future real-plugin manifest reference. Notes §"Why a Final[PluginId] sentinel, not a config knob" preserves the no-config-knob discipline. |
| DP-F2 | harden | _visited=None mutable-default antipattern |
Applied — compose_extends_chain threads visited: frozenset[PluginId] (immutable) + visited_path: tuple[PluginId, ...] (immutable accumulator for cycle-chain reporting). AC-11 + Implementation outline §5 pin the shape. |
| DP-F3 | harden | _sort_key(plugin) shape ignored lifted scope |
Applied — _sort_key(c: ScopedCandidate) -> tuple[int, int, str] returns (-c.lifted_scope.specificity(), -c.plugin.manifest.precedence, c.plugin.manifest.name). Negation for descending; tuple naturally ascending. |
| DP-F4 | harden | Functional-core / imperative-shell tangle in resolve |
Applied — Implementation outline §4 splits pure helpers (_lift_dim, lift_manifest_scope, _lift_candidates, _unpack, _filter_matches, _sort_key, _candidates_considered) from the orchestrating resolve. Each pure helper independently mutation-target-rich. AC-13 module-purity scan enforces. |
| DP-F5 | harden | Magic-number 4 cap |
Applied — AC-17 introduces _MAX_EXTENDS_DEPTH: Final[int] = 4; AST scan asserts literal 4 appears at most once in resolver.py. Mutation kill-list M18. |
| DP-F6 | nit | ScopedCandidate typed dataclass vs raw tuple |
Applied — AC-3 introduces @dataclass(frozen=True, slots=True) class ScopedCandidate: plugin: Plugin; lifted_scope: PluginScope for naming clarity. |
Stage 3 — Researcher¶
Not invoked. No critic finding was tagged NEEDS RESEARCH. All resolutions reuse this codebase's established precedents:
_validation/S1-02-plugin-scope-sum-type.md—matches(*, task, language, build)keyword-only signature; AST module-purity AC-21; Hypothesisscope_dims()strategy form._validation/S1-03-tagged-union-outcomes.md—Annotated[..., Field(discriminator="kind")]discipline;assert_neverAST exhaustiveness;EscalationReasonliteral taxonomy that includes"plugin_extends_cycle"._validation/S2-01-plugin-registry-kernel.md— fresh-PluginRegistry()test isolation; autouserestore_default_registryfixture;register -> Pluginreturn._validation/S2-02-plugin-manifest-pydantic.md §Arch amendments—PluginManifest.scope: ManifestScope; precedence default 50; arch follow-up logged ("S2-04 produces a ResolvedManifest")._validation/S2-03-plugin-loader-integrity.md— Strategy seam via Protocol (PluginVerifier); AST source-scan fences (chokepoint discipline); tagged-union sum type for errors; parametrized per-variant tests; verify-all-then-import-all order pinned via AC.- ADR-0003 §Decision (steps 1–4) and §Consequences (row 5 —
candidates_consideredsemantics; line 50 —PluginRegistryCorruptedspanning event vs exception distinction). - Production ADR-0031 §Inheritance and override — left-to-right merge with later-wins on collision.
src/codegenie/types/identifiers.py(Phase 0 / S1-01) —PluginIdnewtype + smart constructor.
Stage 4 — Edits applied to story¶
The story file was rewritten in place. Substantive changes:
- Status raised from
ReadytoHARDENED. Validation notes block appended under header. - Depends on list corrected to include S1-02 (
PluginScope), S1-03 (tagged-union discipline), S2-02 (ManifestScopeshape — pre-lift), S2-03 (loader +PluginRejectedtaxonomy). Original story listed only S2-02 / S2-03. - Context rewritten to enumerate four load-bearing commitments instead of two — adds the
ManifestScope → PluginScopelift seam and theextendswalker as separate commitments. - References expanded to include the four sibling
_validation/reports (S1-02, S1-03, S2-01, S2-02, S2-03) and the arch line-numbers (lines 479, 486–514, 392–411, 775–791, 779). - Acceptance criteria restructured (19 ACs, each individually verifiable):
- AC-1: module surface +
__all__(alphabetized) + stowaway-export test. - AC-2:
UNIVERSAL_FALLBACK_ID: Final[PluginId]sentinel + AST single-source-of-truth scan. - AC-3:
ScopedCandidatefrozen dataclass. - AC-4:
ConcreteResolutionPydantic withmatched_scope: PluginScope(per arch §Data model line 779). - AC-5:
UniversalFallbackResolutionPydantic withcandidates_considered: tuple[PluginId, ...](immutable). - AC-6:
PluginResolutiondiscriminated alias (Annotated[..., Field(discriminator="kind")]). - AC-7:
lift_manifest_scopecross-product fan-out with 4 parametrized examples. - AC-8:
_lift_candidatesflat-map. - AC-9: 7-step resolution algorithm pinned with
_unpackdiscipline. - AC-10:
candidates_consideredsemantics pinned (alphabetized; excludes universal). - AC-11:
compose_extends_chainwithfrozenset[PluginId]+tuple[PluginId, ...]accumulator;PluginNotRegisteredpropagation; left-to-right merge. - AC-12:
PluginRegistry.resolvedelegation; AST scan forNotImplementedErrorabsence. - AC-13: module-purity AST scan with explicit allowlist.
- AC-14: exhaustiveness
match+assert_neverAST scan + mypy_dispatch_examplehelper. - AC-15: 13 named tests in dependency order.
- AC-16: Hypothesis property test with strategy specifications +
max_examples=200; deadline=None. - AC-17:
_MAX_EXTENDS_DEPTH: Final[int] = 4with AST literal-uniqueness scan. - AC-18: fixtures (
make_universal_fallback, extendedmake_fake_plugin). - AC-19: lint + mypy clean on all new files.
- Implementation outline restructured into 9 numbered steps with explicit pure-helper ordering (Implementation outline §4 lists 7 pure helpers in dependency order).
- TDD plan expanded:
- Red test rewritten to use
make_universal_fallback,PluginId,manifest_scope_kwargs, exact-tuple-equality assertion oncandidates_considered, andUNIVERSAL_FALLBACK_IDsymbol import. - Green follow-on lists every AC-15 test name + AST-scan tests + Hypothesis property.
- Refactor section preserved + extended (named helpers,
_dispatch_examplehelper,_MAX_EXTENDS_DEPTHconstant). - Mutation kill-list table (18 mutations) added — every mutation has a named catching test.
- Files to touch expanded from 7 to 12 paths:
src/codegenie/plugins/resolution.py— collapsed to one-line re-export.tests/unit/plugins/test_resolver_purity.py— module-purity AST scan.tests/unit/plugins/test_resolver_exhaustiveness.py— exhaustiveness AST scan.tests/static/test_universal_fallback_id_single_source.py— AST scan.tests/static/test_no_notimplemented_in_registry.py— AST scan.- Out of scope expanded:
- Loader-time
extendscycle / depth pre-check (deferred to S2-03's hardened loader). - Loader-time integrity check for
extendstarget presence. registry.all()empty case is defensive belt-and-braces (canonical fail-loud is in S2-03 loader).- Notes for the implementer restructured into 11 subsections covering: sentinel discipline, incoming scope semantics,
assert_neverdiscipline, depth-cap basis, cycle chain shape, left-to-right merge gotcha,PluginNotRegisteredpropagation rationale, TCCM substitution point, module placement decision, Hypothesis strategy discipline, mypy strictness,candidates_consideredsanitization, property-test-is-the-contract.
The original story's substance (the algorithm, the totality property, cycle detection, the universal-fallback-as-plugin discipline) is preserved. The validator only tightened ACs into individually verifiable assertions, introduced the ManifestScope → PluginScope lift seam that the rest of Phase 3 implicitly assumes, refactored the resolver into a functional-core / imperative-shell split with 7 pure helpers, promoted the magic string + magic number to typed Final constants with AST single-source-of-truth scans, parametrized the TDD plan into 13 named tests + a Hypothesis property + 18-row mutation kill-list, and pinned candidates_considered semantics + _unpack incoming-scope discipline. No scope change.
Arch follow-up (carried forward; not a story edit)¶
The arch follow-up logged in _validation/S2-02-plugin-manifest-pydantic.md remains pending:
phase-arch-design.mdline 755:scope: PluginScope→scope: ManifestScopeon the load-timePluginManifest. Document the post-liftResolvedManifest.scope: PluginScope(orConcreteResolution.matched_scope: PluginScope) as the form S2-04 produces.
This story's hardening makes the lift seam visible in the AC surface, which partially addresses the arch gap. A clean fix is still a one-line arch edit + a ResolvedManifest model OR keeping the lift inside the resolver (current direction). No action required for this story; logged for the phase-architect skill's next pass.
Verdict — HARDENED¶
The story now passes the four critics:
- Coverage: every AC is individually verifiable; the four list-fan-out cases are parametrized; both universal-only-registry and missing-universal edges are tested;
extends_chainordering pinned via tuple equality;candidates_consideredsemantics + ordering + exclusion are tested;PluginNotRegisteredpropagation is tested. - Test-Quality: every AC has at least one concrete test that would fail under an obvious mutation (18-row mutation kill-list). The left-to-right merge gets dedicated tests for both
composed_adaptersandcomposed_tccm.provides. AST source-scan fences guard the literal"universal--*--*", the literal4,NotImplementedErrorremoval, exhaustivenessmatcharms, and module purity. The Hypothesis property test catches the wide class of "resolver became partial" mutations. - Consistency: every chokepoint (S1-02
matcheskeyword signature, S2-02ManifestScopeshape, S2-01register -> Pluginreturn) is routed correctly; theManifestScope → PluginScopelift seam is explicit;candidates_consideredsemantics match ADR-0003 §Consequences row 5;PluginResolutionmodule placement is pinned;PluginRegistryCorruptedis the typed exception, the spanning event is S6-01. - Design-Patterns: Tagged union (
PluginResolution) makes "no concrete match" type-impossible to silently drop; Open/Closed at the kernel (UNIVERSAL_FALLBACK_IDsentinel + AST single-source-of-truth scan keeps the kernel free ofif plugin.id == Xbranches outside the resolver itself); functional core / imperative shell (7 named pure helpers); Strategy pattern preserved via theScopedCandidatelift seam (Phase 4'sLlmFallbackResolutioncould land additively as a new union variant + a new strategy in the same flow); make-illegal-states-unrepresentable via the discriminatedkind: Literal[...]discipline; primitive obsession on universal-ID and depth-cap is removed.
Ready for phase-story-executor.