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 same — verify_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); None → Sha256TreeDigestVerifier(). 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(autouserestore_default_registryfixture, 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-codebasePath.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:
- Status raised from
ReadytoHARDENED. Validation notes block appended under header. - Acceptance criteria restructured (14 ACs, each individually verifiable):
- AC-1:
load_plugins(plugin_root, lock_path, *, registry, verifier) -> Result[LoadReport, PluginRejected]+compute_plugin_tree_digest(plugin_dir) -> BlobDigest.LoadReportis PydanticBaseModel(frozen=True, extra="forbid")withloaded: tuple[PluginId, ...]andtotal_walked: int. - AC-2:
compute_plugin_tree_digestis deterministic + chokepoint-routed viacodegenie.hashing.tree_digest_of_files; un-prefixed 64-hex; skips__pycache__/*.pyc; refuses symlinks viaPath.resolve(strict=True).is_relative_to(plugin_dir.resolve(strict=True)). - AC-3:
plugins/PLUGINS.lockships as{}; loader walk for zero plugins returnsOk(LoadReport(loaded=(), total_walked=0)). Mismatch on any plugin →Err(IntegrityMismatch(plugin, expected, actual)). - AC-4:
PluginRejectedis the tagged-union TypeAlias over seven dataclass variants —IntegrityMismatch | MissingManifest | SchemaViolation | UnlockedPlugin | MissingPluginDirectory | PluginImportError | SymlinkEscape. Each carries only its evidence (PluginIdmandatory; digest fields only onIntegrityMismatch). - AC-5:
LockFile = RootModel[dict[PluginId, BlobDigest]]withLockFile.from_path(path) -> Result[LockFile, LockFileMalformed]routing throughcodegenie.parsers.safe_json.load. Top-level non-object →Err. - AC-6:
PluginVerifierProtocol +Sha256TreeDigestVerifier()default — ADR-0011 §Consequences line 78 substitution seam. - AC-7: parametrized
tests/unit/plugins/test_loader.pywith one test perPluginRejectedvariant (seven names enumerated). - AC-8: verify-all-then-import-all order pinned; no
importlib.import_moduleruns until every plugin's integrity check passes. - AC-9: fail-fast on first rejection.
- AC-10: Hypothesis property test for digest walk-order invariance.
- AC-11: cross-platform — binary-mode reads; LF/CRLF parametrize;
__pycache__skip test-pinned. - AC-12:
test_no_partial_registration_on_any_rejection(parametrized) +test_tampered_plugin_module_never_imported(sys.modules witness). - AC-13: default-registry path tested with autouse
restore_default_registry(S2-01 precedent). - AC-14: AST source-scan in
tests/static/test_plugins_loader_chokepoints.pyassertingloader.pydoes not importjson,hashlib, orblake3. - Implementation outline restructured into 6 numbered steps:
- §1 extends
codegenie.hashingwithtree_digest_of_files(pairs) -> BlobDigest(chokepoint extension, NOT a new chokepoint). - §2 splits
_collect_plugin_files(impure, returnsResult) fromcompute_plugin_tree_digest(composes pure + impure). - §3 introduces
PluginVerifierProtocol +Sha256TreeDigestVerifier. - §4 routes
_read_lockthroughsafe_json.load;LockFilePydanticRootModel. - §5 the seven-step
load_pluginsflow with verify-all-then-import-all order. - §6
plugins/__init__.pyempty marker + CODEOWNERS edit + PR template. - TDD plan expanded:
- Red test (
test_integrity_mismatch_returns_err) preserved but rewritten — drop unusedimport hashlib; assert un-prefixedBlobDigestformat viare.fullmatch. - Green follow-on tests enumerated (10 named tests + 1 Hypothesis property).
- Refactor pulls per-variant rejection into a small
_reject(variant)helper composing with the chokepoint extension. - Files to touch expanded:
src/codegenie/hashing.py— additivetree_digest_of_files.src/codegenie/plugins/verifiers.py—PluginVerifierProtocol +Sha256TreeDigestVerifier.src/codegenie/plugins/lockfile.py—LockFilePydanticRootModel+LockFileMalformedtyped error.plugins/__init__.py— empty package marker (required forimportlib)..github/CODEOWNERS— additive entry (NOT rootCODEOWNERS).tests/static/test_plugins_loader_chokepoints.py— AST source-scan fence.- Out of scope expanded:
- Alternate entry-module names (
api.pyis the Phase-3 convention; alternative names deferred to manifest schema amendment). PluginExtendsCycledetection (S2-04).- Phase 11
SigstoreVerifiersubstitution. - Lock-file regeneration tooling (
codegenie plugins lock-updateCLI — Phase 11 territory per phase-arch E17 recovery column). - Notes for the implementer restructured into eight subsections:
- §1 Empty-lock invariant (preserved).
- §2 Honest-framing language (preserved; tightened — "integrity check" / "tree digest" only).
- §3 Loader pipeline — discover → verify → import → register; verify-all-then-import-all; fail-fast at each gate.
- §4 Phase 11 substitution seam (
PluginVerifierProtocol) — explicit pre-commitment per ADR-0011. - §5 Functional-core / imperative-shell split (
_collect_plugin_filesimpure ;tree_digest_of_filespure). - §6 Chokepoint discipline —
codegenie.hashing+codegenie.parsers.safe_jsonare the only allowed entry points; AST-source-scan fence enforces. - §7 Entry-module convention (
api.py) andplugins/__init__.pyimport-path requirement. - §8 CODEOWNERS placeholder discipline (
.github/CODEOWNERS, additive line; TODO comment for human team handle). - §9 Deliberately-not-adopted decisions (no DI container, no entry-module manifest field in Phase 3, no Sigstore substitution this story).
- §10 Symbol references — S2-01's
register -> Plugintightening; 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.shufflewalk, 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 directhashlib/blake3/jsonimports. - Consistency: every chokepoint (ADR-0001 hashing, Phase 1 ADR-0009
safe_json) is routed through, not bypassed;BlobDigestformat conflict resolved at the chokepoint extension;CODEOWNERSlocation matches the existing.github/CODEOWNERS;api.pyentry-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
PluginVerifierseam (the rule-of-three doesn't apply here because ADR-0011 names the seam directly — design pre-commitment); tagged-union sum type makes illegalPluginRejectedstates unrepresentable; functional-core / imperative-shell split clarifies pure vs impure; lockfile is aRootModelfor 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.