Story S5-01 — vuln-remediation registration + breakdown_keys + failure_modes¶
Step: Step 5 — Backfill bench/vuln-remediation/ with ≥10 cases + rubric + taxonomies
Status: HARDENED (phase-story-validator, 2026-06-04)
Effort: S
Depends on: S1-03 (HARDENED @register_task_class kwarg surface: breakdown_keys, failure_mode_taxonomy, bench_path, min_cases_for_promotion, decorated class IS the rubric class), S2-01 (HARDENED load_task_class is the import surface for tests; hyphen→underscore translation lives there; PEP 420 implicit namespace package contract), S1-04 (Rubric Protocol — the stub rubric class must satisfy it structurally), S4-02 (CLI exit-code mapping the typed errors feed). Transitively: Step 1 contracts (@register_task_class, BreakdownKey StrEnum convention, taxonomy loader).
ADRs honored: ADR-0001 (subprocess-isolation envelope the rubric will fit), ADR-0004 (per-task-class failure_modes.yaml taxonomy with severity; runtime-mapping is severity-only, descriptions live in YAML for fence-CI validation), ADR-0006 (curation-class split; min_cases_for_promotion["silver"] triggers held-out floor), ADR-0008 (per-task-class BreakdownKey StrEnum + substring ban at value level + rubric.unknown_breakdown_key block code), Phase 5 ADR-0014 (substring-ban source-of-truth shared with ADR-0008)
Validation notes¶
Validated: 2026-06-04 Verdict: HARDENED Findings addressed: 21 total — 7 blocks, 11 hardens, 3 nits
Changes applied (full audit log: _validation/S5-01-vuln-registration-and-taxonomies.md):
- Status line updated to
HARDENED (phase-story-validator, 2026-06-04)(F-CON-9). - Depends-on rewritten to name S1-03, S2-01, S1-04 explicitly (F-CON-8).
- AC-1 + Implementation outline §5 rewritten (BLOCK): the registration call now uses the HARDENED S1-03 kwarg surface (
breakdown_keys=frozenset(m.value for m in BreakdownKey),failure_mode_taxonomy=_TAXONOMY) and the imperative-application formregister_task_class(...)(VulnRemediationRubric)against the rubric class imported frombench.vuln_remediation.rubric— not the marker-class decoration the original story showed. The marker-class pattern is forbidden in Notes-for-implementer (F-CON-1 + F-CON-2). - TDD test surface switched to
load_task_class(BLOCK):tests/unit/eval/test_bench_vuln_registration.pycallsload_task_class("vuln-remediation", bench_root=tmp_bench_root, registry=fresh_registry)instead ofimportlib.import_module("bench.vuln_remediation.registration"). The original direct-importlib path cannot resolve the hyphenated on-disk directory under standard Python import machinery; the hyphen→underscore translation is HARDENED S2-01's job. Test file relocated totests/unit/eval/to inherit S2-01's autouseconftest.py(sys.path/sys.modules/default_registry snapshot+restore). The fixture's manualsys.modulesdeletion is removed — the conftest handles it (F-CON-3). failure_mode_descriptionsfield assertion dropped (BLOCK): S1-03 AC-2 pinsTaskClassto exactly six fields viadataclasses.fields(TaskClass)introspection. Descriptions live infailure_modes.yamlfor fence-CI #6 validation at PR time and are asserted in this story by reading the YAML directly (test_failure_modes_yaml_has_non_empty_descriptions) — not viaTaskClass. Notes-for-implementer pins the split: "descriptions in YAML; severities on TaskClass" (F-CON-4).bench/__init__.pyhedge hard-banned (BLOCK): S2-01 HARDENED uses PEP 420 implicit namespace packages — no__init__.pyfiles anywhere inbench/. The original hedge ("may also need to exist") would (a) break the PEP 420 contract; (b) for the hyphenated leaf, the file can't exist as a valid Python module name. Files-to-touch row removed; Notes-for-implementer carries the hard ban (F-CON-5).- Runner-internal always-block codes added to required taxonomy set (BLOCK): ADR-0004 §Tradeoffs requires the always-block codes to be replicated per task class. The original AC's
REQUIRED_BLOCK_CODESlisted only ADR-0004 §Consequences' eight codes —sut.timeout,rubric.unknown_breakdown_key,rubric.malformed_outputwere missing. An executor would ship a YAML that the runner immediately blows up on at the first SUT timeout. AC-5 +REQUIRED_BLOCK_CODESnow require all 11 block codes (F-CON-6). rubric.pystub decision pinned (BLOCK): the original Notes said "Pick whichever the team's review velocity supports." Decision pinned to ship-the-stub in this story so S5-01 is independently mergeable. The exact stub body is pinned in Implementation outline + Notes:class VulnRemediationRubric:with one methoddef score(self, case, harness_output): raise NotImplementedError("S5-02 replaces this body"). Files-to-touch table addsbench/vuln-remediation/rubric.py; S5-02 replaces the body byte-for-byte (F-CON-7).failure_modes.yamlschema ACs added (HARDEN): AC-7 pins the YAML structure: top-level isdict[str, dict[str, str]]; each entry has exactly{severity, description}keys (noowner, nosla, etc.);severity ∈ {block, warn, info}; description is a non-empty str; YAML loaded viayaml.safe_loadonly. Three parametrized tests pin each branch — an executor cannot ship a YAML that "parses but silently accepts extra keys" (F-COV-3).- BreakdownKey↔FailureMode semantic-symmetry pin (HARDEN): AC-3 + AC-5 document the four breakdown/failure-mode pairs S5-02's rubric will use (
cve.dropped↔validator.cve_not_dropped, etc.). Documentation only — does not force a test; rubric (S5-02) owns the score↔failure inversion (F-COV-4). - Double-load cache assertion (HARDEN): AC-6 +
test_double_load_task_class_does_not_raise_and_returns_same_taskclassexercise the bench-fixture-specific double-load behavior. Defense-in-depth on top of S2-01 AC-6 (F-COV-5). - Tautology in
tc.breakdown_keysassertion fixed (BLOCK): literal-set assertiontc.breakdown_keys == frozenset({"validator.build_passed", "validator.tests_passed", "cve.dropped", "recipe.applied"})+len(tc.breakdown_keys) == 4replaces the original tautologicaltc.breakdown_keys == frozenset(m.value for m in BreakdownKey). Kills empty-enum, single-member, and lowercased-member mutants (F-TQ-1). MappingProxyTypeimmutability AC (HARDEN): AC-5 + §TDD addsisinstance(tc.failure_mode_taxonomy, types.MappingProxyType)+ apytest.raises(TypeError)on attempted item assignment. Defense-in-depth on S1-03 AC-9 (F-TQ-2).- BreakdownKey ast.Constant local check (HARDEN): AC-3 +
test_breakdown_key_values_are_ast_constant_stringsASTsbreakdown_keys.pyand asserts every member-value isast.Constant. Fires before fence-CI #5 (S7-01) in a typical PR pipeline (F-TQ-3). - Rubric-class-identity AC (HARDEN): AC-1 +
test_registered_rubric_class_is_imported_class_unmodifiedassertstc.rubric_class is VulnRemediationRubric(the imported class). Guards against a regression that wraps the rubric with a side-effecting proxy (F-TQ-5). tc.bench_pathresolved-absolute AC (HARDEN): AC-2 addsassert tc.bench_path == (bench_root / "vuln-remediation").resolve()(F-TQ-6).- Runtime substring ban AC (HARDEN): AC-4 walks
tc.breakdown_keys(loader-produced) at runtime and asserts no banned substring — defense-in-depth on top of the staticBreakdownKeywalk (F-TQ-7). - Severity-shape AC (HARDEN): AC-5 asserts
for sev in tc.failure_mode_taxonomy.values(): isinstance(sev, str) and sev in {"block","warn","info"}. Guards a regression that accidentally projects{severity, description}dicts into the taxonomy value (F-TQ-8). - YAML→taxonomy helper extraction (HARDEN): Implementation outline §3 + AC-7 pin a file-local private helper
_severity_taxonomy_from_yaml(path) -> Mapping[str, Literal["block","warn","info"]]inregistration.py(NOT a shared module — Rule 2). Notes-for-implementer documents the rule-of-three lift trigger: Phase 15's task class is the third consumer; that's when the helper lifts tosrc/codegenie/eval/loader.pyas_load_failure_mode_taxonomy(the arch already names this function at line 564) (F-DP-1). min_cases_for_promotionliteral AC (NIT): AC-2 pinstc.min_cases_for_promotion == MappingProxyType({"bronze": 10, "silver": 25}). Notes-for-implementer keeps the silver-fallback instruction but pins the explicit AC change required if S5-04 slips (F-COV-6).Status:line +Depends on:+ ADRs-honored alignment with HARDENED siblings.
Design endorsements (no edit; surfaced in Notes-for-implementer):
- Open/Closed seam at the bench/{task-class}/ directory contract (F-DP-3) — Phase 7 ships the next task class by copying this story's three files verbatim; zero src/codegenie/eval/ edits.
- _MIN_CASES_FOR_PROMOTION Final constant (F-DP-2) — optional cleanup; not promoted to AC (Rule 2 — one literal today).
- Adversarial mutant catalog (F-TQ-10) — five named mutants the §TDD plan kills, surfaced in Notes-for-implementer for the executor and PR reviewers.
No NEEDS RESEARCH items — every pattern is precedented in this repo or in upstream HARDENED stories.
Context¶
Step 5 produces the worked example every Phase 7 implementer will pattern-match against. Before any cases or rubric land, the task-class identity for vuln-remediation must exist: a single register_task_class("vuln-remediation", ...)(VulnRemediationRubric) imperative-application call, a BreakdownKey StrEnum whose values pass ADR-0008's substring ban, and a failure_modes.yaml taxonomy whose entries carry severity ∈ {block, warn, info} and a non-empty description per ADR-0004. These three (plus a rubric.py stub) are the structural contract every subsequent S5-* story extends — the rubric (S5-02) replaces the stub body and emits keys constrained by BreakdownKey and codes constrained by failure_modes.yaml; the cases (S5-03/04) carry no taxonomy, but the runner validates rubric output against this taxonomy at score time.
The story is intentionally scoped tight: no cases yet, no full rubric, no E2E run. It is the identity declaration the harness needs to know vuln-remediation is a real task class with a real breakdown-key and failure-mode shape, plus a stub rubric class so the decorator's type[Rubric] requirement is satisfied at import time.
References — where to look¶
- Architecture:
../phase-arch-design.md §bench/{task-class}/directory contract— the four files this story creates and their structural roles.../phase-arch-design.md §Component design → src/codegenie/eval/loader.py— howbreakdown_keys.pyis imported andfrozenset({m.value for m in BreakdownKey})is extracted; the loader's_load_failure_mode_taxonomyhelper is the rule-of-three lift target.../phase-arch-design.md §Fence-CI test— assertions #4 (literal name only), #5 (StrEnum substring ban), #6 (taxonomy validity) all gate this story; this story's local tests are defense-in-depth on top of these.- Phase ADRs:
../ADRs/0004-per-task-class-failure-modes-taxonomy.md §Decision + §Consequences + §Tradeoffs—severity: block|warn|infoper code; non-emptydescription; loader parses intotask_class.failure_mode_taxonomy: Mapping[str, Literal["block","warn","info"]](severity only — descriptions stay in YAML for fence-CI #6); §Tradeoffs row "Codes shared across task classes ... must be replicated per task class" is why this story includes the runner-internal always-block codes (sut.timeout,rubric.unknown_breakdown_key,rubric.malformed_output).../ADRs/0006-curation-class-split-with-fence-ci-held-out-floor.md §Decision—min_cases_for_promotion["silver"]triggers the fence-CI held-out floor (≥ 5 held-out cases). Declaring silver here commits the bench to the floor S5-04 must satisfy.../ADRs/0008-breakdown-keys-strenum-with-substring-ban.md §Decision—BreakdownKeyis aStrEnum; member values (not just names) are walked by fence-CI assertion #5 forconfidence|llm|self_reported|model_says. The story-local AST walk in §TDD is defense-in-depth on the same set. Therubric.unknown_breakdown_keyblock code listed here must appear in the taxonomy.- Sibling HARDENED stories (load-bearing for this story's implementation):
S1-03-taskclass-dataclass-and-registry.md—@register_task_classHARDENED kwarg surface:(name, *, bench_path, min_cases_for_promotion, breakdown_keys, failure_mode_taxonomy, registry=None) -> Callable[[type[Rubric]], type[Rubric]]. The decorator's first positional arg is the literal task-class name; the kwargs are the data; the decorated class IS the rubric. Imperative-application form isregister_task_class(...)(VulnRemediationRubric).S2-01-bench-import-path-resolution.md—load_task_class(name, bench_root, *, registry=None) -> TaskClassis the test/CLI import surface; handles the hyphen→underscore translation, PEP 420 implicit namespace package wiring, sys.modules caching for idempotent second-load. The tests in this story go throughload_task_class, notimportlib.import_module.S1-04-rubric-protocol.md—RubricProtocol (one method,score(case, harness_output) -> BenchScore). The stub rubric class in this story satisfies it structurally.- Production ADRs:
../../../production/adrs/0008-objective-signal-trust-score.md— the upstream "no LLM self-confidence" commitment ADR-0008 structurally enforces. Phase 5 ADR-0014 — the substring-ban source-of-truth. - Source design:
../High-level-impl.md §Step 5— initial taxonomy proposal (vuln-remediation block/warn/info entries).
Goal¶
Land bench/vuln-remediation/{registration.py, breakdown_keys.py, failure_modes.yaml, rubric.py (stub)} declaring exactly one register_task_class("vuln-remediation", bench_path=..., min_cases_for_promotion={"bronze": 10, "silver": 25}, breakdown_keys=frozenset(m.value for m in BreakdownKey), failure_mode_taxonomy=_TAXONOMY)(VulnRemediationRubric) imperative-application call against the stub rubric class, a BreakdownKey StrEnum whose values pass ADR-0008's substring ban at the value level, and a failure_modes.yaml with all 11 block codes (ADR-0004 §Consequences's 8 + the 3 runner-internal always-block codes) + warn/info codes — all four files importable via the HARDENED load_task_class("vuln-remediation", bench_root=Path("bench")) surface and validated by fence-CI assertions #4–#6.
Acceptance criteria¶
- [ ] AC-1 (imperative registration call, HARDENED-S1-03-conformant):
bench/vuln-remediation/registration.pycontains exactly one call toregister_task_classwith: (a) first positional arg the literalast.Constantstring"vuln-remediation"; (b) kwargsbench_path=Path(__file__).parent,min_cases_for_promotion={"bronze": 10, "silver": 25},breakdown_keys=frozenset(m.value for m in BreakdownKey),failure_mode_taxonomy=_TAXONOMY(the file-local mapping built fromfailure_modes.yaml); (c) imperative-application formregister_task_class(...)(VulnRemediationRubric)against the rubric class imported frombench.vuln_remediation.rubric. The decorator form@register_task_class(...) class _Marker: passis forbidden — it fails S1-03'stype[Rubric]Protocol contract. AC-1 pins both the AST shape (one call, literal name) and the kwarg-name set via the local AST-walking test described in §TDD. Testtest_registered_rubric_class_is_imported_class_unmodifiedassertstc.rubric_class is VulnRemediationRubric(S1-03 AC-5 — decorator returns the class unmodified). - [ ] AC-2 (TaskClass shape post-load, six-field discipline): After
tc = load_task_class("vuln-remediation", bench_root=tmp_bench_root, registry=fresh_registry): (a)tc.name == "vuln-remediation"; (b)tc.bench_path == (tmp_bench_root / "vuln-remediation").resolve()(absolute, symlink-resolved per S2-01 AC-13); (c)tc.min_cases_for_promotion == MappingProxyType({"bronze": 10, "silver": 25})— both the literal value AND theMappingProxyTypeimmutability normalized by the decorator per S1-03 AC-9a (isinstance(tc.min_cases_for_promotion, types.MappingProxyType)); (d)dataclasses.fields(TaskClass)still has exactly six entries (the S1-03 AC-2 pin holds — this story adds no field). ADR-0006: declaring silver triggers the held-out-≥5 fence-CI assertion (#3); S5-04 must ship the 5 held-out cases before fence-CI passes. - [ ] AC-3 (BreakdownKey StrEnum, literal-values + ast.Constant + len pin):
bench/vuln-remediation/breakdown_keys.pydefinesclass BreakdownKey(StrEnum)with exactly the four membersVALIDATOR_BUILD_PASSED = "validator.build_passed",VALIDATOR_TESTS_PASSED = "validator.tests_passed",CVE_DROPPED = "cve.dropped",RECIPE_APPLIED = "recipe.applied". Testtest_breakdown_key_values_are_ast_constant_stringsASTs the module source and asserts everyAssign.valueinsideclass BreakdownKey(StrEnum)isast.Constant[str](nof"...", noprefix + suffix); defense-in-depth on fence-CI #5 (S7-01). Semantic symmetry withfailure_modes.yaml:cve.dropped↔validator.cve_not_dropped,validator.build_passed↔validator.build_failed,validator.tests_passed↔validator.tests_failed,recipe.applied↔recipe.semantic_drift— pinned as documentation; the rubric (S5-02) owns the score↔failure inversion. - [ ] AC-4 (substring ban — static + runtime): Static: every
BreakdownKeymember value passes the substring ban (confidence|llm|self_reported|model_saysabsent). Runtime:for v in tc.breakdown_keys: for banned in BANNED_SUBSTRINGS: assert banned not in v. Both layers are tested; both kill a loader-injected drift the other would miss. Testtests/unit/test_breakdown_keys_static.py(S1-05) walks every registered enum and stays green; this story's local tests are bench-specific defense-in-depth. - [ ] AC-5 (failure_modes.yaml taxonomy — 11 block + 3 warn + 2 info codes):
bench/vuln-remediation/failure_modes.yamldeclares exactly the following codes (any addition or omission fails fence-CI #6 and the local schema tests): block(11 codes — ADR-0004 §Consequences's 8 + runner-internal-always-block 3 per ADR-0004 §Tradeoffs replication requirement + ADR-0008 §Decision'srubric.unknown_breakdown_key):validator.build_failed,validator.tests_failed,validator.cve_not_dropped,recipe.semantic_drift,rubric.timeout,rubric.unknown_failure_mode,rubric.unknown_breakdown_key,rubric.malformed_output,sut.exception,sut.timeout,sut.cancelledwarn(3):recipe.unused_field,cassette.tier_mismatch,cost.over_estimateinfo(2):recipe.optimized_path,rag.first_hitEach entry hasseverity ∈ {"block", "warn", "info"}and a non-emptydescriptionstr. After load,task_class.failure_mode_taxonomy[code]returns the declared severity for every declared code; severity-shape AC:for code, sev in tc.failure_mode_taxonomy.items(): isinstance(sev, str) and sev in {"block","warn","info"}— guards against accidentally projecting the full{severity, description}dict into the taxonomy value.MappingProxyTypeimmutability normalized by the decorator (S1-03 AC-9):isinstance(tc.failure_mode_taxonomy, types.MappingProxyType);with pytest.raises(TypeError): tc.failure_mode_taxonomy["new.code"] = "block".- [ ] AC-6 (double-load idempotence — bench-fixture-specific):
tc1 = load_task_class("vuln-remediation", bench_root=tmp_bench_root, registry=fresh_registry); tc2 = load_task_class("vuln-remediation", bench_root=tmp_bench_root, registry=fresh_registry)does NOT raiseTaskClassAlreadyRegistered, returnstc1 is tc2, andtc1 is fresh_registry.get("vuln-remediation"). Defense-in-depth on S2-01 AC-6 — catches a bench-fixture-specific regression (e.g., afor _ in range(2)wrapping the imperative-application call) the loader-generic test would miss. - [ ] AC-7 (failure_modes.yaml schema fence): Three parametrized tests pin the YAML shape: (a)
test_failure_modes_yaml_top_level_is_dict_of_dicts_with_exactly_two_keys— for every entry,set(entry) == {"severity", "description"}(no extra keys); (b)test_failure_modes_yaml_severity_values_are_in_literal_set—entry["severity"] in {"block", "warn", "info"}; (c)test_failure_modes_yaml_descriptions_are_nonempty_strings—isinstance(entry["description"], str) and entry["description"].strip() != "". Plustest_failure_modes_yaml_loads_via_safe_load_onlymonkeypatchesyaml.loadto raise and asserts the read still succeeds (safe_load path taken). The YAML→taxonomy projection lives in a file-local private helper_severity_taxonomy_from_yaml(path: Path) -> Mapping[str, Literal["block","warn","info"]]so it's testable in isolation; rule-of-three lift trigger is Phase 15's task class (arch line 564 already names the lift target_load_failure_mode_taxonomy). - [ ] AC-8 (red→green pipeline, lint, typecheck): Red test from §TDD plan exists, was committed at red marker, now green.
ruff check,ruff format --check,mypy --strict bench/vuln-remediation/registration.py bench/vuln-remediation/breakdown_keys.py bench/vuln-remediation/rubric.py, andpytest tests/unit/eval/test_bench_vuln_registration.pyall pass. Fence-CI assertions #4 (literal name), #5 (BreakdownKey substring ban), #6 (taxonomy validity) all pass on these three files in S7-01's ≤ 2 s budget.
Implementation outline¶
-
Directory skeleton: Create
bench/vuln-remediation/{registration.py, breakdown_keys.py, failure_modes.yaml, rubric.py, README.md}. README is a one-paragraph stub naming what S5-02/03/04/05 will add. Do NOT createbench/__init__.pyorbench/vuln-remediation/__init__.py— S2-01 HARDENED uses PEP 420 implicit namespace packages; the second file can't exist as a valid Python module name anyway (hyphen). -
Write the red test
tests/unit/eval/test_bench_vuln_registration.pyfirst — see §TDD plan. The test file lives undertests/unit/eval/to inherit S2-01 HARDENED AC-22's autouseconftest.py(sys.path/sys.modules/default_registry snapshot+restore). -
breakdown_keys.py:"""ADR-0008 — per-task-class BreakdownKey StrEnum. Every member value is a literal ast.Constant string; substring ban (Phase 5 ADR-0014 + ADR-0008) applied at the value level. """ from enum import StrEnum class BreakdownKey(StrEnum): VALIDATOR_BUILD_PASSED = "validator.build_passed" VALIDATOR_TESTS_PASSED = "validator.tests_passed" CVE_DROPPED = "cve.dropped" RECIPE_APPLIED = "recipe.applied" -
failure_modes.yaml: Flat mapping{code: {severity: <literal>, description: <non-empty str>}}. All 11 block + 3 warn + 2 info codes per AC-5. Top-of-file comment namesADR-0004andADR-0008. Severities are literal lowercase strings. The YAML parses viayaml.safe_load. -
rubric.py(stub — S5-02 replaces the body byte-for-byte):"""Stub rubric for vuln-remediation. S5-02 replaces the body. Satisfies the Rubric Protocol (S1-04: one method, `score`) so the decorator's `type[Rubric]` requirement holds at import time. """ from typing import Any class VulnRemediationRubric: def score(self, case: Any, harness_output: Any) -> Any: raise NotImplementedError("S5-02 replaces this body") -
registration.py(imperative-application form per HARDENED S1-03):"""ADR-0004, ADR-0006, ADR-0008 — task-class identity for vuln-remediation. Decoration is via the imperative-application form. The decorator's kwarg surface is S1-03 HARDENED; the loader (S2-01) is the import surface. """ from pathlib import Path from types import MappingProxyType from typing import Final, Literal, Mapping import yaml from codegenie.eval.registry import register_task_class from bench.vuln_remediation.breakdown_keys import BreakdownKey from bench.vuln_remediation.rubric import VulnRemediationRubric _HERE: Final[Path] = Path(__file__).parent def _severity_taxonomy_from_yaml( path: Path, ) -> Mapping[str, Literal["block", "warn", "info"]]: """File-local helper. Rule-of-three lift trigger: Phase 15's task class. At that point this moves to `src/codegenie/eval/loader.py` as `_load_failure_mode_taxonomy` (arch line 564 names the target).""" raw = yaml.safe_load(path.read_text()) if not isinstance(raw, dict): raise ValueError(f"failure_modes.yaml top-level must be dict, got {type(raw).__name__}") return MappingProxyType({code: spec["severity"] for code, spec in raw.items()}) _TAXONOMY: Final[Mapping[str, Literal["block", "warn", "info"]]] = ( _severity_taxonomy_from_yaml(_HERE / "failure_modes.yaml") ) register_task_class( "vuln-remediation", bench_path=_HERE, min_cases_for_promotion={"bronze": 10, "silver": 25}, breakdown_keys=frozenset(m.value for m in BreakdownKey), failure_mode_taxonomy=_TAXONOMY, )(VulnRemediationRubric) -
Run
mypy --strictandpytest tests/unit/eval/test_bench_vuln_registration.py; iterate to green.
TDD plan — red / green / refactor¶
Red — write the failing test first¶
Test file path: tests/unit/eval/test_bench_vuln_registration.py (under tests/unit/eval/ to inherit S2-01 HARDENED AC-22's autouse conftest.py).
# tests/unit/eval/test_bench_vuln_registration.py
import ast
import types
from enum import StrEnum
from pathlib import Path
from types import MappingProxyType
import pytest
import yaml
# BANNED_SUBSTRINGS mirrors Phase 5 ADR-0014 + ADR-0008 §Decision; any change
# here must amend both ADRs.
BANNED_SUBSTRINGS = ("confidence", "llm", "self_reported", "model_says")
EXPECTED_BREAKDOWN_VALUES = frozenset({
"validator.build_passed",
"validator.tests_passed",
"cve.dropped",
"recipe.applied",
})
REQUIRED_BLOCK_CODES = frozenset({
# ADR-0004 §Consequences's initial taxonomy
"validator.build_failed",
"validator.tests_failed",
"validator.cve_not_dropped",
"recipe.semantic_drift",
"rubric.timeout",
"rubric.unknown_failure_mode",
"sut.exception",
"sut.cancelled",
# ADR-0004 §Tradeoffs replication requirement: runner-internal always-block
"sut.timeout",
"rubric.unknown_breakdown_key", # ADR-0008 §Decision
"rubric.malformed_output",
})
REQUIRED_WARN_CODES = frozenset({
"recipe.unused_field",
"cassette.tier_mismatch",
"cost.over_estimate",
})
REQUIRED_INFO_CODES = frozenset({
"recipe.optimized_path",
"rag.first_hit",
})
ALL_REQUIRED_CODES = REQUIRED_BLOCK_CODES | REQUIRED_WARN_CODES | REQUIRED_INFO_CODES
@pytest.fixture()
def fresh_registry():
"""The autouse conftest at tests/unit/eval/conftest.py (S2-01 HARDENED AC-22)
snapshots sys.path/sys.modules and monkeypatches default_registry. This
fixture exposes the fresh default_registry for direct assertions."""
from codegenie.eval.registry import default_registry as reg
return reg
@pytest.fixture()
def bench_root(tmp_path: Path) -> Path:
"""Materialize bench/vuln-remediation/* under tmp_path, mirroring the
real on-disk fixture. The loader resolves the hyphenated leaf via
spec_from_file_location (S2-01 AC-9)."""
bench = tmp_path / "bench"
src = Path(__file__).parents[3] / "bench" / "vuln-remediation"
target = bench / "vuln-remediation"
target.mkdir(parents=True)
for name in ("registration.py", "breakdown_keys.py", "failure_modes.yaml", "rubric.py"):
(target / name).write_bytes((src / name).read_bytes())
return bench
# --- AC-1 / AC-2 — registered TaskClass shape ----------------------------------
def test_registration_via_load_task_class_uses_literal_name_and_promotion(
bench_root: Path, fresh_registry,
):
from codegenie.eval.loader import load_task_class
tc = load_task_class("vuln-remediation", bench_root=bench_root, registry=fresh_registry)
assert tc.name == "vuln-remediation"
assert tc.bench_path == (bench_root / "vuln-remediation").resolve()
# ADR-0006: declaring silver commits to held-out-≥5 fence (S5-04).
assert tc.min_cases_for_promotion == MappingProxyType({"bronze": 10, "silver": 25})
assert isinstance(tc.min_cases_for_promotion, types.MappingProxyType)
def test_registered_rubric_class_is_imported_class_unmodified(
bench_root: Path, fresh_registry,
):
from codegenie.eval.loader import load_task_class
tc = load_task_class("vuln-remediation", bench_root=bench_root, registry=fresh_registry)
# S1-03 AC-5: decorator returns the class unmodified — `is`, not `==`.
from bench.vuln_remediation.rubric import VulnRemediationRubric
assert tc.rubric_class is VulnRemediationRubric
# --- AC-3 — BreakdownKey StrEnum shape ----------------------------------------
def test_breakdown_key_strenum_has_expected_four_members(bench_root: Path, fresh_registry):
from codegenie.eval.loader import load_task_class
load_task_class("vuln-remediation", bench_root=bench_root, registry=fresh_registry)
from bench.vuln_remediation.breakdown_keys import BreakdownKey
assert issubclass(BreakdownKey, StrEnum)
values = frozenset(m.value for m in BreakdownKey)
assert values == EXPECTED_BREAKDOWN_VALUES
assert len(values) == 4
def test_breakdown_key_values_are_ast_constant_strings(bench_root: Path):
"""Defense-in-depth on fence-CI #5 (S7-01). Catches f-string / concat mutants."""
src = (bench_root / "vuln-remediation" / "breakdown_keys.py").read_text()
tree = ast.parse(src)
enum_cls = next(
n for n in ast.walk(tree)
if isinstance(n, ast.ClassDef) and n.name == "BreakdownKey"
)
for node in enum_cls.body:
if isinstance(node, ast.Assign):
assert isinstance(node.value, ast.Constant), (
f"BreakdownKey.{node.targets[0].id} value is not ast.Constant"
)
assert isinstance(node.value.value, str)
# --- AC-4 — substring ban (static + runtime) ---------------------------------
def test_breakdown_key_strenum_static_substring_ban(bench_root: Path, fresh_registry):
from codegenie.eval.loader import load_task_class
load_task_class("vuln-remediation", bench_root=bench_root, registry=fresh_registry)
from bench.vuln_remediation.breakdown_keys import BreakdownKey
for m in BreakdownKey:
for banned in BANNED_SUBSTRINGS:
assert banned not in m.value, (
f"banned substring {banned!r} in BreakdownKey.{m.name} value {m.value!r}"
)
def test_breakdown_keys_runtime_substring_ban(bench_root: Path, fresh_registry):
"""Runtime-side: loader-produced tc.breakdown_keys, not the static enum."""
from codegenie.eval.loader import load_task_class
tc = load_task_class("vuln-remediation", bench_root=bench_root, registry=fresh_registry)
for v in tc.breakdown_keys:
for banned in BANNED_SUBSTRINGS:
assert banned not in v
# --- AC-5 — failure_modes.yaml taxonomy + immutability ------------------------
def test_failure_modes_taxonomy_has_every_required_code_with_correct_severity(
bench_root: Path, fresh_registry,
):
from codegenie.eval.loader import load_task_class
tc = load_task_class("vuln-remediation", bench_root=bench_root, registry=fresh_registry)
tax = tc.failure_mode_taxonomy
for code in REQUIRED_BLOCK_CODES:
assert tax[code] == "block", f"{code} should be block-severity"
for code in REQUIRED_WARN_CODES:
assert tax[code] == "warn"
for code in REQUIRED_INFO_CODES:
assert tax[code] == "info"
# The set is exactly what we require — no silent extras, no gaps.
assert set(tax.keys()) == set(ALL_REQUIRED_CODES)
def test_failure_mode_taxonomy_value_shape_is_literal_str_severity(
bench_root: Path, fresh_registry,
):
"""Guards against accidentally projecting the full {severity, description}
dict into the taxonomy value instead of the bare severity."""
from codegenie.eval.loader import load_task_class
tc = load_task_class("vuln-remediation", bench_root=bench_root, registry=fresh_registry)
for code, sev in tc.failure_mode_taxonomy.items():
assert isinstance(sev, str), f"{code} severity is not str: {type(sev).__name__}"
assert sev in {"block", "warn", "info"}
def test_failure_mode_taxonomy_is_mapping_proxy_type(bench_root: Path, fresh_registry):
"""S1-03 AC-9 typed-at-the-edge: decorator normalizes to MappingProxyType."""
from codegenie.eval.loader import load_task_class
tc = load_task_class("vuln-remediation", bench_root=bench_root, registry=fresh_registry)
assert isinstance(tc.failure_mode_taxonomy, types.MappingProxyType)
with pytest.raises(TypeError):
tc.failure_mode_taxonomy["new.code"] = "block" # type: ignore[index]
# --- AC-6 — double-load idempotence ------------------------------------------
def test_double_load_task_class_does_not_raise_and_returns_same_taskclass(
bench_root: Path, fresh_registry,
):
"""Defense-in-depth on S2-01 AC-6 — bench-fixture-specific."""
from codegenie.eval.loader import load_task_class
tc1 = load_task_class("vuln-remediation", bench_root=bench_root, registry=fresh_registry)
tc2 = load_task_class("vuln-remediation", bench_root=bench_root, registry=fresh_registry)
assert tc1 is tc2
assert tc1 is fresh_registry.get("vuln-remediation")
# --- AC-7 — failure_modes.yaml schema fence ----------------------------------
def _load_yaml(bench_root: Path) -> dict:
return yaml.safe_load(
(bench_root / "vuln-remediation" / "failure_modes.yaml").read_text(),
)
def test_failure_modes_yaml_top_level_is_dict_of_dicts_with_exactly_two_keys(
bench_root: Path,
):
raw = _load_yaml(bench_root)
assert isinstance(raw, dict)
for code, entry in raw.items():
assert isinstance(entry, dict), f"{code} entry is not a dict"
assert set(entry.keys()) == {"severity", "description"}, (
f"{code} has unexpected keys: {set(entry.keys())}"
)
@pytest.mark.parametrize("legal_severity", ["block", "warn", "info"])
def test_failure_modes_yaml_severity_values_are_in_literal_set(
bench_root: Path, legal_severity: str,
):
raw = _load_yaml(bench_root)
severities = {entry["severity"] for entry in raw.values()}
assert severities <= {"block", "warn", "info"}
# And every legal severity actually appears (sanity).
assert legal_severity in severities
def test_failure_modes_yaml_descriptions_are_nonempty_strings(bench_root: Path):
"""ADR-0004 §Decision: every entry has a non-empty description.
Validated by fence-CI #6 (S7-01); this story's local test is defense-in-depth."""
raw = _load_yaml(bench_root)
for code, entry in raw.items():
assert isinstance(entry["description"], str), f"{code} description is not str"
assert entry["description"].strip() != "", f"{code} description is empty"
def test_failure_modes_yaml_loads_via_safe_load_only(
bench_root: Path, monkeypatch: pytest.MonkeyPatch,
):
"""If unsafe yaml.load were used, this monkeypatch would fail the read."""
monkeypatch.setattr(
yaml, "load",
lambda *a, **kw: pytest.fail("yaml.load called; safe_load discipline broken"),
)
# safe_load is what registration.py uses; this should not trip the monkeypatch.
raw = yaml.safe_load(
(bench_root / "vuln-remediation" / "failure_modes.yaml").read_text(),
)
assert isinstance(raw, dict)
Run it; confirm ModuleNotFoundError (the registration module doesn't exist yet) or BenchCaseLoadError if the loader is in place but the bench dir empty. Commit as red marker.
Green — smallest impl shape¶
- Create the four files in
bench/vuln-remediation/per §Implementation outline §1, §3, §4, §5, §6. failure_modes.yamlas flat mapping{code: {severity, description}}— all 13 codes (11 block + 2 warn missing actually 3 warn + 2 info = 16 codes total).- Wait — recount: 11 block + 3 warn + 2 info = 16 codes total. The YAML must declare exactly these 16; no more, no fewer.
- The imperative-application call in
registration.pyruns once — Python's sys.modules cache (set up by S2-01's loader) prevents re-execution on subsequentload_task_classcalls.
Refactor — clean up¶
- Module docstrings on
registration.py,breakdown_keys.py,rubric.pycite ADR-0004, ADR-0006, ADR-0008. failure_modes.yamltop-of-file comment names ADR-0004 + ADR-0008.- Type-narrow the
min_cases_for_promotionliteral so mypy--strictaccepts it. - The README stub names what S5-02/03/04/05 will add; do not include cases or rubric details — those land in their stories.
- Optional cleanup (NOT promoted to AC): lift
min_cases_for_promotion={"bronze": 10, "silver": 25}to a module-level_MIN_CASES_FOR_PROMOTION: Final[Mapping[str, int]] = MappingProxyType({"bronze": 10, "silver": 25})so the silver-fallback edit is a one-line constant change. Rule 2 — only one literal today.
Files to touch¶
| Path | Why |
|---|---|
bench/vuln-remediation/registration.py |
New file — imperative-application register_task_class("vuln-remediation", ...)(VulnRemediationRubric) + _severity_taxonomy_from_yaml helper |
bench/vuln-remediation/breakdown_keys.py |
New file — BreakdownKey StrEnum with 4 literal-value members |
bench/vuln-remediation/failure_modes.yaml |
New file — full taxonomy (11 block + 3 warn + 2 info codes) with severity + non-empty description per code |
bench/vuln-remediation/rubric.py |
New file — stub VulnRemediationRubric (one score method raising NotImplementedError); S5-02 replaces the body byte-for-byte |
bench/vuln-remediation/README.md |
New file — stub naming what S5-02/03/04/05 add |
tests/unit/eval/test_bench_vuln_registration.py |
New file — pins identity, StrEnum, taxonomy, YAML schema, immutability, double-load |
Out of scope¶
- The rubric implementation. S5-02 replaces
bench/vuln-remediation/rubric.py's body byte-for-byte. This story ships the stub so S5-01 is independently mergeable; the stub satisfies S1-04's Rubric Protocol structurally (onescoremethod) so the decorator'stype[Rubric]requirement holds at import time. S5-02 is NOT a precondition for S5-01 to merge. - Bench cases. S5-03 and S5-04 land cases.
digests.yaml. S5-05 signs cases; no cases exist yet.- Cassette pin selection. Story-level decision is "every case will carry a 32-hex
cassette_canary_pin"; the values are the cases' problem (S5-03/04). - Wiring into
codegenie eval run. Already wired by S4-02; this story does not modify CLI or runner code. - Fence-CI implementation. S7-01 owns the six fence-CI assertions; this story's local tests are defense-in-depth, not the fence itself.
- Rule-of-three lift of
_severity_taxonomy_from_yaml. Today there are two consumers (vuln-remediation + migration-chainguard-distroless); Phase 15's task class is the third. At that point the helper moves tosrc/codegenie/eval/loader.pyas_load_failure_mode_taxonomy(the arch already names the function at line 564).
Notes for the implementer¶
@register_task_classHARDENED kwarg surface (S1-03 line 100-110). The decorator's signature isregister_task_class(name, *, bench_path, min_cases_for_promotion, breakdown_keys, failure_mode_taxonomy, registry=None) -> Callable[[type[Rubric]], type[Rubric]]. Use the imperative-application formregister_task_class(...)(VulnRemediationRubric)because the rubric class lives in a separaterubric.pyfile. The@register_task_class(...) class _Marker: passpattern is forbidden — the marker class fails S1-03'srubric_class: type[Rubric]Protocol contract (mypy --strict would reject), and the decorator's behavior under it is to register the marker as the rubric, which the runner cannot subprocess-invoke.- Loader is the test/CLI import surface. Standard Python
importlib.import_module("bench.vuln_remediation.registration")cannot resolve the hyphenated on-disk directorybench/vuln-remediation/— the hyphen→underscore translation is HARDENED S2-01's job viaspec_from_file_location. Tests and CLI both go throughload_task_class. Directimportlibcalls in tests will fail withModuleNotFoundError. - Hyphen vs underscore — both forms, in different contexts. On-disk:
bench/vuln-remediation/(hyphen — directory naming convention; matches the registered slug). In code:bench.vuln_remediation.X(underscore — Python identifier discipline). The two forms are NOT alternatives; both must be used in their respective contexts. S2-01 HARDENED AC-9 owns the translation. - Do NOT create
bench/__init__.pyorbench/vuln-remediation/__init__.py. S2-01 HARDENED uses PEP 420 implicit namespace packages — no__init__.pyfiles anywhere inbench/. The hyphenated leaf can't have one anyway (invalid Python identifier). - Descriptions in YAML; severities on TaskClass. Per ADR-0004 §Consequences,
task_class.failure_mode_taxonomy: Mapping[str, Literal["block","warn","info"]]carries severity only. The full{severity, description}shape lives in the YAML and is validated by fence-CI #6 (and by this story's localtest_failure_modes_yaml_descriptions_are_nonempty_strings). Do not invent afailure_mode_descriptionsfield on TaskClass — S1-03 AC-2 pins the six-field set; adding a 7th breaks the structural assertion. - Substring ban applies to values, not names.
STYLE_QUALITY = "llm_confidence"is the failure mode the fence catches — a member namedSTYLE_QUALITYis harmless if its value is, e.g.,"style.quality". Reviewers readingbreakdown_keys.pyshould be able to see every value at a glance — keep them on one line each. Source-of-truth for the substring list is Phase 5 ADR-0014 + ADR-0008; any change there amends both. - Declaring
"silver": 25inmin_cases_for_promotionis an explicit ADR-0006 commitment that S5-04's 5 held-out cases must land before fence-CI passes. If S5-04 slips and you cannot ship 5 held-out cases in the same train, drop"silver"frommin_cases_for_promotion(ship{"bronze": 10}only) — adding silver later is one line; shipping silver without held-out floor fails fence-CI #3 and blocks the phase merge. If you dropsilver, also update AC-2 in this story to removesilver(the literal-equality assertion would otherwise fail). - Why all 11 block codes are required. ADR-0004 §Tradeoffs explicitly requires codes shared across task classes (runner-internal always-block:
sut.exception,sut.timeout,rubric.timeout,rubric.unknown_failure_mode,rubric.unknown_breakdown_key,rubric.malformed_output) to be replicated per task class. Withoutsut.timeoutin this YAML, the first SUT subprocess timeout in production emitssut.timeout→ resolver sees unknown code → falls back torubric.unknown_failure_mode(block) → operator gets a misleading error. ADR-0008 §Decision addsrubric.unknown_breakdown_keyto the always-block set. bench.vuln_remediation.rubricstub-replacement contract. S5-02 replaces the body byte-for-byte. Until then this story's stub raisesNotImplementedError("S5-02 replaces this body")fromscore(...). The Protocol contract (S1-04) is satisfied structurally (one method with the right name) — mypy --strict accepts it; the registry stores it; runtime invocation would raise the stub's error, which is the correct fail-loud behavior pre-S5-02.- Extension-by-addition seam at the
bench/{task-class}/directory contract. Phase 7 shipsbench/migration-chainguard-distroless/by copying these three files (+stub) verbatim. Zero edits tosrc/codegenie/eval/should be required. If Phase 7 needs a kernel edit, that's a contract surface bug to fix here, not there. - Adversarial mutant catalog (this story's §TDD plan kills these five):
- Empty BreakdownKey —
class BreakdownKey(StrEnum): pass. Killed by AC-3's literal-set equality. - BreakdownKey value list of length 1 — only
VALIDATOR_BUILD_PASSEDshipped. Killed by AC-3'slen == 4. failure_modes.yamlwithseverity: "fatal"— out of Literal set. Killed by AC-7's severity-set assertion.failure_modes.yamlentry with extraownerkey — silent schema drift. Killed by AC-7's exact-key-set assertion.registration.pydecorating a marker class — fails Protocol contract; runner can't invoke. Killed by AC-1'stc.rubric_class is VulnRemediationRubric.