Story S6-01 — bench/migration-chainguard-distroless/ registration, taxonomies, and stub rubric¶
Step: Step 6 — Seed bench/migration-chainguard-distroless/
Status: HARDENED (phase-story-validator, 2026-07-25)
Effort: M
Depends on: S1-03 HARDENED (@register_task_class kwarg surface: breakdown_keys, failure_mode_taxonomy, bench_path, min_cases_for_promotion, imperative-application form against a type[Rubric]), S1-04 (Rubric Protocol — the stub rubric class must satisfy it structurally), S2-01 HARDENED (load_task_class is the import surface for tests; hyphen→underscore translation lives there; PEP 420 implicit namespace package contract — no __init__.py anywhere under bench/), S5-01 HARDENED (the mirrored pattern this story extends by addition). Transitively: S5-02 HARDENED (subprocess-entrypoint discipline for a working rubric — this story ships the second such rubric).
ADRs honored: ADR-0001 (subprocess rubric; if __name__ == "__main__"; no module-level side effects; non-zero exit on malformed stdin), ADR-0004 (failure_modes.yaml taxonomy; runner-internal always-block codes replicated per task class per §Tradeoffs; runtime taxonomy is severity-only, descriptions live in YAML for fence-CI #6), ADR-0006 (curation-class split — bronze-only floor keeps held-out ≥5 fence-CI inactive for this task class until Phase 7 raises the bar), ADR-0008 (BreakdownKey StrEnum + substring ban at value level; rubric.unknown_breakdown_key block code required in the taxonomy), Phase 5 ADR-0014 (substring-ban source-of-truth shared with ADR-0008)
Validation notes¶
Validated: 2026-07-25 Verdict: HARDENED Findings addressed: 21 total — 8 blocks, 10 hardens, 3 nits
Changes applied (full audit log: _validation/S6-01-distroless-registration-and-stub-rubric.md):
- Status line updated to
HARDENED (phase-story-validator, 2026-07-25)(F-N-3). - Depends-on rewritten to name S1-03 HARDENED, S1-04, S2-01 HARDENED, S5-01 HARDENED (F-CON-3). The original single-line "S5-05" was misleading — this story mirrors S5-01's pattern, not S5-05's E2E surface.
bench/migration-chainguard-distroless/__init__.py+.../cases/__init__.pyhard-banned (BLOCK, F-CON-1): the original Files-to-touch listed both as "package markers." That directly contradicts HARDENED S2-01 + HARDENED S5-01's PEP 420 discipline — no__init__.pyfiles anywhere underbench/. The hyphenated directory can't have one (invalid Python identifier) and the underscore-named cases/ variant would break S2-01's namespace-package contract. Rows removed; hard-ban paragraph added to Notes-for-implementer.- Red test module prefix fixed (BLOCK, F-CON-2): original Red test imported
_codegenie_bench.migration_chainguard_distroless.rubric— that prefix does not exist in the codebase. HARDENED S5-01 imports throughbench.<underscore-slug>.*afterload_task_class(...)has resolved the on-disk hyphenated directory viaspec_from_file_location. Red test rewritten to callload_task_class("migration-chainguard-distroless", bench_root=...)and then importbench.migration_chainguard_distroless.rubric— mirroring S5-01 F-CON-3. - Imperative-application form pinned (BLOCK, F-COV-1): original ACs did not pin
register_task_class(...)(MigrationChainguardDistrolessRubric). An executor could ship@register_task_class(...) class _Marker: passand every original AC would pass — but S1-03'stype[Rubric]Protocol contract would fail at runner invocation. AC-1 now pins (a) exactly one call toregister_task_class, (b) first positional arg isast.Constant["migration-chainguard-distroless"], (c) imperative-application form applied to the rubric class imported frombench.migration_chainguard_distroless.rubric. - Rubric-class-identity AC added (BLOCK, F-COV-2): AC-1 asserts
tc.rubric_class is MigrationChainguardDistrolessRubric(S1-03 AC-5 — decorator returns the class unmodified). Guards against a regression that wraps the rubric with a side-effecting proxy. MappingProxyTypeimmutability ACs added (BLOCK, F-COV-3): original ACs did not assertisinstance(tc.min_cases_for_promotion, types.MappingProxyType)orisinstance(tc.failure_mode_taxonomy, types.MappingProxyType), nor did they assert attempted mutation raisesTypeError. S1-03 AC-9 pins both; without the assertions here, an executor could ship a plaindictand S1-03's typed-at-the-edge contract silently breaks. AC-2 + AC-6 pin both layers.- Always-block codes replicated per task class (BLOCK, F-COV-4): ADR-0004 §Tradeoffs explicitly requires runner-internal always-block codes to be replicated per task class. Original story listed only 3 migration-specific block codes. Without
sut.timeout(and the six other always-block codes) in this YAML, the first SUT subprocess timeout in Phase 7 production emitssut.timeout→ resolver sees unknown code → falls back torubric.unknown_failure_mode→ operator gets a misleading error. AC-5 expanded to require exactly 3 migration-specific + 7 runner-internal always-block block codes (10 total) plusmigration.dockerfile_unparseablewarn. __main__non-zero exit on malformed stdin (BLOCK, F-COV-5): ADR-0001 §Consequences pinsrubric.malformed_outputas the runner's reaction to non-zero rubric exit. S5-02 AC-2 pins the discipline. Story did not have an AC for this — atry/exceptswallow would silently pass. AC-4 +test_main_exits_nonzero_on_malformed_envelope_jsonmirror S5-02.- YAML schema exact-key strictness (HARDEN, F-COV-6): S5-01 AC-7 pins three parametrized tests on the YAML shape. Story asserted only severity + description informally; extras like
owner:would slip through. AC-7 now pins the three: (a) exact{severity, description}per entry, (b) severity in literal set, (c) non-empty string description. Plus ayaml.safe_loadmonkeypatch discipline test (F-TQ-5). - Taxonomy value shape AC (HARDEN, F-COV-7): AC-5 asserts
for code, sev in tc.failure_mode_taxonomy.items(): isinstance(sev, str) and sev in {"block","warn","info"}. Guards against the loader accidentally projecting the full{severity, description}dict into the taxonomy value. - Double-load idempotence AC (HARDEN, F-COV-8): AC-8 mirrors S5-01 AC-6 —
tc1 is tc2andtc1 is registry.get(name). Defense-in-depth on S2-01 AC-6, bench-fixture-specific. - Unverifiable "or the existing fence" hedge removed (HARDEN, F-COV-9): original AC-6 said "…extended to walk this task class proves it (or the existing fence already does — verify which)." Ambiguity is not a spec. Rewritten to name the deterministic behavior: per S1-05,
tests/unit/test_breakdown_keys_static.pyauto-discovers registered task classes — a properly-wired registration is picked up without a story-local extension. AC-9 pins runtime substring-ban test locally as defense-in-depth. - "No module-level I/O" test pinned (HARDEN, F-COV-10): original Implementation outline §5 said "Determinism is on the bench-author's shoulders" — enforcement was aspirational. ADR-0001 requires no module-level side effects; an executor could ship
open(config, "r")at module top-level and break subprocess startup latency + load-order guarantees. AC-10 +test_rubric_module_has_no_module_level_io_or_side_effectsAST-walk the top-level nodes and rejectCalloutside function/class defs, plus rejecttime/random/uuid/os.environimports/reads. Mirrors S5-02 AC-9. - Semantic-symmetry inversions pinned as AC (HARDEN, F-COV-11): original story had three signals but never paired them with failure codes explicitly. AC-11 documents the three inversions:
base_image_swapped ↔ migration.base_image_not_chainguard,shell_free ↔ migration.shell_invocation_present,build_passes ↔ migration.build_failed. Documentation-only for this story; Phase 7 owns rubric-hardening enforcement. - Tautology
hasattr(mod, "__name__")replaced (HARDEN, F-TQ-1): originaltest_distroless_rubric_is_subprocess_entrypoint_onlyassertedhasattr(mod, "__name__")— every Python module has__name__. Replaced with an AST-walk: (a)if __name__ == "__main__":block exists at module top-level, (b) no top-levelCallinvokesscore(...)outside the__main__guard. breakdown_keysassertion tightened from subset → equality (HARDEN, F-TQ-2): originalrequired.issubset(tc.breakdown_keys)passes for any superset; kills no mutants. Assertion changed to exact set + length pin:tc.breakdown_keys == frozenset({"base_image_swapped", "shell_free", "build_passes"})andlen == 3. Kills empty-enum, single-member, and lowercased-member mutants.failure_modesexistence tightened from subset → exact (HARDEN, F-TQ-3): original iteration over 3 required codes silently accepts a rubric author who omitsmigration.build_failed. Combined with F-COV-4's expansion, AC-5 now assertsset(tc.failure_mode_taxonomy.keys()) == REQUIRED_ALL_CODES.BreakdownKeyast.Constant local check (HARDEN, F-TQ-4): AC-3 +test_breakdown_key_values_are_ast_constant_stringswalks the module source and asserts everyAssign.valueinsideclass BreakdownKey(StrEnum)isast.Constant[str]. Fires before fence-CI #5 (S7-01) in a typical PR pipeline; kills f-string / concat / prefix+suffix mutants._severity_taxonomy_from_yamlhelper pinned (HARDEN, F-DP-2): original Implementation outline had inline YAML loading. HARDENED S5-01 uses a file-local private helper_severity_taxonomy_from_yaml(path) -> Mapping[str, Literal["block","warn","info"]]. Story is the SECOND consumer (S5-01 was the first); Phase 15's third task class is the rule-of-three lift trigger to_load_failure_mode_taxonomyinsrc/codegenie/eval/loader.py. Implementation outline §2 pins the helper verbatim; Notes-for-implementer names the deferred lift target.- Score-formula extension-friendliness (HARDEN, F-DP-1): original "
score=mean_of_three_booleans" hard-codes the signal count — Phase 7 adding a 4th signal would silently corrupt scores. Amended toscore = statistics.mean(breakdown.values())— extends automatically when a newBreakdownKeymember ships. - Test file relocated (NIT, F-N-1):
tests/unit/test_distroless_registration.py→tests/unit/eval/test_bench_distroless_registration.py. Inherits S2-01 HARDENED AC-22's autouseconftest.py(sys.path/sys.modules/default_registry snapshot+restore). - README.md row added to Files-to-touch (NIT, F-N-2):
bench/migration-chainguard-distroless/README.md— stub naming S6-02 (3 held-out cases + digests) and S6-03 (E2E + verdict).
Design endorsements (no edit; surfaced in Notes-for-implementer):
- Extension-by-addition seam at bench/{task-class}/ — S6-01 is the second consumer of the S5-01 seam. Zero edits to src/codegenie/eval/ required. Phase 7's rubric-hardening (semver checks on Chainguard tags, multi-stage detection, RUNTIME_CAPABILITY_MATCH) extends this task class's four files.
- Functional-core / imperative-shell — score() pure, __main__ shell. Same discipline S5-02 landed for vuln-remediation. F-TQ-6 (a _dockerfile_facts frozen-dataclass extractor) is a design opportunity Phase 7 may take when the rubric grows — not promoted to AC here (Rule 2 — three lines is fine today).
- Adversarial mutant catalog (this story's §TDD plan kills these five):
1. Empty BreakdownKey — class BreakdownKey(StrEnum): pass. Killed by AC-3's exact-set equality.
2. BreakdownKey subset (only BASE_IMAGE_SWAPPED) — Killed by AC-3's len == 3.
3. failure_modes.yaml with extra owner: key per entry — silent schema drift. Killed by AC-7's exact-key-set assertion.
4. registration.py decorating a marker class — fails Protocol contract; runner can't invoke. Killed by AC-1's tc.rubric_class is MigrationChainguardDistrolessRubric.
5. Rubric wraps score(...) in try/except Exception: return passing_score — swallows internal failure. Killed by AC-4's test_main_exits_nonzero_on_malformed_envelope_json (partial — full catch requires S6-02's held-out cases exercising real signal paths).
No NEEDS RESEARCH items — every pattern is precedented in HARDENED S5-01 + S5-02 + S1-03 + S2-01.
Context¶
Phase 7 introduces migration-chainguard-distroless as the second task class without editing any Phase 0–6 source — the extension-by-addition invariant in CLAUDE.md. For that to work, Phase 6.5 must ship a complete directory skeleton (registration.py, breakdown_keys.py, failure_modes.yaml, rubric.py) that fence-CI is already asserting against and that mirrors the bench/vuln-remediation/ pattern landed in S5-01/S5-02. The rubric is a working stub: at N=3 held-out cases (S6-02) it only needs to demonstrate the subprocess contract and the three Dockerfile-derived signals; Phase 7 will harden scoring as the corpus grows.
The registration declares only bronze: 10 in min_cases_for_promotion — silver/gold are Phase 7's call. This deliberately keeps fence-CI assertion #3 (held-out floor ≥ 5 for tier ≥ silver) inactive for this task class until Phase 7 raises the bar.
Unlike S5-01's NotImplementedError stub, this story's rubric ships a working stub — three coarse signals (regex on FROM line, regex on RUN sh|bash, expected/build.log last-line check). That distinction matters: S5-02 replaced S5-01's stub body byte-for-byte later; here the working stub is the initial rubric, and S6-02's held-out cases exercise it end-to-end without a separate story replacing the body. Phase 7 hardens the signals in place (semver checks on Chainguard tags, multi-stage detection, RUNTIME_CAPABILITY_MATCH) — those edits stay inside bench/migration-chainguard-distroless/rubric.py.
References — where to look¶
- Architecture:
../phase-arch-design.md §"bench/{task-class}/ directory contract"— the four files this story creates and their structural roles; fence-CI walks the directory shape.../phase-arch-design.md §"What new task classes will need" §Step 1(bench/migration-chainguard-distroless/ subsection).../phase-arch-design.md §"Component design → loader.py"— how_load_breakdown_keysand_load_failure_mode_taxonomyconsume these files; the loader's_load_failure_mode_taxonomyhelper is the rule-of-three lift target (arch line 564).../phase-arch-design.md §"Scenarios → Scenario 3"— fence-CI walkingbench/*/registration.py.../phase-arch-design.md §Fence-CI test— assertions #4 (literal name), #5 (StrEnum substring ban), #6 (taxonomy validity) all gate this story; local tests are defense-in-depth.- Phase ADRs:
../ADRs/0001-rubric-execution-isolation-via-subprocess.md §Decision, §Consequences—if __name__ == "__main__"entrypoint; JSON over stdin/stdout; no module-level side effects; non-zero exit onjson.JSONDecodeError/pydantic.ValidationError(the runner reacts withrubric.malformed_output).../ADRs/0004-per-task-class-failure-modes-taxonomy.md §Decision + §Consequences + §Tradeoffs— YAML schema (severity ∈ {block, warn, info}, non-emptydescription); §Tradeoffs (last row) requires runner-internal always-block codes to be replicated per task class.../ADRs/0006-curation-class-split-with-fence-ci-held-out-floor.md §Decision— declaring silver commits to held-out ≥ 5; this story declares bronze only, so the fence stays inactive here until Phase 7.../ADRs/0008-breakdown-keys-strenum-with-substring-ban.md §Decision—BreakdownKeyStrEnum member values (not just names) are walked;rubric.unknown_breakdown_keyblock code required in the taxonomy.- Sibling HARDENED stories (load-bearing for this story's implementation):
S1-03-taskclass-dataclass-and-registry.md— HARDENED decorator kwarg surface; imperative-application form;MappingProxyTypenormalization; decorated class returned unmodified.S1-04-rubric-protocol.md—RubricProtocol (one method,score(case, harness_output) -> BenchScore).S2-01-bench-import-path-resolution.md—load_task_classis the import surface; hyphen→underscore translation; PEP 420 implicit namespace package (NO__init__.pyunderbench/); autouse conftest attests/unit/eval/.S5-01-vuln-registration-and-taxonomies.md— HARDENED pattern this story mirrors byte-for-byte in structure.S5-02-vuln-rubric-and-unit-tests.md— HARDENED subprocess-CLI discipline for a working rubric (this story's rubric ships in one shot, but the same subprocess contract applies).- Production ADRs:
../../../production/adrs/0008-objective-signal-trust-score.md— facts not judgments; Dockerfile-derived signals are the right shape. - Source design:
../High-level-impl.md §"Step 6". - Existing precedent:
bench/vuln-remediation/{registration.py, rubric.py, breakdown_keys.py, failure_modes.yaml}from S5-01/S5-02.
Goal¶
Land bench/migration-chainguard-distroless/{registration.py, breakdown_keys.py, failure_modes.yaml, rubric.py} declaring exactly one register_task_class("migration-chainguard-distroless", bench_path=..., min_cases_for_promotion={"bronze": 10}, breakdown_keys=frozenset(m.value for m in BreakdownKey), failure_mode_taxonomy=_TAXONOMY)(MigrationChainguardDistrolessRubric) imperative-application call, a BreakdownKey StrEnum with three literal-value members, a failure_modes.yaml with 3 migration-specific + 7 runner-internal always-block codes (+ warn/info as needed), and a working stub rubric that scores three Dockerfile-derived signals via a subprocess entrypoint — all files importable via HARDENED load_task_class("migration-chainguard-distroless", bench_root=Path("bench")) and validated by fence-CI assertions #4–#6.
Acceptance criteria¶
-
[ ] AC-1 (imperative registration call, HARDENED-S1-03-conformant).
bench/migration-chainguard-distroless/registration.pycontains exactly one call toregister_task_classwith: (a) first positional arg the literalast.Constantstring"migration-chainguard-distroless"; (b) kwargsbench_path=Path(__file__).parent,min_cases_for_promotion={"bronze": 10},breakdown_keys=frozenset(m.value for m in BreakdownKey),failure_mode_taxonomy=_TAXONOMY; (c) imperative-application formregister_task_class(...)(MigrationChainguardDistrolessRubric)against the rubric class imported frombench.migration_chainguard_distroless.rubric. The decorator-on-marker-class form@register_task_class(...) class _Marker: passis forbidden (fails S1-03'stype[Rubric]Protocol contract). Testtest_registered_rubric_class_is_imported_class_unmodifiedassertstc.rubric_class is MigrationChainguardDistrolessRubric(S1-03 AC-5). Local AST-walking test asserts exactly oneregister_task_classcall at module top level, first arg isast.Constant[str]. -
[ ] AC-2 (TaskClass shape post-load, bronze-only). After
tc = load_task_class("migration-chainguard-distroless", bench_root=tmp_bench_root, registry=fresh_registry): (a)tc.name == "migration-chainguard-distroless"; (b)tc.bench_path == (tmp_bench_root / "migration-chainguard-distroless").resolve()(absolute, symlink-resolved per S2-01 AC-13); (c)tc.min_cases_for_promotion == MappingProxyType({"bronze": 10})— both value equality ANDisinstance(tc.min_cases_for_promotion, types.MappingProxyType)(S1-03 AC-9a); (d)"silver" not in tc.min_cases_for_promotionand"gold" not in tc.min_cases_for_promotion— ADR-0006 held-out floor stays inactive for this task class; (e)dataclasses.fields(TaskClass)still has exactly six entries (S1-03 AC-2 pin holds — this story adds no field). -
[ ] AC-3 (BreakdownKey StrEnum — exact-set equality + ast.Constant + len pin).
bench/migration-chainguard-distroless/breakdown_keys.pydefinesclass BreakdownKey(StrEnum)with exactly the three membersBASE_IMAGE_SWAPPED = "base_image_swapped",SHELL_FREE = "shell_free",BUILD_PASSES = "build_passes". Test assertsfrozenset(m.value for m in BreakdownKey) == frozenset({"base_image_swapped", "shell_free", "build_passes"})ANDlen(list(BreakdownKey)) == 3— kills empty-enum, single-member, and lowercased-member mutants. 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). -
[ ] AC-4 (
__main__entrypoint shape + non-zero exit on malformed stdin).rubric.pyhas anif __name__ == "__main__":block that readssys.stdin.buffer.read(), parses it as JSON, validates viacodegenie.eval.models.BenchCase.model_validate(payload["case"])+ a local_HarnessOutputPydantic model, callsscore(...), writes the resultingBenchScoreas JSON tosys.stdout.buffer, and exits 0 on success. Onjson.JSONDecodeErrororpydantic.ValidationErrorthe process exits non-zero (sys.exit(2)) — no broadtry/exceptthat would emit a misleadingly-passingBenchScore. Testtest_main_exits_nonzero_on_malformed_envelope_json(integration) feedsb"not-json"on stdin viasubprocess.runand assertsreturncode != 0(ADR-0001 §Consequencesrubric.malformed_output). -
[ ] AC-5 (failure_modes.yaml taxonomy — exact 10 block + optional warn codes).
bench/migration-chainguard-distroless/failure_modes.yamldeclares exactly the following codes (any addition or omission fails fence-CI #6 and the local schema tests): block(10 codes — 3 migration-specific + 7 runner-internal always-block per ADR-0004 §Tradeoffs replication requirement):- migration-specific:
migration.base_image_not_chainguard,migration.shell_invocation_present,migration.build_failed - runner-internal always-block:
sut.exception,sut.timeout,sut.cancelled,rubric.timeout,rubric.unknown_failure_mode,rubric.unknown_breakdown_key(ADR-0008 §Decision),rubric.malformed_output(ADR-0001 §Consequences)
- migration-specific:
-
warn(1 — rubric-internal):migration.dockerfile_unparseable(rubric emits this when the SUT-produced Dockerfile cannot be tokenized enough to score signals; keeps the case surfacing an actionable warn rather than a silent zero). Each entry hasseverity ∈ {"block", "warn", "info"}and a non-emptydescriptionstr. After load, for every code in the required set,tc.failure_mode_taxonomy[code]returns the declared severity. Severity-shape AC:for code, sev in tc.failure_mode_taxonomy.items(): isinstance(sev, str) and sev in {"block","warn","info"}(guards against projecting the full{severity, description}dict). Set-exactness AC:set(tc.failure_mode_taxonomy.keys()) == REQUIRED_ALL_CODES. -
[ ] AC-6 (
MappingProxyTypeimmutability on both mappings).isinstance(tc.min_cases_for_promotion, types.MappingProxyType)ANDisinstance(tc.failure_mode_taxonomy, types.MappingProxyType). Bothwith pytest.raises(TypeError): tc.min_cases_for_promotion["silver"] = 25andwith pytest.raises(TypeError): tc.failure_mode_taxonomy["new.code"] = "block". S1-03 AC-9 typed-at-the-edge — the decorator normalizes; without these assertions an executor could ship a plaindictand never notice. -
[ ] AC-7 (failure_modes.yaml schema fence — three parametrized tests). (a)
test_failure_modes_yaml_top_level_is_dict_of_dicts_with_exactly_two_keys— for every entry,set(entry) == {"severity", "description"}(no extras, no missing). (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 safe_load path is what registration.py takes. -
[ ] AC-8 (double-load idempotence — bench-fixture-specific).
tc1 = load_task_class(...); tc2 = load_task_class(...)does NOT raiseTaskClassAlreadyRegistered, returnstc1 is tc2, andtc1 is fresh_registry.get("migration-chainguard-distroless"). 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). -
[ ] AC-9 (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 von the loader-produced set. The existingtests/unit/test_breakdown_keys_static.py(S1-05) auto-discovers registered task classes and picks this task class up without a story-local extension (no verify-which hedge). Bench-specific defense-in-depth via the runtime test lives in this story's test file. -
[ ] AC-10 (rubric.py has no module-level I/O or side effects).
test_rubric_module_has_no_module_level_io_or_side_effectsparsesbench/migration-chainguard-distroless/rubric.pyviaast.parseand asserts: (a) noast.Callnode appears at module top-level outsideFunctionDef/AsyncFunctionDef/ClassDef/If(only theif __name__ == "__main__":guard's body permits top-level effects, and the check confirms that guard is the only top-levelIf); (b) noimport time/from time, noimport random/from random, noimport uuid/from uuid; (c) noos.environattribute access outside the__main__guard. Mirrors S5-02 AC-9 discipline. Required for subprocess startup latency + audit-chain byte-stability. -
[ ] AC-11 (semantic-symmetry inversions documented). The three breakdown↔failure-mode pairs this task class commits to (Phase 7 rubric-hardening enforces the inversion at score time):
base_image_swapped↔migration.base_image_not_chainguardshell_free↔migration.shell_invocation_present-
build_passes↔migration.build_failedDocumentation-only for this story; no test forces the rubric's emit to invert. When Phase 7 hardens the rubric, that story owns a parametrizedtest_each_falsy_breakdown_condition_emits_its_paired_failure_code(mirror of S5-02 AC-5). The pairing pins the contract so the hardening story doesn't have to re-derive it. -
[ ] AC-12 (no LLM SDK). The rubric does not import any LLM SDK (
anthropic,openai,langchain,langgraph,transformers,torch,sentence-transformers). S5-02 AC-12's fence-CI extension already walksbench/**/rubric.py, so no additional wiring is needed — this story's rubric stays green under the existing extended walk. -
[ ] AC-13 (red→green pipeline, lint, typecheck, fence-CI). Red test from §TDD plan exists, was committed at red marker, now green.
ruff check,ruff format --check,mypy --strict bench/migration-chainguard-distroless/registration.py bench/migration-chainguard-distroless/breakdown_keys.py bench/migration-chainguard-distroless/rubric.py bench/migration-chainguard-distroless/tests/test_rubric_unit.py tests/unit/eval/test_bench_distroless_registration.py tests/integration/test_rubric_subprocess_distroless.pyall pass. Fence-CI assertions #4 (literal name), #5 (BreakdownKey substring ban), #6 (taxonomy validity) all pass in S7-01's ≤ 2 s budget.
Implementation outline¶
-
Directory skeleton. Create
bench/migration-chainguard-distroless/{registration.py, breakdown_keys.py, failure_modes.yaml, rubric.py, README.md}plusbench/migration-chainguard-distroless/tests/test_rubric_unit.py. Do NOT createbench/migration-chainguard-distroless/__init__.pyorbench/migration-chainguard-distroless/cases/__init__.py— S2-01 HARDENED uses PEP 420 implicit namespace packages; the hyphenated directory can't have one anyway (invalid Python identifier).cases/is empty until S6-02 lands the held-out three. -
registration.py(imperative-application form;_severity_taxonomy_from_yamlhelper mirrored from S5-01):"""ADR-0004, ADR-0006, ADR-0008 — task-class identity for migration-chainguard-distroless. Second task class; extends the S5-01 pattern by addition. Bronze-only floor (ADR-0006) — silver/gold are Phase 7's call. """ 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.migration_chainguard_distroless.breakdown_keys import BreakdownKey from bench.migration_chainguard_distroless.rubric import MigrationChainguardDistrolessRubric _HERE: Final[Path] = Path(__file__).parent def _severity_taxonomy_from_yaml( path: Path, ) -> Mapping[str, Literal["block", "warn", "info"]]: """File-local helper (second consumer; Phase 15's task class is the rule-of-three lift trigger to `src/codegenie/eval/loader.py` as `_load_failure_mode_taxonomy` — arch line 564).""" 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( "migration-chainguard-distroless", bench_path=_HERE, min_cases_for_promotion={"bronze": 10}, breakdown_keys=frozenset(m.value for m in BreakdownKey), failure_mode_taxonomy=_TAXONOMY, )(MigrationChainguardDistrolessRubric) -
breakdown_keys.py— three literal-value members (Phase 7 may add e.g.RUNTIME_CAPABILITY_MATCHlater; values must pass the substring ban): -
failure_modes.yaml— 10 block + 1 warn = 11 entries total; flat mapping{code: {severity, description}}; top-of-file comment names ADR-0004 + ADR-0001. Severities are literal lowercase strings. -
rubric.py— working stub. Purescore()+__main__shell. Score =statistics.mean(breakdown.values())(extends automatically when a newBreakdownKeymember ships): - Parse
harness_output["dockerfile"](the SUT's produced Dockerfile string). - Compute three boolean signals: (a)
FROMline targetscgr.dev/chainguard/*→BASE_IMAGE_SWAPPED; (b) noRUN sh|bash|/bin/shin the Dockerfile →SHELL_FREE; (c)harness_output["build_log_last_line"]matchesSuccessfully builtor equivalent →BUILD_PASSES. breakdown = {BreakdownKey.X.value: 1.0 if condition else 0.0, ...}for all three keys.passed = all(v == 1.0 for v in breakdown.values());score = statistics.mean(breakdown.values()).- Emit paired failure codes (AC-11) for each falsy signal.
- Emit
migration.dockerfile_unparseable(warn) when the Dockerfile string is empty / has noFROMline. -
__main__block reads stdin JSON, validates, callsscore(...), writes stdout, exits 0; exits 2 onjson.JSONDecodeError/pydantic.ValidationError. -
tests/test_rubric_unit.py(bench-author, in-process) — directscore(case, harness_output)calls covering: all-pass, swap-not-done, shell-present, build-failed, dockerfile-empty (dockerfile_unparseable warn). Assertset(result.breakdown.keys()) == {m.value for m in BreakdownKey}; assert emitted failure codes are in the YAML-declared set. -
tests/unit/eval/test_bench_distroless_registration.py— pins identity, StrEnum, taxonomy, YAML schema, immutability, double-load per §TDD plan. Lives undertests/unit/eval/to inherit S2-01 HARDENED AC-22's autouseconftest.py. -
tests/integration/test_rubric_subprocess_distroless.py— exercises the subprocess path (subprocess.runwithSCRUBBED_ENVper ADR-0001 + Phase 5 ADR-0012); asserts wall-clock ≤ 60 s on a representative envelope; assertsreturncode != 0onb"not-json"stdin (AC-4).
TDD plan — red / green / refactor¶
Red — write the failing test first¶
Test file path: tests/unit/eval/test_bench_distroless_registration.py (under tests/unit/eval/ to inherit S2-01 HARDENED AC-22's autouse conftest.py).
# tests/unit/eval/test_bench_distroless_registration.py
import ast
import types
from enum import StrEnum
from pathlib import Path
from types import MappingProxyType
import pytest
import yaml
BANNED_SUBSTRINGS = ("confidence", "llm", "self_reported", "model_says")
EXPECTED_BREAKDOWN_VALUES = frozenset({
"base_image_swapped",
"shell_free",
"build_passes",
})
REQUIRED_BLOCK_CODES = frozenset({
# migration-specific
"migration.base_image_not_chainguard",
"migration.shell_invocation_present",
"migration.build_failed",
# ADR-0004 §Tradeoffs replication requirement: runner-internal always-block
"sut.exception",
"sut.timeout",
"sut.cancelled",
"rubric.timeout",
"rubric.unknown_failure_mode",
"rubric.unknown_breakdown_key", # ADR-0008 §Decision
"rubric.malformed_output", # ADR-0001 §Consequences
})
REQUIRED_WARN_CODES = frozenset({
"migration.dockerfile_unparseable",
})
REQUIRED_ALL_CODES = REQUIRED_BLOCK_CODES | REQUIRED_WARN_CODES
@pytest.fixture()
def fresh_registry():
from codegenie.eval.registry import default_registry as reg
return reg
@pytest.fixture()
def bench_root(tmp_path: Path) -> Path:
"""Materialize bench/migration-chainguard-distroless/* under tmp_path."""
bench = tmp_path / "bench"
src = Path(__file__).parents[3] / "bench" / "migration-chainguard-distroless"
target = bench / "migration-chainguard-distroless"
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_bronze_only_floor(
bench_root: Path, fresh_registry,
):
from codegenie.eval.loader import load_task_class
tc = load_task_class(
"migration-chainguard-distroless", bench_root=bench_root, registry=fresh_registry,
)
assert tc.name == "migration-chainguard-distroless"
assert tc.bench_path == (bench_root / "migration-chainguard-distroless").resolve()
# ADR-0006: bronze-only; silver/gold are Phase 7's call, held-out floor stays inactive.
assert tc.min_cases_for_promotion == MappingProxyType({"bronze": 10})
assert isinstance(tc.min_cases_for_promotion, types.MappingProxyType)
assert "silver" not in tc.min_cases_for_promotion
assert "gold" not in tc.min_cases_for_promotion
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(
"migration-chainguard-distroless", bench_root=bench_root, registry=fresh_registry,
)
from bench.migration_chainguard_distroless.rubric import MigrationChainguardDistrolessRubric
assert tc.rubric_class is MigrationChainguardDistrolessRubric
def test_registration_has_exactly_one_register_task_class_call_with_literal_name(
bench_root: Path,
):
"""AC-1: AST-walk pins the imperative-application form."""
src = (bench_root / "migration-chainguard-distroless" / "registration.py").read_text()
tree = ast.parse(src)
calls = [
n for n in ast.walk(tree)
if isinstance(n, ast.Call)
and isinstance(n.func, ast.Name)
and n.func.id == "register_task_class"
]
assert len(calls) == 1, f"expected one register_task_class call, found {len(calls)}"
call = calls[0]
assert isinstance(call.args[0], ast.Constant), "first arg must be ast.Constant literal"
assert call.args[0].value == "migration-chainguard-distroless"
# --- AC-3 — BreakdownKey StrEnum shape ----------------------------------------
def test_breakdown_key_strenum_has_expected_three_members(bench_root: Path, fresh_registry):
from codegenie.eval.loader import load_task_class
load_task_class(
"migration-chainguard-distroless", bench_root=bench_root, registry=fresh_registry,
)
from bench.migration_chainguard_distroless.breakdown_keys import BreakdownKey
assert issubclass(BreakdownKey, StrEnum)
values = frozenset(m.value for m in BreakdownKey)
assert values == EXPECTED_BREAKDOWN_VALUES
assert len(list(BreakdownKey)) == 3
def test_breakdown_key_values_are_ast_constant_strings(bench_root: Path):
"""AC-3: defense-in-depth on fence-CI #5 (S7-01). Catches f-string / concat mutants."""
src = (bench_root / "migration-chainguard-distroless" / "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-9 — 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(
"migration-chainguard-distroless", bench_root=bench_root, registry=fresh_registry,
)
from bench.migration_chainguard_distroless.breakdown_keys import BreakdownKey
for m in BreakdownKey:
for banned in BANNED_SUBSTRINGS:
assert banned not in m.value
def test_breakdown_keys_runtime_substring_ban(bench_root: Path, fresh_registry):
from codegenie.eval.loader import load_task_class
tc = load_task_class(
"migration-chainguard-distroless", 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 + set-exactness -----------------------
def test_failure_modes_taxonomy_has_exactly_required_codes_with_correct_severity(
bench_root: Path, fresh_registry,
):
from codegenie.eval.loader import load_task_class
tc = load_task_class(
"migration-chainguard-distroless", 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"
assert set(tax.keys()) == set(REQUIRED_ALL_CODES), (
f"taxonomy set drift: extras={set(tax.keys()) - REQUIRED_ALL_CODES}, "
f"missing={REQUIRED_ALL_CODES - set(tax.keys())}"
)
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(
"migration-chainguard-distroless", 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"}
# --- AC-6 — MappingProxyType immutability ------------------------------------
def test_min_cases_for_promotion_is_mapping_proxy_type_and_immutable(
bench_root: Path, fresh_registry,
):
from codegenie.eval.loader import load_task_class
tc = load_task_class(
"migration-chainguard-distroless", bench_root=bench_root, registry=fresh_registry,
)
assert isinstance(tc.min_cases_for_promotion, types.MappingProxyType)
with pytest.raises(TypeError):
tc.min_cases_for_promotion["silver"] = 25 # type: ignore[index]
def test_failure_mode_taxonomy_is_mapping_proxy_type_and_immutable(
bench_root: Path, fresh_registry,
):
from codegenie.eval.loader import load_task_class
tc = load_task_class(
"migration-chainguard-distroless", 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-8 — double-load idempotence ------------------------------------------
def test_double_load_task_class_does_not_raise_and_returns_same_taskclass(
bench_root: Path, fresh_registry,
):
from codegenie.eval.loader import load_task_class
tc1 = load_task_class(
"migration-chainguard-distroless", bench_root=bench_root, registry=fresh_registry,
)
tc2 = load_task_class(
"migration-chainguard-distroless", bench_root=bench_root, registry=fresh_registry,
)
assert tc1 is tc2
assert tc1 is fresh_registry.get("migration-chainguard-distroless")
# --- AC-7 — failure_modes.yaml schema fence ----------------------------------
def _load_yaml(bench_root: Path) -> dict:
return yaml.safe_load(
(bench_root / "migration-chainguard-distroless" / "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"}
# "info" is absent in this task class's YAML — the sanity check only fires for
# severities that MUST appear (block, warn).
if legal_severity in {"block", "warn"}:
assert legal_severity in severities
def test_failure_modes_yaml_descriptions_are_nonempty_strings(bench_root: Path):
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"),
)
raw = yaml.safe_load(
(bench_root / "migration-chainguard-distroless" / "failure_modes.yaml").read_text(),
)
assert isinstance(raw, dict)
# --- AC-10 — rubric.py has no module-level I/O or side effects ---------------
def test_rubric_module_has_no_module_level_io_or_side_effects(bench_root: Path):
"""ADR-0001 §Consequences: no module-level side effects. AST-walk top-level nodes;
only `if __name__ == "__main__":` may permit statements with effects."""
src = (bench_root / "migration-chainguard-distroless" / "rubric.py").read_text()
tree = ast.parse(src)
# (a) no top-level Call outside function/class/if guard bodies.
for node in tree.body:
assert not isinstance(node, ast.Expr) or not isinstance(node.value, ast.Call), (
f"top-level Call at line {node.lineno} violates ADR-0001 no-side-effects"
)
# (b) no time/random/uuid imports.
banned_modules = {"time", "random", "uuid"}
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
assert alias.name.split(".")[0] not in banned_modules, (
f"banned import: {alias.name}"
)
if isinstance(node, ast.ImportFrom):
assert (node.module or "").split(".")[0] not in banned_modules, (
f"banned from-import: {node.module}"
)
# (c) no os.environ access outside the __main__ guard body.
def _visit(nodes, inside_main_guard: bool) -> None:
for n in nodes:
if isinstance(n, ast.If) and _is_main_guard(n.test):
_visit(n.body, inside_main_guard=True)
continue
if not inside_main_guard:
for child in ast.walk(n):
if (
isinstance(child, ast.Attribute)
and isinstance(child.value, ast.Name)
and child.value.id == "os"
and child.attr == "environ"
):
pytest.fail(f"os.environ access at module scope line {child.lineno}")
def _is_main_guard(test: ast.expr) -> bool:
return (
isinstance(test, ast.Compare)
and isinstance(test.left, ast.Name)
and test.left.id == "__name__"
and len(test.comparators) == 1
and isinstance(test.comparators[0], ast.Constant)
and test.comparators[0].value == "__main__"
)
_visit(tree.body, inside_main_guard=False)
# --- AC-1 (subprocess-entrypoint shape, not tautology) -----------------------
def test_rubric_module_has_main_guard_and_no_top_level_score_invocation(bench_root: Path):
"""AC-1 / F-TQ-1: replaces the `hasattr(mod, "__name__")` tautology."""
src = (bench_root / "migration-chainguard-distroless" / "rubric.py").read_text()
tree = ast.parse(src)
main_guards = [
n for n in tree.body
if isinstance(n, ast.If)
and isinstance(n.test, ast.Compare)
and isinstance(n.test.left, ast.Name)
and n.test.left.id == "__name__"
]
assert len(main_guards) == 1, "rubric.py must have exactly one `if __name__ == '__main__':`"
for node in tree.body:
if isinstance(node, ast.If) and node in main_guards:
continue
for child in ast.walk(node):
if (
isinstance(child, ast.Call)
and isinstance(child.func, ast.Name)
and child.func.id == "score"
):
pytest.fail(
f"top-level `score(...)` call at line {child.lineno}; "
"score must only be invoked inside the __main__ guard or bench-author tests",
)
Run it; confirm ModuleNotFoundError (registration module doesn't exist yet) or TaskClassNotFound. Commit as red marker.
Green — smallest impl shape¶
-
Create the four artifact files + the two test files per §Implementation outline §1–§8. Smallest shape: registration is one imperative-application call using
_severity_taxonomy_from_yaml; breakdown_keys lists exactly the three members; YAML lists exactly the 11 codes (10 block + 1 warn); rubric is ascore()pure function +__main__reading stdin, parsing the Dockerfile string with regex, writing JSON to stdout with non-zero exit on malformed input. -
bench/migration-chainguard-distroless/tests/test_rubric_unit.py(in-process, per S5-02 pattern): four unit tests covering all-pass, swap-not-done, shell-present, build-failed. Test file lives underbench/**/tests/and imports the rubric directly viafrom bench.migration_chainguard_distroless.rubric import scoreafter loading through the autouse conftest (mirrors S5-02 F-CON-3's conftest bridge). -
Run
pytest tests/unit/eval/test_bench_distroless_registration.py bench/migration-chainguard-distroless/tests/ tests/integration/test_rubric_subprocess_distroless.py; iterate to green.
Refactor — clean up¶
- Module docstrings on
registration.py,breakdown_keys.py,rubric.pycite ADR-0001, ADR-0004, ADR-0008.failure_modes.yamltop-of-file comment names ADR-0004 + ADR-0001. mypy --strictclean on all touched files.rubric.py'smain() -> Noneannotation is required.- Rubric's regex set is conservative — false-positive on shell detection is preferable to false-negative in a stub (Phase 7 hardens).
bench/migration-chainguard-distroless/README.md— one-paragraph stub naming what S6-02 (3 held-out cases +digests.yaml) and S6-03 (E2Ecodegenie eval run+ verdict) will add.
Files to touch¶
| Path | Why |
|---|---|
bench/migration-chainguard-distroless/registration.py |
New — imperative-application register_task_class("migration-chainguard-distroless", ...)(MigrationChainguardDistrolessRubric) + _severity_taxonomy_from_yaml helper (second consumer; Phase 15 triggers the rule-of-three lift) |
bench/migration-chainguard-distroless/breakdown_keys.py |
New — BreakdownKey StrEnum with 3 literal-value members |
bench/migration-chainguard-distroless/failure_modes.yaml |
New — 10 block + 1 warn = 11 entries; 3 migration-specific + 7 runner-internal always-block per ADR-0004 §Tradeoffs |
bench/migration-chainguard-distroless/rubric.py |
New — score(case, harness_output) -> BenchScore pure function + __main__ subprocess entrypoint scoring Dockerfile-derived signals |
bench/migration-chainguard-distroless/README.md |
New — stub naming S6-02 and S6-03 |
bench/migration-chainguard-distroless/tests/test_rubric_unit.py |
New — bench-author unit tests, in-process (per S5-02 pattern) |
tests/unit/eval/test_bench_distroless_registration.py |
New — pins identity, StrEnum, taxonomy, YAML schema, immutability, double-load, no-side-effects, main-guard-not-tautology |
tests/integration/test_rubric_subprocess_distroless.py |
New — subprocess-CLI test with SCRUBBED_ENV, wall-clock ≤ 60 s, non-zero exit on b"not-json" stdin |
Out of scope¶
- Seed cases (
cases/001-*,cases/002-*,cases/003-*,cases/digests.yaml) — S6-02 owns case curation and signing. - E2E
codegenie eval run+ N=3 verdict documentation — S6-03. - Hardening the rubric (multi-stage detection, build sandboxing, semver checks on Chainguard image tags,
RUNTIME_CAPABILITY_MATCH, semantic-symmetry inversion enforcement per AC-11) — Phase 7. - Adding
silver/goldtomin_cases_for_promotion— Phase 7 raises the bar once ≥10 cases with ≥5 held-out exist. - Rule-of-three lift of
_severity_taxonomy_from_yaml. Today there are two consumers (bench/vuln-remediation/registration.py+ this task class'sregistration.py); 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). _dockerfile_factsfunctional-core extractor. Design-pattern opportunity surfaced in Notes-for-implementer; Rule 2 keeps it out of scope until the rubric grows.
Notes for the implementer¶
- Stub-quality is correct. The signals are coarse (regex on
FROMline, regex onRUN sh|bash). That is the Phase 6.5 commitment; Phase 7 expands. Resist gold-plating —Rule 2 Simplicity FirstandRule 3 Surgical Changes. - The
if __name__ == "__main__"discipline is load-bearing (ADR-0001). The runner spawnspython rubric.pyas a subprocess; any module-level import of e.g.,dockerwould either fail at subprocess startup or slow every case by hundreds of ms. Keep imports minimal — stdlib +codegenie.eval.models+pydanticideally. Do NOT do any I/O at module top level. - Non-zero exit on malformed stdin is load-bearing (ADR-0001 §Consequences). The runner reacts to non-zero rubric exit with
FailureMode(code="rubric.malformed_output", severity="block"). Atry/except Exception: emit passing BenchScorewould swallow the fail-loud path and silently pass a broken run. Usesys.exit(2)onjson.JSONDecodeError/pydantic.ValidationError— mirror S5-02 AC-2 exactly. - All 10 block codes are required per ADR-0004 §Tradeoffs. Runner-internal always-block codes (
sut.exception,sut.timeout,sut.cancelled,rubric.timeout,rubric.unknown_failure_mode,rubric.unknown_breakdown_key,rubric.malformed_output) must be replicated per task class or the resolver falls back torubric.unknown_failure_modeon drift and gives operators a misleading error. This is not optional. - Breakdown-key values are the substring-ban surface, not member names (ADR-0008).
BASE_IMAGE_SWAPPED = "base_image_swapped"— thevalueis what fence-CI walks. Source-of-truth for the substring list is Phase 5 ADR-0014 + ADR-0008. bench_pathin@register_task_classmust bePath(__file__).parentofregistration.pyso the loader can locate sibling files. Mirrorbench/vuln-remediation/registration.pyexactly.tests/unit/test_breakdown_keys_static.pyauto-discovers registered task classes — if you wired registration correctly, the existing static test picks this task class up without edit. Verify the test file already ships insrc/codegenie/(per S1-05) before assuming; do not duplicate.migration-chainguard-distrolessslug uses hyphens in@register_task_class("...")but the Python package directory must use underscores (migration_chainguard_distroless/) for Option Asys.path-prep imports. Loader (HARDENED S2-01 AC-9) handles the slug→module-name translation viaspec_from_file_location; mirror vuln-remediation's pattern.- Do NOT create
bench/__init__.py,bench/migration-chainguard-distroless/__init__.py, orbench/migration-chainguard-distroless/cases/__init__.py. S2-01 HARDENED uses PEP 420 implicit namespace packages; the hyphenated leaf can't have one anyway (invalid Python identifier). Adding any of them breaks S2-01's contract and is caught by the fence. _severity_taxonomy_from_yamlis a file-local helper (second consumer). HARDENED S5-01 landed the first copy inbench/vuln-remediation/registration.py; this story ships the second copy inline. The rule-of-three lift trigger is Phase 15's task class; at that point the helper moves tosrc/codegenie/eval/loader.pyas_load_failure_mode_taxonomy(arch line 564). Copy-paste is deliberate — Rule 2 keeps abstraction deferred until the third data point.score = statistics.mean(breakdown.values()), notmean_of_three_booleans. The mean formula extends automatically when Phase 7 adds a 4thBreakdownKeymember. Hard-coding "three" bakes in a landmine.- Semantic-symmetry inversions (AC-11) — pinned, not enforced. The three signal↔failure pairs live in AC-11 as documentation; Phase 7 owns rubric-hardening enforcement via a parametrized
test_each_falsy_breakdown_condition_emits_its_paired_failure_code(mirror of S5-02 AC-5). Ship the pairings in the rubric docstring so Phase 7 doesn't have to re-derive them. - Design opportunity —
_dockerfile_factsfunctional-core extractor. The Dockerfile-parsing regex logic is a candidate for a_dockerfile_facts(text: str) -> _DockerfileFactsfrozen-dataclass helper. Benefits: (a) separates pure logic from__main__shell (functional-core / imperative-shell pattern the repo prefers), (b) makes regex behavior testable without stdin/stdout plumbing. Not promoted to AC — Rule 2 keeps it deferred until the rubric grows (Phase 7 likely triggers it when adding multi-stage detection).