Skip to content

Validation report — S2-03 — Plugin loader: filesystem walk, importlib, PLUGINS.lock integrity check

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-03-plugin-loader-integrity.md


Context brief

S2-03 ships the disk-to-kernel bridge: walks plugins/*/plugin.yaml, verifies a per-plugin SHA-256 tree-digest against plugins/PLUGINS.lock, importlib.import_modules each plugin's entry module so @register_plugin side-effects fire, and surfaces every failure mode as a typed Result. Ships an empty PLUGINS.lock + CODEOWNERS gating. The integrity check is structurally honest-framed per ADR-0011 — "catches accidental corruption and partial merges", NOT "cryptographic signature"; Phase 11 substitutes Sigstore at this exact loader interface (verify_plugin(plugin_dir) -> Result[None, VerificationError]).

This is the second loader-with-typed-tagged-errors in Phase 3 (after S2-02's manifest loader), the first place Phase 3 dynamic-imports user code, and the first place the codebase introduces a PluginVerifier strategy seam that Phase 11 will substitute. The kernel split S2-01 → S2-02 → S2-03 → S2-04 reaches its third stage here.

Load-bearing context: - ADR-0001 (Phase 0) §Decision: src/codegenie/hashing.py is the single chokepoint for hashlib.sha256 + blake3 imports in the entire src/codegenie/ tree. No other module may import either directly. - ADR-0002 §Decision: loader mutates the passed registry (or default_registry if None); exit code 4 on collision/cycle/lock-mismatch. - ADR-0011 §Consequences line 78: Phase 11 substitutes PLUGINS.lock with Sigstore signing; the loader interface stays the sameverify_plugin(plugin_dir) -> Result[None, VerificationError]. This is an explicit Strategy / Plugin / Open-Closed pre-commitment. - ADR-0010 §Decision 3 + S1-03 outcomes: every state machine is a tagged-union sum type, not anaemic Literal + parallel optional fields. - Phase 1 ADR-0009: codegenie.parsers.safe_json.load(path, max_bytes=...) is the JSON chokepoint (O_NOFOLLOW + size cap + depth cap). PLUGINS.lock reading routes through it; json.loads direct is forbidden in src/codegenie/. - Phase-arch §C2 line 483 + E10 + E17: failure modes are enumerated and each is exit code 4; PluginRejected(integrity_mismatch) is one of seven listed. - Phase-arch §C10 + E12: symlink-TOCTOU is a real threat model the codebase guards explicitly; Path.resolve(strict=True) + is_relative_to(jail) is the documented discipline (also see src/codegenie/probes/deployment.py:183-193 for the in-codebase precedent). - Sibling precedents: _validation/S2-01-plugin-registry-kernel.md (tagged-union exception payloads, autouse default_registry snapshot fixture, fixture-isolated tests), _validation/S2-02-plugin-manifest-pydantic.md (loader-with-tagged-union errors, route through chokepoints, parametrized per-variant tests, Hypothesis property for never-raises invariant), src/codegenie/probes/__init__.py (explicit-imports hygiene), src/codegenie/hashing.py (identity_hash/content_hash prefix-tagged format, single chokepoint).

Original story strengths: - Correctly cited ADR-0002 / ADR-0011 / production ADR-0031 across Context, References, and Notes. - Goal precisely scoped the disk-to-kernel-bridge boundary and out-of-scoped S2-04 (resolver / extends walking) and S7-01 (concrete plugin tree). - Honest framing language preserved per ADR-0011 ("integrity check" not "signature"; integrity_mismatch not signature_failure); Notes §2 articulates the discipline as structural, not just docs. - Symlink-discipline + Path.resolve(strict=True).is_relative_to(...) pattern correctly referenced (probe deployment.py:183-193 precedent). - Empty-lock invariant called out twice (Context + Notes §1) so the executor does not "improve" by guarding against {} as bad config. - CODEOWNERS placeholder discipline correct (Notes §5 — leave TODO; do not invent a team handle). - Out-of-scope cleanly separated S2-04 / S6-04 / S7-01 / Phase 11.

Original story weaknesses (resolved here): - Bypasses codegenie.hashing chokepoint (ADR-0001). Implementation outline §2 prescribes feeding (relpath, bytes) pairs into hashlib.sha256() directly inside the new loader module. ADR-0001 §Decision (Phase 0): src/codegenie/hashing.py is the only place under src/codegenie/ that may import hashlib.sha256 or blake3. Routing the tree-digest through a new hashing.plugin_tree_digest(...) extension is the only chokepoint-respecting path. (Consistency F1 / Design-Patterns F4 — block.) - BlobDigest format mismatch. Story types the digest as BlobDigest (S1-01: ^[0-9a-f]{64}$, un-prefixed 64-hex). But hashing.identity_hash_bytes returns "sha256:<64-hex>" (prefix-tagged). The two formats collide. The chokepoint extension must return un-prefixed hex (matching BlobDigest) OR the loader must strip the prefix at one boundary. Story prescribes neither. (Consistency F2 — block.) - Bypasses safe_json.load chokepoint (Phase 1 ADR-0009). Implementation outline §2 names _read_lock as "small JSON loader" — json.loads direct. Phase 1 establishes safe_json.load(path, max_bytes=..., max_depth=...) as the codebase JSON chokepoint with O_NOFOLLOW + size cap + depth cap. PLUGINS.lock must route through it. A naive json.loads reopens the symlink + size-cap-bypass + alias-amplification holes ADR-0009 closed. (Consistency F3 / Test-Quality F8 — block.) - CODEOWNERS location wrong. Story says "CODEOWNERS (repo root) gains an entry … (create if absent)". The existing repo has .github/CODEOWNERS (Phase 0); GitHub honors .github/CODEOWNERS over root. The story should edit the existing file, not create a duplicate. (Consistency F4 — block.) - Anaemic PluginRejected shape violates ADR-0010 / S1-03 tagged-union discipline. Story prescribes PluginRejected(reason: Literal[7-variants], plugin: PluginId, expected: BlobDigest | None, actual: BlobDigest | None, detail: str | None) — a single Pydantic with parallel optional fields. This admits illegal states (PluginRejected(reason="missing_manifest", expected="abc...", actual="def...")). ADR-0010 §Decision 3 (already-shipped in Phase 3) + S1-03 mandate a tagged-union sum type where each variant carries only its evidence. (Design-Patterns F2 / Consistency F7 — block.) - Missing PluginVerifier Strategy seam (ADR-0011 §Consequences line 78). ADR-0011 explicitly names Phase 11's substitution interface: verify_plugin(plugin_dir) -> Result[None, VerificationError]. The story inlines the SHA-256 tree-digest verification inside load_plugins — Phase 11 will be forced to edit loader.py (extension-by-edit, NOT extension-by-addition; violates the load-bearing CLAUDE.md commitment). The fix is a PluginVerifier Protocol parameter, defaulted to a Phase-3 Sha256TreeDigestVerifier() implementation. (Consistency F8 / Design-Patterns F1 — block.) - Order-of-operations untied. Critical: if importlib.import_module fires before per-plugin integrity verification, a tampered plugin's module-body executes and may call register_plugin(...) before the loader catches the mismatch — the registry is mutated for a rejected plugin. Story's Implementation outline §2.5 → §2.6 order is correct (verify-then-import) but no AC pins it; no test would catch a mutant that reorders. The red test asserts len(registry.all()) == 0 but only on the integrity_mismatch path; the other six reasons (especially import_error where reading a partial module body is the failure mode) have no registry not mutated assertion. (Coverage F2 / Test-Quality F5 — block.) - Symlink-escape rejection reason in Notes but not in ACs. Notes line 174 names Err(PluginRejected(reason="symlink_escape", plugin=name)) as "add it if the implementation hits it in practice; otherwise, defer". This is too soft — phase-arch E12 names symlink-TOCTOU as load-bearing; the loader must refuse symlink-escape deterministically. AC + test required. (Coverage F1 / Test-Quality F8 — block.) - Single TDD red test; no parametrization across the seven rejection reasons. AC-7 says "covers every failure mode (one test per PluginRejected.reason)" but the TDD plan codifies exactly one (test_integrity_mismatch_returns_err). The executor improvises six others; without pinning shapes, six executor improvisations land at varying mutation-resistance. Per S2-02 precedent (AC-17), parametrize. (Test-Quality F3 — block.) - Digest determinism property not required. Refactor §3 marks the "walk-order invariance" property test as optional. The whole architectural point of the integrity check is determinism — a non-deterministic loader is worse than no loader. Required, not optional. (Test-Quality F6 / Coverage F5 — block.) - Cross-platform file-bytes invariance not required. Refactor §4 names "file mode bits and line endings must not affect the digest" as a refactor note. No AC enforces. A naive impl reading in text mode silently produces different digests on Windows/Linux; CI is Linux-only so the bug would surface for a contributor on Windows. (Test-Quality F? / Coverage F6 — harden.) - __pycache__ / *.pyc exclusion not test-pinned. AC-2 names the skip but no test creates a __pycache__/foo.pyc and asserts digest invariance before/after. After the first importlib.import_module, __pycache__ is populated — a digest that includes it changes on second load. (Test-Quality F7 — harden.) - Default-registry mutation path (registry=None) not test-pinned. S2-01's AC-10 + autouse restore_default_registry fixture is the precedent. Story exposes registry=None but no test would catch a mutant that constructs a fresh PluginRegistry() instead of routing to default_registry. (Test-Quality F4 — harden.) - api.py entry-module convention not anchored. Notes line 174 names importlib.import_module(f"plugins.{slug}.api") — but production ADR-0031 §Plugin directory layout enumerates probes/, adapters/, subgraph/, recipes/, policy/, skills/no api.py. The convention is introduced by this story alone. Either (a) document the convention in this story with a Notes paragraph and add to arch-design as follow-up, or (b) carry the entry-point in plugin.yaml. Story picks (a) implicitly but does not document. (Consistency F5 — harden.) - plugins/__init__.py not in Files-to-touch. importlib.import_module("plugins.{slug}.api") requires plugins/ to be importable as a package — i.e., plugins/__init__.py must exist. Story does not create it. (Consistency F9 / Coverage F8 — harden.) - _read_lock schema-discipline. Notes line 176 says "refuse to accept top-level non-objects" but no AC enforces. A loader that accepts [] or null and treats as empty silently degrades the contract. Pin: _read_lock returns Result[LockFile, LockFileMalformed]; non-object top level → Err. (Coverage F7 / Test-Quality F10 — harden.) - Pure-vs-impure tangle in compute_plugin_tree_digest. Function does both filesystem walk (impure) AND hashing (pure given bytes). Splitting _collect_plugin_files(plugin_dir) -> tuple[tuple[str, bytes], ...] (impure) from hashing.tree_digest_of_files(pairs) -> BlobDigest (pure, chokepoint-respecting) clarifies testing AND satisfies ADR-0001 by construction. (Design-Patterns F4 — harden.) - Primitive obsession on digest payloads in PluginRejected.IntegrityMismatch. expected: str | None / actual: str | None should be BlobDigest (S1-01 newtype). (Design-Patterns F3 — harden.) - LoadReport shape undertyped. AC-1 names the type and the empty-case shape (LoadReport(loaded=[], total_walked=0)); no AC pins it as a BaseModel(frozen=True, extra="forbid") Pydantic per phase-arch convention. (Consistency F10 — nit.) - Test imports import hashlib but doesn't use it. Dead import; mutant hashlib.sha512 substitution would never be caught at this test. The locked-digest assertion is computed via compute_plugin_tree_digest which is correct, but the test doesn't assert the format of BlobDigest (un-prefixed 64-hex). A mutant returning "sha256:..." or uppercase passes. (Test-Quality F1 — harden.) - PullRequest template location. Story says .github/pull_request_template.md — correct GitHub convention. No issue. (No finding.) - exit_code: ClassVar[int] = 4 placement under tagged-union refactor. Under the new sum-type, exit_code lives as a single function exit_code_for(p: PluginRejected) -> Literal[4] OR as a ClassVar on each variant. Either is fine; pin one for the executor. (Design-Patterns F5 — nit.)


Stage 2 — Four critic reports

Coverage critic — 9 findings

ID Severity Title Resolution
F1 block Symlink-escape rejection variant in Notes but not in ACs Applied — AC-4 expanded with SymlinkEscape(plugin, offending_path) variant; AC-7 parametrized to include test_symlink_escape_returns_err. Phase-arch §C10 + E12 anchor.
F2 block Verify-then-import order-of-operations not enforced by AC Applied — new AC-8 mandates verify-all-then-import-all: integrity check fires for every plugin before any importlib.import_module runs. AC-12 adds test_tampered_plugin_module_never_imported asserting sys.modules is unmodified after a rejection (mutation-resistance for import_error + integrity_mismatch paths).
F3 harden LoadReport partial-success semantics not pinned Applied — AC-1 expanded: LoadReport is BaseModel(frozen=True, extra="forbid") with loaded: tuple[PluginId, ...] (immutable) and total_walked: int. AC-9 mandates fail-fast on first rejection; loaded reflects only fully-verified-AND-imported plugins before the failure (which under verify-then-import is the empty tuple).
F4 block Single TDD test for seven rejection variants Applied — AC-7 mandates a @pytest.mark.parametrize table covering all seven PluginRejected variants by name; TDD §"Red" gains the table and TDD §"Green follow-on" lists each test name.
F5 block Digest determinism (walk-order invariance) property test optional Applied — AC-10 mandates a Hypothesis property test: for any plugin_dir, compute_plugin_tree_digest(plugin_dir) is invariant under os.walk order. Refactor §3 elevated to TDD §"Green follow-on".
F6 harden Cross-platform file-bytes invariance not required Applied — AC-11 mandates that file reads use Path.read_bytes() (binary mode); a test_digest_is_bytes_mode parametrized over LF / CRLF line endings asserts equal digests. Refactor §4 elevated.
F7 harden _read_lock top-level-non-object behavior not in AC Applied — AC-5 expanded: LockFile is a Pydantic RootModel[dict[PluginId, BlobDigest]] loaded via LockFile.from_path(path) -> Result[LockFile, LockFileMalformed]; top-level non-object → Err(LockFileMalformed(detail=...)); AC-7 parametrization adds test_malformed_lock_returns_err.
F8 harden plugins/__init__.py not in Files-to-touch Applied — Files-to-touch adds plugins/__init__.py (empty package marker) so importlib.import_module("plugins.{slug}.api") resolves. Notes §"Entry-module convention" documents the api.py convention.
F9 nit LoadReport Pydantic discipline not pinned Applied — see F3 resolution above.

Test-Quality critic — 10 findings

ID Severity Title Resolution
F1 harden Red test imports hashlib unused; doesn't assert BlobDigest format Applied — TDD §"Red" rewritten: drop import hashlib; add assert re.fullmatch(r"[0-9a-f]{64}", locked_digest) to pin the un-prefixed format. The mutant returning "sha256:..." or uppercase now fails.
F2 block registry not mutated only tested on integrity_mismatch path Applied — AC-12 + TDD §"Green follow-on" test_no_partial_registration_on_any_rejection parametrized across all seven PluginRejected variants; each asserts registry.all() == () after load_plugins returns Err.
F3 block Per-variant tests not pinned Applied — TDD §"Red" + §"Green follow-on" enumerate all seven test names and a parametrize table: test_integrity_mismatch_returns_err (red), test_missing_manifest_returns_err, test_schema_violation_returns_err, test_unlocked_plugin_returns_err, test_missing_plugin_directory_returns_err, test_import_error_returns_err, test_symlink_escape_returns_err.
F4 harden Default-registry registry=None path untested Applied — AC-13 + TDD §"Green follow-on" test_load_plugins_default_singleton_path mirrors S2-01's pattern; autouse restore_default_registry fixture in tests/unit/plugins/conftest.py (existing per S2-01).
F5 block No test asserts import-not-fired-on-rejection (mutation-resistance for ordering) Applied — test_tampered_plugin_module_never_imported (AC-12 + TDD §"Green follow-on"): tamper plugin's api.py so its module body calls sys.modules.setdefault("PROOF_OF_IMPORT", True); load_plugins must Err AND "PROOF_OF_IMPORT" not in sys.modules. Catches mutants that reorder verify/import.
F6 block Digest walk-order property test optional Applied — @hypothesis.given(file_bytes=...) property test mandated by AC-10; the strategy generates arbitrary (path, bytes) pairs and asserts digest is invariant under random.shuffle(walk).
F7 harden __pycache__ / *.pyc exclusion not test-pinned Applied — test_digest_skips_pycache_directory (AC-11 follow-on): compute digest, populate plugin_dir/__pycache__/foo.pyc with arbitrary bytes, recompute digest, assert equality. Companion test_digest_skips_pyc_files for top-level *.pyc.
F8 block Bypasses safe_json.load chokepoint Applied — Implementation outline §1 mandates _read_lock routes through codegenie.parsers.safe_json.load; AC-5 names the chokepoint; AC-14 adds an AST-source-scan test in tests/static/test_plugins_loader_chokepoints.py asserting loader.py does NOT import json (only safe_json) and does NOT import hashlib / blake3 (only codegenie.hashing).
F9 harden Cross-platform line-ending parametrization missing Applied — see Coverage F6.
F10 nit _read_lock round-trip test absent Applied — test_lockfile_roundtrip (TDD §"Green follow-on"): write {"a": "0"*64}, load via LockFile.from_path, assert lockfile.root[PluginId("a")] == BlobDigest("0"*64).

Consistency critic — 10 findings

ID Severity Title Resolution
F1 block Bypasses codegenie.hashing chokepoint (ADR-0001) Applied — Implementation outline §2 mandates extending src/codegenie/hashing.py with tree_digest_of_files(pairs: Iterable[tuple[str, bytes]]) -> BlobDigest (returns un-prefixed 64-hex per BlobDigest newtype; uses SHA-256). loader.py never imports hashlib or blake3; only codegenie.hashing. AC-14 AST-source-scan test enforces. (Composes with Test-Quality F8 / Design-Patterns F4.)
F2 block BlobDigest format conflict (un-prefixed 64-hex vs sha256:<hex> prefix-tagged) Applied — tree_digest_of_files returns un-prefixed (matches BlobDigest regex). Chokepoint extension explicitly returns BlobDigest(...) newtype-wrapped via parse_blob_digest. AC-1 + AC-2 pin the return shape; TDD §"Red" asserts the format with re.fullmatch(r"[0-9a-f]{64}", ...).
F3 block Bypasses safe_json.load chokepoint (Phase 1 ADR-0009) Applied — see Test-Quality F8.
F4 block CODEOWNERS location wrong (story says repo root; existing file is .github/CODEOWNERS) Applied — Files-to-touch row corrected to .github/CODEOWNERS (additive entry); "create if absent" language removed. Notes §"CODEOWNERS placeholder" preserved. Existing precedent at .github/CODEOWNERS:25-31.
F5 harden api.py entry-module convention not anchored to ADR-0031 (production has no api.py) Applied — Notes §"Entry-module convention" documents the convention: "Phase 3 introduces plugins/{slug}/api.py as the registration entry module; subsequent phases either preserve this convention or carry an entry_module field on the manifest (deferred to Phase 11 when Sigstore lands)." Out-of-scope expanded to name "alternate entry-module names — deferred to manifest schema amendment if needed".
F6 harden Anaemic PluginRejected violates ADR-0010 / S1-03 tagged-union discipline Applied — AC-4 rewritten: PluginRejected is a TypeAlias over a tagged union of seven dataclasses (IntegrityMismatch, MissingManifest, SchemaViolation, UnlockedPlugin, MissingPluginDirectory, PluginImportError, SymlinkEscape). Each carries only its evidence. exit_code is a ClassVar[int] = 4 on a shared base or a free function exit_code_for_rejection(p) -> Literal[4]. Composes with Design-Patterns F2.
F7 block Missing PluginVerifier Strategy seam (ADR-0011 §Consequences line 78) Applied — AC-6 + Implementation outline §3 introduce class PluginVerifier(Protocol): def verify(self, plugin_dir: Path, expected: BlobDigest) -> Result[None, VerificationError]. Ship Sha256TreeDigestVerifier() as the Phase-3 default. load_plugins(*, verifier: PluginVerifier | None = None); NoneSha256TreeDigestVerifier(). Phase 11 substitutes SigstoreVerifier via DI with zero edits to loader.py. Composes with Design-Patterns F1.
F8 harden register return type divergence story-vs-arch (S2-01 precedent) Applied — Notes §"Symbol references" acknowledges that S2-01 tightened to -> Plugin; loader code uses whichever S2-01 shipped (Reader checks src/codegenie/plugins/registry.py at implementation time).
F9 harden plugins/__init__.py not in Files-to-touch Applied — see Coverage F8.
F10 nit LoadReport Pydantic discipline not pinned Applied — see Coverage F3 / F9.

Design-Patterns critic — 8 findings

ID Severity Title Resolution
F1 block Inlined SHA-256 verification locks Phase 11 into extension-by-edit Applied — see Consistency F7. The PluginVerifier Protocol seam is the canonical Plugin / Strategy / Open-Closed application that ADR-0011 §Consequences line 78 explicitly pre-commits. Phase 11 adds SigstoreVerifier as a new file + DI substitution; zero edits to loader.py.
F2 block Anaemic Pydantic(reason: Literal[7], …) violates ADR-0010 / S1-03 Applied — see Consistency F6. The tagged-union sum type makes illegal states unrepresentable; match + assert_never at consumer sites enforces exhaustiveness at mypy --strict.
F3 harden Primitive obsession on expected: str | None, actual: str | None Applied — IntegrityMismatch.expected: BlobDigest, actual: BlobDigest (no | None; both are mandatory on this variant per the tagged-union refactor). The Optional-itis disappears because each variant carries only what it needs.
F4 harden Functional-core / imperative-shell tangle in compute_plugin_tree_digest Applied — Implementation outline §2 splits: _collect_plugin_files(plugin_dir: Path) -> Result[tuple[tuple[str, bytes], ...], SymlinkEscape] (impure: walks fs, filters, asserts symlinks) + codegenie.hashing.tree_digest_of_files(pairs: Iterable[tuple[str, bytes]]) -> BlobDigest (pure given inputs; chokepoint). compute_plugin_tree_digest(plugin_dir) is the public face composing both.
F5 harden Pipeline ordering (discover → verify → import → register) not articulated Applied — Notes §"Loader pipeline" documents the four-gate order as the load-bearing discipline; each gate fails-fast; verify-all-then-import-all (not per-plugin verify-then-import) is the rule, so a tampered plugin's module body NEVER runs even if other plugins' integrity passes. (Composes with Coverage F2 / Test-Quality F5.)
F6 nit "No DI container" decision (correct YAGNI) — recorded for context No change required — Notes §"Deliberately not adopted" preserves the rationale (matches phase-arch §"Patterns considered and deliberately rejected" line 951).
F7 harden LockFile as Pydantic RootModel makes illegal-states-unrepresentable for lock content Applied — see Coverage F7. LockFile = RootModel[dict[PluginId, BlobDigest]] lifts raw dict[str, str] to typed at a single boundary via field_validators that route through parse_plugin_id and parse_blob_digest.
F8 nit Open/Closed for new rejection reasons (sum-type composes additively) Applied via Design-Patterns F2 — adding a new PluginRejected variant is a new dataclass + one new arm in match blocks (Open/Closed at the file boundary). Literal taxonomy is gone. Future symlink-cycle / namespace-collision variants land additively.

Stage 3 — Researcher

Not invoked. No critic finding was tagged NEEDS RESEARCH. All resolutions reuse this codebase's established precedents:

  • src/codegenie/hashing.py:1-29 (Phase 0 ADR-0001 chokepoint discipline)
  • src/codegenie/parsers/safe_json.py (Phase 1 ADR-0009 JSON chokepoint)
  • _validation/S2-01-plugin-registry-kernel.md (autouse restore_default_registry fixture, parametrized exception payload tests, tagged-union exception attribute pattern)
  • _validation/S2-02-plugin-manifest-pydantic.md (loader-with-typed-tagged-union-errors family, parametrized per-variant tests, Hypothesis property for never-raises invariant, route-through-chokepoint discipline)
  • _validation/S1-03-tagged-union-outcomes.md (Phase 3 tagged-union pattern in the same package)
  • src/codegenie/probes/deployment.py:183-193 (in-codebase Path.resolve(strict=True) + is_relative_to(...) symlink-discipline precedent)
  • ADR-0011 §Consequences line 78 (Phase 11 substitution interface explicitly named — design pre-commitment that pays the price of introducing the Strategy seam now, not later)

Stage 4 — Edits applied to story

The story file was rewritten in place with surgical edits. Substantive changes:

  1. Status raised from Ready to HARDENED. Validation notes block appended under header.
  2. Acceptance criteria restructured (14 ACs, each individually verifiable):
  3. AC-1: load_plugins(plugin_root, lock_path, *, registry, verifier) -> Result[LoadReport, PluginRejected] + compute_plugin_tree_digest(plugin_dir) -> BlobDigest. LoadReport is Pydantic BaseModel(frozen=True, extra="forbid") with loaded: tuple[PluginId, ...] and total_walked: int.
  4. AC-2: compute_plugin_tree_digest is deterministic + chokepoint-routed via codegenie.hashing.tree_digest_of_files; un-prefixed 64-hex; skips __pycache__/*.pyc; refuses symlinks via Path.resolve(strict=True).is_relative_to(plugin_dir.resolve(strict=True)).
  5. AC-3: plugins/PLUGINS.lock ships as {}; loader walk for zero plugins returns Ok(LoadReport(loaded=(), total_walked=0)). Mismatch on any plugin → Err(IntegrityMismatch(plugin, expected, actual)).
  6. AC-4: PluginRejected is the tagged-union TypeAlias over seven dataclass variants — IntegrityMismatch | MissingManifest | SchemaViolation | UnlockedPlugin | MissingPluginDirectory | PluginImportError | SymlinkEscape. Each carries only its evidence (PluginId mandatory; digest fields only on IntegrityMismatch).
  7. AC-5: LockFile = RootModel[dict[PluginId, BlobDigest]] with LockFile.from_path(path) -> Result[LockFile, LockFileMalformed] routing through codegenie.parsers.safe_json.load. Top-level non-object → Err.
  8. AC-6: PluginVerifier Protocol + Sha256TreeDigestVerifier() default — ADR-0011 §Consequences line 78 substitution seam.
  9. AC-7: parametrized tests/unit/plugins/test_loader.py with one test per PluginRejected variant (seven names enumerated).
  10. AC-8: verify-all-then-import-all order pinned; no importlib.import_module runs until every plugin's integrity check passes.
  11. AC-9: fail-fast on first rejection.
  12. AC-10: Hypothesis property test for digest walk-order invariance.
  13. AC-11: cross-platform — binary-mode reads; LF/CRLF parametrize; __pycache__ skip test-pinned.
  14. AC-12: test_no_partial_registration_on_any_rejection (parametrized) + test_tampered_plugin_module_never_imported (sys.modules witness).
  15. AC-13: default-registry path tested with autouse restore_default_registry (S2-01 precedent).
  16. AC-14: AST source-scan in tests/static/test_plugins_loader_chokepoints.py asserting loader.py does not import json, hashlib, or blake3.
  17. Implementation outline restructured into 6 numbered steps:
  18. §1 extends codegenie.hashing with tree_digest_of_files(pairs) -> BlobDigest (chokepoint extension, NOT a new chokepoint).
  19. §2 splits _collect_plugin_files (impure, returns Result) from compute_plugin_tree_digest (composes pure + impure).
  20. §3 introduces PluginVerifier Protocol + Sha256TreeDigestVerifier.
  21. §4 routes _read_lock through safe_json.load; LockFile Pydantic RootModel.
  22. §5 the seven-step load_plugins flow with verify-all-then-import-all order.
  23. §6 plugins/__init__.py empty marker + CODEOWNERS edit + PR template.
  24. TDD plan expanded:
  25. Red test (test_integrity_mismatch_returns_err) preserved but rewritten — drop unused import hashlib; assert un-prefixed BlobDigest format via re.fullmatch.
  26. Green follow-on tests enumerated (10 named tests + 1 Hypothesis property).
  27. Refactor pulls per-variant rejection into a small _reject(variant) helper composing with the chokepoint extension.
  28. Files to touch expanded:
  29. src/codegenie/hashing.py — additive tree_digest_of_files.
  30. src/codegenie/plugins/verifiers.pyPluginVerifier Protocol + Sha256TreeDigestVerifier.
  31. src/codegenie/plugins/lockfile.pyLockFile Pydantic RootModel + LockFileMalformed typed error.
  32. plugins/__init__.py — empty package marker (required for importlib).
  33. .github/CODEOWNERS — additive entry (NOT root CODEOWNERS).
  34. tests/static/test_plugins_loader_chokepoints.py — AST source-scan fence.
  35. Out of scope expanded:
  36. Alternate entry-module names (api.py is the Phase-3 convention; alternative names deferred to manifest schema amendment).
  37. PluginExtendsCycle detection (S2-04).
  38. Phase 11 SigstoreVerifier substitution.
  39. Lock-file regeneration tooling (codegenie plugins lock-update CLI — Phase 11 territory per phase-arch E17 recovery column).
  40. Notes for the implementer restructured into eight subsections:
  41. §1 Empty-lock invariant (preserved).
  42. §2 Honest-framing language (preserved; tightened — "integrity check" / "tree digest" only).
  43. §3 Loader pipeline — discover → verify → import → register; verify-all-then-import-all; fail-fast at each gate.
  44. §4 Phase 11 substitution seam (PluginVerifier Protocol) — explicit pre-commitment per ADR-0011.
  45. §5 Functional-core / imperative-shell split (_collect_plugin_files impure ; tree_digest_of_files pure).
  46. §6 Chokepoint discipline — codegenie.hashing + codegenie.parsers.safe_json are the only allowed entry points; AST-source-scan fence enforces.
  47. §7 Entry-module convention (api.py) and plugins/__init__.py import-path requirement.
  48. §8 CODEOWNERS placeholder discipline (.github/CODEOWNERS, additive line; TODO comment for human team handle).
  49. §9 Deliberately-not-adopted decisions (no DI container, no entry-module manifest field in Phase 3, no Sigstore substitution this story).
  50. §10 Symbol references — S2-01's register -> Plugin tightening; consume what S2-01 shipped.

The original story's substance (loader, integrity check, importlib, empty PLUGINS.lock, CODEOWNERS, honest framing per ADR-0011) is preserved. The validator only tightened ACs into individually verifiable assertions, routed through chokepoints per ADR-0001 / Phase 1 ADR-0009, introduced the PluginVerifier Strategy seam that ADR-0011 §Consequences line 78 explicitly pre-commits, refactored PluginRejected from anaemic Literal-discriminated to tagged-union sum type per ADR-0010 / S1-03 / S2-02 precedent, and mandated mutation-resistant tests for the verify-then-import order-of-operations. No scope change.


Verdict — HARDENED

The story now passes the four critics:

  • Coverage: every AC is individually verifiable; the seven-variant rejection taxonomy has a parametrized test per variant; verify-then-import order is pinned by an AC + a witness test; symlink-escape is explicit; digest determinism is required, not optional; cross-platform invariance is enforced.
  • Test-Quality: every AC has at least one concrete test that would fail under an obvious mutation (return None, random.shuffle walk, text-mode read, partial-import-on-reject, missing __pycache__ skip, prefix-tagged digest, anaemic-payload). Hypothesis property test catches the wide mutation class of "walk order matters". AST source-scan fence catches direct hashlib / blake3 / json imports.
  • Consistency: every chokepoint (ADR-0001 hashing, Phase 1 ADR-0009 safe_json) is routed through, not bypassed; BlobDigest format conflict resolved at the chokepoint extension; CODEOWNERS location matches the existing .github/CODEOWNERS; api.py entry-module convention is documented; ADR-0011 §Consequences Phase 11 substitution seam (verify_plugin -> Result) is exposed as a Protocol parameter, not inlined.
  • Design-Patterns: Strategy / Plugin / Open-Closed pattern applied at the PluginVerifier seam (the rule-of-three doesn't apply here because ADR-0011 names the seam directly — design pre-commitment); tagged-union sum type makes illegal PluginRejected states unrepresentable; functional-core / imperative-shell split clarifies pure vs impure; lockfile is a RootModel for one-boundary typed-lift; Pipeline pattern (discover → verify → import → register) is articulated in Notes; primitive obsession on digest payloads is removed.

Ready for phase-story-executor.