Story S4-05 — docs/trust-tiers.yaml + recommendation writer¶
Step: Step 4 — Wire the CLI and the read-only promotion gate
Status: HARDENED (phase-story-validator, 2026-06-04)
Effort: S
Depends on: S4-04 (HARDENED — PromotionGate.evaluate returns PromotionVerdict; TierConfig + load_tier_config live in src/codegenie/eval/tier_config.py, NOT in promotion.py; PromotionGate.__init__ validates registered task-class tier slugs at startup per AC-3)
ADRs honored: ADR-0003 (tier IDs as str, validated at startup against docs/trust-tiers.yaml; YAML is contract data, CODEOWNERS-gated, candidate numbers only), ADR-0009 (automatic-demotion is a recommendation-shift; the recommendation file is the only side effect), Production ADR-0009 (humans always merge; the recommendation file is advisory data, not a control signal), Production ADR-0015 (calibration deferred; docs/trust-tiers.yaml carries candidates, not commitments)
Validation notes¶
Validated: 2026-06-04 Verdict: HARDENED Findings addressed: 16 total — 5 block, 8 harden, 3 nit
Conflict-resolution priority applied: Consistency > Coverage > Test-Quality > Design-Patterns. The story as authored predated the S4-04 HARDENED contract surface and the actual PromotionVerdict field shape; an executor following it verbatim would (a) import load_tier_config / TierConfig from promotion.py (wrong module — they live in tier_config.py per S4-04 AC-1); (b) build a sample_verdict fixture with non-existent fields chain_head and run_id (those live on BenchRunReport, not PromotionVerdict); (c) call PromotionVerdict.model_construct(...) (forbidden by S4-04 AC-10 — full Pydantic validation is required); (d) leave schema_version: 1 as a load-bearing-but-unspec'd literal in the YAML (no fence against silent v2 drift); (e) emit a static-introspection test so coarse it false-positives on any docstring containing "recommendations". Every issue is patchable in place → HARDENED, not RESCUE.
tier_configmodule — every import in the TDD plan moved fromcodegenie.eval.promotiontocodegenie.eval.tier_config(Consistency F-CON-1; mirrors S4-04 HARDENED Implementation outline §2).sample_verdictfixture rewritten to populate all eight S1-02 required fields verbatim —task_class,current_tier,target_tier,evidence_sufficient,reasons,lower_bound_95,threshold_at_target,requires_human_approval=True(Consistency F-CON-2 + Coverage F-COV-1).chain_head/run_idremoved (those areBenchRunReportfields).model_constructretired in the fixture — fullPromotionVerdict(...)construction so a future runner-output drift cannot diverge from test fixtures (Consistency F-CON-3 + Test-Quality F-TQ-1; mirrors S4-04 F-TQ-1).schema_version: 1forward-compat fence added —load_tier_configaccepts{thresholds, current_tiers, schema_version}only; unknown top-level keys orschema_version != 1raiseTierConfigInvalid(Coverage F-COV-2 + Design-Patterns F-DP-1). Without this fence, a v2 YAML silently parses to v1 semantics — the worst kind of silent index staleness for contract data.- Deterministic-time injection on
write_recommendation— new keyword-onlynow: datetime | None = Noneparameter; defaults todatetime.now(UTC); enables byte-identical-rerun + filename-proximity tests without monkeypatchingdatetime(Design-Patterns F-DP-2 — functional-core / imperative-shell; the filename derivation becomes a pure function ofnow). The new_utc_iso_filename(now: datetime) -> stris the pure helper; the public wrapper is the thin impure shell. - Filename test strengthened — adds a ±10s proximity assertion between the filename's parsed UTC timestamp and an injected
nowto kill mutants that return a constant filename (Test-Quality F-TQ-2). mkdir(parents=True)test strengthened — nests three levels (tmp_path / "a" / "b" / "c") to kill aparents=Falsemutant; one nested level admits aparents=Falsemutant that still passes (Test-Quality F-TQ-3).- Byte-identical-rerun test added — two
write_recommendation(verdict, out_dir, now=fixed_dt)calls produce equal bytes on disk; pins ADR-0002 §Consequences "byte-identical across reruns" determinism contract through to the recommendation surface (Coverage F-COV-3 + Test-Quality F-TQ-4). - Atomic-write-failure cleanup test added — monkeypatches
os.replaceto raise; asserts target absent AND.tmpcleaned up; pins the half-tested "atomic" guarantee against a mutant that omits the cleanup branch (Test-Quality F-TQ-5). - Static-introspection test scoped — only flags string literals that are arguments to read-shaped calls (
open,read_text,read_bytes,glob,iterdir,rglob) AND containrecommendations; no longer false-positives on docstrings (Test-Quality F-TQ-6; mirrors the S1-05 import-ban discipline narrowed by AST role, not substring presence). - Same-second collision documented as known limitation — keeping the arch's exact
<utc-iso>.jsonfilename (phase-arch-design.md §Dynamic view → Sequence); collision risk is accepted residual (Consistency over filename-suffix Design-Patterns suggestion; arch dictates filename literal). - Forward-compat note added — when
silver/gold/platinumthresholds land in a later phase, new top-level keys (e.g.per_task_class_thresholds) require an ADR amendment ANDschema_versionbump. The fence forces this loud. - Bench-registration tier-validation duplicate dropped — the
test_unknown_tier_in_registration_raises_against_phase65_yamltest lived under an undefinedregistry_with_silver_min_casesfixture; this responsibility belongs to S4-04test_promotion.pyAC-3 (which coversmin_cases_for_promotionkeys,current_tiersvalues, ANDthresholdskeys against the shipped YAML set). S4-05 references S4-04 instead of duplicating (Consistency F-CON-4). schema_versionaccepted in YAML — the shipped YAML retainsschema_version: 1(it is now load-bearing for the forward-compat fence, not decorative).- Module constant introduced —
DEFAULT_RECOMMENDATION_DIR = Path(".codegenie/eval/recommendations")at module top ofrecommendation.pyto mirror S4-02's pattern; the public default expression references it (nit). - Test docstring scope corrected —
test_recommendation_not_consumedwalkssrc/codegenie/eval/only (not all ofsrc/codegenie/); the doc matches the walk (nit).
Full audit log: _validation/S4-05-trust-tiers-yaml-and-recommendation-writer.md
Context¶
docs/trust-tiers.yaml is the contract data the rest of the harness reads to interpret tier names. ADR-0003 made tier identifiers str validated at startup against this file; the file's existence and minimal schema are load-bearing. Phase 6.5 ships it with uncalibrated candidate numbers for bronze only — production ADR-0015 (threshold calibration) stays deferred, and the YAML's README header says so loudly so future readers do not mistake the candidates for committed thresholds.
The recommendation writer is the second half of the read-only promotion contract. When --with-verdict is set on eval run (S4-02) or when an evaluate call flips evidence_sufficient from False to True for any registered task class, the writer persists the PromotionVerdict to .codegenie/eval/recommendations/<utc-iso>.json. Phase 11/12 will eventually read these files; Phase 6.5 just produces them. Per ADR-0009, the file is advisory — no Phase 6.5 code path consumes the evidence_sufficient field as a control signal. (The architecture explicitly notes this in phase-arch-design.md §Failure modes: "the system never branches on 'evidence sufficient' automatically.")
References — where to look¶
- Architecture:
../phase-arch-design.md §Component design → src/codegenie/eval/promotion.py— namestier_config: TierConfigwiththresholds: Mapping[str, float]andcurrent_tiers: Mapping[str, str].../phase-arch-design.md §Component design → src/codegenie/eval/cli.py—--with-verdicttriggers the recommendation write.../phase-arch-design.md §Dynamic view → Sequence: 14-day silver-promotion candidate— the day-15 verdict-flip writes aPromotionVerdictat.codegenie/eval/recommendations/<utc-iso>.json;evidence_sufficient=True, target_tier="silver", reasons=("all conditions met",).../phase-arch-design.md §Open questions deferred to implementation#5 —docs/trust-tiers.yamlschema details (versioning, per-task-class overrides, downgrade-threshold equality) all defer to ADR-0015 calibration; ship a minimal schema.- Phase ADRs:
../ADRs/0003-tier-identifiers-as-str-validated-at-startup.md§Decision —docs/trust-tiers.yamlis CODEOWNERS-gated contract data;TierConfigis loaded from it; unknown tiers raiseTierConfigInvalidat startup.../ADRs/0009-automatic-demotion-as-recommendation-shift.md— the recommendation file is the only side effect; no automatic demotion.- Production ADRs:
../../../production/adrs/0009-humans-always-merge.md— the recommendation file is data; humans act on it.../../../production/adrs/0015-trust-score-threshold-calibration.md— the calibration ADR whose numbers this YAML eventually carries; in Phase 6.5 the YAML carries candidates with an "uncalibrated" header.- Source design:
../High-level-impl.md §Step 4— names the YAML schema (thresholds,current_tiers), the recommendation directory (.codegenie/eval/recommendations/<utc-iso>.json), and the "candidate numbers + uncalibrated header" discipline.
Goal¶
Ship docs/trust-tiers.yaml (minimal schema, bronze candidate numbers, prominent uncalibrated header) and src/codegenie/eval/recommendation.py with write_recommendation(verdict, out_dir) -> Path invoked from S4-02's --with-verdict flag.
Acceptance criteria¶
docs/trust-tiers.yaml — contract data (Consistency F-CON-1 / F-CON-4 + Coverage F-COV-2)¶
- [ ] AC-1.
docs/trust-tiers.yamlexists and parses to exactly the following shape (theschema_versionfield is load-bearing for the forward-compat fence in AC-7, not decorative):# SPDX-License-Identifier: Apache-2.0 # codewizard-sherpa: tier-config-v1 (UNCALIBRATED — Phase 6.5 candidate numbers only) # See docs/production/adrs/0015-trust-score-threshold-calibration.md for calibration commitments. # CODEOWNERS-gated: every change requires an ADR amendment. schema_version: 1 thresholds: bronze: 0.70 # CANDIDATE — pending ADR-0015 calibration current_tiers: {} # empty until first task class promotes; updates require human PR - [ ] AC-2. The first 500 bytes of
docs/trust-tiers.yamlcontain the literal substring"UNCALIBRATED"(read viaPath.read_bytes()[:500].decode("utf-8", errors="replace")). 500 bytes — not 200 — to survive an SPDX-header or licence-block prefix without forcing a comment-strip refactor (Test-Quality F-TQ-7 widening). - [ ] AC-3. Only
bronzeis declared inthresholds:set(loaded.thresholds) == {"bronze"}and0.0 < loaded.thresholds["bronze"] <= 1.0.silver/gold/platinumare absent — Phase 7 / Phase 15 add them via their own ADR amendments per ADR-0003. - [ ] AC-4.
current_tiersis an empty mapping (loaded.current_tiers == {}); no task class is registered as currently-tiered until a human-authored PR lands one (per ADR-0009 + Production ADR-0009).
tier_config module — imports + round-trip + forward-compat fence (Consistency F-CON-1 + Coverage F-COV-2 + Design-Patterns F-DP-1)¶
- [ ] AC-5. Every test in this story imports
load_tier_configandTierConfigfromcodegenie.eval.tier_config(the module S4-04 AC-1 created), not fromcodegenie.eval.promotion.PromotionGateis imported fromcodegenie.eval.promotion. - [ ] AC-6.
load_tier_config(Path("docs/trust-tiers.yaml"))round-trips to aTierConfigwhosethresholds == MappingProxyType({"bronze": 0.70})andcurrent_tiers == MappingProxyType({})(compared via dict equality —MappingProxyType__eq__against adictreturns True).PromotionGate(tier_config=load_tier_config(...))constructs without raising on a freshTaskClassRegistry()(so the test isolates the contract from whateverdefault_registryhappens to carry). - [ ] AC-7. Forward-compat fence on
schema_version.load_tier_configaccepts top-level keys ⊆{"schema_version", "thresholds", "current_tiers"}only; raisesTierConfigInvalidon: - any unknown top-level key (e.g.
per_task_class_thresholds) —exc.args == ("unknown_key", unknown_key, ("current_tiers", "schema_version", "thresholds")); schema_versionabsent —exc.args == ("schema_version_missing", (1,));schema_version != 1—exc.args == ("schema_version", schema_version, (1,)). This forces a v2 YAML to crash old loaders loudly rather than silently parse to v1 semantics. Tests: three parametrized cases undertest_trust_tiers_yaml.py::test_load_tier_config_forward_compat_fence, each writing a synthetic YAML totmp_pathand asserting the exactTierConfigInvalid.argstuple.- [ ] AC-8. Bench-registration tier validation is owned by S4-04, not this story. The
PromotionGate.__init__startup check that anyTaskClass.min_cases_for_promotionkey absent fromtier_config.thresholdsraisesTierConfigInvalidis covered by S4-04test_promotion.pyAC-3. This story neither duplicates that test nor introduces aregistry_with_silver_min_casesfixture (Consistency F-CON-4).
write_recommendation — contract surface (Coverage F-COV-1 + Design-Patterns F-DP-2 + Test-Quality F-TQ-1 .. F-TQ-5)¶
- [ ] AC-9.
src/codegenie/eval/recommendation.pydefines a module-top constant: and the public function:Thedef write_recommendation( verdict: PromotionVerdict, out_dir: Path = DEFAULT_RECOMMENDATION_DIR, *, now: datetime | None = None, ) -> Path: ...nowparameter is keyword-only; defaults todatetime.now(UTC)whenNone. This is the functional-core / imperative-shell split: filename derivation becomes a pure function ofnow; the only impurity is the default. (Design-Patterns F-DP-2.) - [ ] AC-10. Filename shape. The returned
Path.namematches<utc-iso>.jsonwhere<utc-iso> == now.strftime("%Y-%m-%dT%H-%M-%SZ")(hyphens, not colons — same<utc-iso>convention as the audit JSON in S4-02). The literal filename pattern is fixed byphase-arch-design.md §Dynamic view → Sequenceline 496 — no-<short>hex suffix (same-second collisions are an accepted residual; see Notes for implementer). - [ ] AC-11.
_utc_iso_filename(now: datetime) -> stris a module-private pure helper returningnow.astimezone(UTC).strftime("%Y-%m-%dT%H-%M-%SZ") + ".json". Test isolates it directly —_utc_iso_filename(datetime(2026, 6, 4, 12, 30, 45, tzinfo=UTC)) == "2026-06-04T12-30-45Z.json"(mutation-resistant byte-string pin). - [ ] AC-12. Filename-proximity guard against constant-return mutants. A test calls
write_recommendation(verdict, out_dir, now=datetime(2026,6,4,12,30,45,tzinfo=UTC))and assertswritten.name == "2026-06-04T12-30-45Z.json"exactly. A second test calls withnow=Noneand asserts the filename's parsed UTC timestamp falls within ±10 s ofdatetime.now(UTC)captured before/after the call. Kills mutants that return"2000-01-01T00-00-00Z.json"regardless ofnow. - [ ] AC-13. Directory creation.
out_dir.mkdir(parents=True, exist_ok=True)is invoked exactly once before the temp write. The test usestmp_path / "a" / "b" / "c"(three nested levels) to kill aparents=Falsemutant. (Test-Quality F-TQ-3.) - [ ] AC-14. Atomic write contract. Implementation pattern:
No partial-write debris is reachable via
tmp = target.with_suffix(target.suffix + ".tmp") tmp.write_text(verdict.model_dump_json(indent=2)) os.chmod(tmp, 0o600) os.replace(tmp, target)Path.write_text(target, ...)direct call. Atomicity is verified by the failure-cleanup test in AC-16. - [ ] AC-15. Mode
0o600after replace.stat.S_IMODE(os.stat(written).st_mode) == 0o600. The mode is set ontmpbeforeos.replaceso the renamed target inherits the mode atomically. (Linux + macOS both preserve source mode acrossos.replacewithin the same dir.) - [ ] AC-16. Atomic-write-failure cleanup. A test monkeypatches
os.replaceto raiseOSError("disk full"); callswrite_recommendation; asserts that (a)pytest.raises(OSError)fires; (b) the target file does NOT exist; (c) the.tmpfile is cleaned up (no leftover*.tmpinout_dir). Implementation MUST usetry/except+tmp.unlink(missing_ok=True)in the failure branch to honor this contract. - [ ] AC-17. Body content.
written.read_text() == verdict.model_dump_json(indent=2). The pretty-printed JSON (indent=2, ASCII keys sorted by Pydantic's deterministic field order) round-trips throughPromotionVerdict.model_validate(json.loads(written.read_text())) == verdict— both==andmodel_dump_json()byte-equal (no float-formatting drift). - [ ] AC-18. Byte-identical reruns.
write_recommendation(verdict, out_dir_a, now=fixed_dt)andwrite_recommendation(verdict, out_dir_b, now=fixed_dt)produce identical bytes on disk (Path.read_bytes()equality). Pins the determinism contract from ADR-0002 §Consequences "byte-identical across reruns" through the recommendation surface. (The two writes go to different out_dirs to avoid the secondos.replaceclobbering the first whennowis the same.)
Wire-up boundary (Coverage F-COV-4 — wire-up tests live in S4-02)¶
- [ ] AC-19. Wire-up boundary. S4-02's
--with-verdictflag invokeswrite_recommendation(verdict)(single import + single call inside the deferred-import block). This story exports the function; S4-02 owns and tests the call site. The auto-flip-detection path (recommendation triggered whenevaluateflipsevidence_sufficientFalse→Truebetween consecutive runs) is documented in Notes for implementer and deferred — no Phase 6.5 code path is required to detect the flip.
Write-only guard (Test-Quality F-TQ-6 — AST-narrowed)¶
- [ ] AC-20.
tests/unit/test_recommendation_not_consumed.pyenforces that no module undersrc/codegenie/eval/reads the recommendation directory as a control signal. The walk: - Iterates
src/codegenie/eval/**/*.py(NOTsrc/codegenie/**/*.py— the doc-comment scope correction). - For each module, walks the AST and only flags an
ast.Constant(str)containing"recommendations"if:- its parent is an
ast.Callwhose.funcresolves to one of{"open", "read_text", "read_bytes", "glob", "iterdir", "rglob"}(read-shaped), AND - the file is not
recommendation.py(the writer module itself).
- its parent is an
- This kills the docstring-false-positive failure mode while preserving the "no silent reader" invariant. Implementation walks
ast.parse(src)and inspectsast.Callnodes forAttribute.attr/Name.idmatches; the read-call name set is a module-levelFinal[frozenset[str]].
Quality gates¶
- [ ] AC-21. The red tests from §TDD plan exist, were committed at the red marker, and are now green.
- [ ] AC-22.
ruff check,ruff format --check,mypy --strict src/codegenie/eval/recommendation.py, andpytest tests/unit/test_trust_tiers_yaml.py tests/unit/test_recommendation_writer.py tests/unit/test_recommendation_not_consumed.pyall pass on touched files.
Implementation outline¶
- Write red tests in this order (each commit gates on
pytestfailing at the import / fixture step): tests/unit/test_trust_tiers_yaml.py— file existence + UNCALIBRATED marker + bronze-only + emptycurrent_tiers+ round-trip + forward-compat fence (schema_version, unknown keys).tests/unit/test_recommendation_writer.py—_utc_iso_filenamepure pin +write_recommendation(filename, mkdir 3-deep, atomic + cleanup-on-failure, mode 0600, body round-trip, byte-identical reruns, deterministic-time injection).tests/unit/test_recommendation_not_consumed.py— AST-narrowed read-call walk oversrc/codegenie/eval/.- Author
docs/trust-tiers.yamlwith the exact content shape from AC-1. Include the SPDX header and theUNCALIBRATEDdisclaimer as a leading comment within the first 500 bytes. - Extend
src/codegenie/eval/tier_config.py(the module S4-04 created) soload_tier_configenforces the forward-compat fence per AC-7: - Parse YAML →
dict[str, Any]. - Reject unknown top-level keys (raise
TierConfigInvalid("unknown_key", <key>, ("current_tiers", "schema_version", "thresholds"))). - Require
schema_version(raiseTierConfigInvalid("schema_version_missing", (1,))). - Reject
schema_version != 1(raiseTierConfigInvalid("schema_version", <got>, (1,))). - Construct
TierConfig(thresholds=..., current_tiers=...)only after the fence passes. - Create
src/codegenie/eval/recommendation.py: - Module-top imports: stdlib only (
from __future__ import annotations,os,statif needed for docs,from datetime import UTC, datetime,from pathlib import Path,from typing import Final), plusfrom codegenie.eval.models import PromotionVerdict. DEFAULT_RECOMMENDATION_DIR: Final[Path] = Path(".codegenie/eval/recommendations")._READ_CALL_NAMES: Final[frozenset[str]] = frozenset({"open", "read_text", "read_bytes", "glob", "iterdir", "rglob"})(consumed only by the static-introspection test via import; placing it here ensures the canonical set is single-sourced — recommended, not required)._utc_iso_filename(now: datetime) -> str— pure helper; returnsnow.astimezone(UTC).strftime("%Y-%m-%dT%H-%M-%SZ") + ".json".write_recommendation(verdict, out_dir=DEFAULT_RECOMMENDATION_DIR, *, now=None) -> Path— the impure shell:resolved_now = now if now is not None else datetime.now(UTC) out_dir.mkdir(parents=True, exist_ok=True) target = out_dir / _utc_iso_filename(resolved_now) tmp = target.with_suffix(target.suffix + ".tmp") tmp.write_text(verdict.model_dump_json(indent=2)) os.chmod(tmp, 0o600) try: os.replace(tmp, target) except OSError: tmp.unlink(missing_ok=True) raise return target- Add the AST-narrowed static-introspection test
tests/unit/test_recommendation_not_consumed.pyper AC-20. - Wire the writer call site from S4-02's
--with-verdictflag (single import + single function call inside the deferred-import block). Verification: S4-02's existing tests cover the wire-up; no test in this story duplicates them. - Run
ruff format,ruff check,mypy --strict src/codegenie/eval/recommendation.py, the full pytest set from AC-22.
TDD plan — red / green / refactor¶
Red — write the failing test first¶
# tests/unit/test_trust_tiers_yaml.py
from pathlib import Path
import pytest
# NOTE: tier_config — NOT promotion — per S4-04 AC-1 module split.
from codegenie.eval.tier_config import load_tier_config, TierConfig
from codegenie.eval.promotion import PromotionGate
from codegenie.eval.registry import TaskClassRegistry
from codegenie.eval.errors import TierConfigInvalid
YAML_PATH = Path("docs/trust-tiers.yaml")
def test_trust_tiers_yaml_exists():
assert YAML_PATH.exists(), "docs/trust-tiers.yaml is contract data — Phase 6.5 must ship it"
def test_trust_tiers_yaml_first_500_bytes_warn_uncalibrated():
# 500 bytes (not 200) — survives an SPDX/licence prefix without forcing a comment-strip.
head = YAML_PATH.read_bytes()[:500].decode("utf-8", errors="replace")
assert "UNCALIBRATED" in head, (
"trust-tiers.yaml must declare UNCALIBRATED in its leading comments per ADR-0003 + ADR-0015"
)
def test_trust_tiers_yaml_only_declares_bronze():
cfg = load_tier_config(YAML_PATH)
assert isinstance(cfg, TierConfig)
assert set(cfg.thresholds) == {"bronze"}, (
"Phase 6.5 ships only bronze; silver/gold/platinum are added by their consuming phase + ADR amendment"
)
assert 0.0 < cfg.thresholds["bronze"] <= 1.0
def test_trust_tiers_yaml_current_tiers_empty():
cfg = load_tier_config(YAML_PATH)
assert cfg.current_tiers == {}, (
"current_tiers is empty until a human PR promotes a task class per ADR-0009"
)
def test_promotion_gate_constructs_from_shipped_yaml():
# Isolate from default_registry — the YAML-vs-registry contract is the focus.
cfg = load_tier_config(YAML_PATH)
gate = PromotionGate(tier_config=cfg, registry=TaskClassRegistry())
assert gate is not None # construction did not raise
# ---------- Forward-compat fence on schema_version (AC-7) ----------
@pytest.mark.parametrize(
"yaml_text, expected_args",
[
# Unknown top-level key — sorted available set for "did you mean" diagnostics.
(
"schema_version: 1\nthresholds:\n bronze: 0.7\ncurrent_tiers: {}\nper_task_class_thresholds: {}\n",
("unknown_key", "per_task_class_thresholds", ("current_tiers", "schema_version", "thresholds")),
),
# schema_version absent.
(
"thresholds:\n bronze: 0.7\ncurrent_tiers: {}\n",
("schema_version_missing", (1,)),
),
# schema_version != 1.
(
"schema_version: 2\nthresholds:\n bronze: 0.7\ncurrent_tiers: {}\n",
("schema_version", 2, (1,)),
),
],
ids=["unknown_top_level_key", "schema_version_missing", "schema_version_not_one"],
)
def test_load_tier_config_forward_compat_fence(tmp_path, yaml_text, expected_args):
p = tmp_path / "trust-tiers.yaml"
p.write_text(yaml_text)
with pytest.raises(TierConfigInvalid) as exc_info:
load_tier_config(p)
assert exc_info.value.args == expected_args, (
f"forward-compat fence args mismatch: got {exc_info.value.args!r}, expected {expected_args!r}"
)
# tests/unit/test_recommendation_writer.py
import json
import os
import re
import stat
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from codegenie.eval.recommendation import (
DEFAULT_RECOMMENDATION_DIR,
_utc_iso_filename,
write_recommendation,
)
from codegenie.eval.models import PromotionVerdict
@pytest.fixture
def sample_verdict() -> PromotionVerdict:
# All EIGHT S1-02-required fields explicit; full validation (NOT model_construct,
# which S4-04 AC-10 forbids). `chain_head` / `run_id` are BenchRunReport fields
# — they are NOT on PromotionVerdict; including them would violate extra="forbid".
return PromotionVerdict(
task_class="vuln-remediation",
current_tier="bronze",
target_tier="bronze",
evidence_sufficient=True,
reasons=("all conditions met",),
lower_bound_95=0.78,
threshold_at_target=0.70,
requires_human_approval=True,
)
# ---------- Pure helper pin (AC-11) ----------
def test_utc_iso_filename_is_pure_and_byte_pinned():
got = _utc_iso_filename(datetime(2026, 6, 4, 12, 30, 45, tzinfo=UTC))
assert got == "2026-06-04T12-30-45Z.json"
def test_utc_iso_filename_normalizes_naive_to_utc_via_astimezone():
# A non-UTC tz-aware input is normalized via astimezone — same byte-string output.
from datetime import timezone
cst = timezone(timedelta(hours=-6))
got = _utc_iso_filename(datetime(2026, 6, 4, 6, 30, 45, tzinfo=cst))
assert got == "2026-06-04T12-30-45Z.json"
# ---------- Filename + proximity (AC-10, AC-12) ----------
def test_writer_filename_exact_with_injected_now(tmp_path, sample_verdict):
fixed = datetime(2026, 6, 4, 12, 30, 45, tzinfo=UTC)
written = write_recommendation(sample_verdict, out_dir=tmp_path, now=fixed)
assert written.name == "2026-06-04T12-30-45Z.json"
def test_writer_filename_default_now_within_10s(tmp_path, sample_verdict):
before = datetime.now(UTC)
written = write_recommendation(sample_verdict, out_dir=tmp_path)
after = datetime.now(UTC)
assert re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z\.json", written.name)
parsed = datetime.strptime(written.stem, "%Y-%m-%dT%H-%M-%SZ").replace(tzinfo=UTC)
# ±10s window kills mutants that return a constant filename.
assert before - timedelta(seconds=10) <= parsed <= after + timedelta(seconds=10)
# ---------- Directory creation (AC-13) ----------
def test_writer_creates_three_nested_levels(tmp_path, sample_verdict):
out = tmp_path / "a" / "b" / "c" # 3 levels — kills parents=False mutant
assert not out.exists()
written = write_recommendation(sample_verdict, out_dir=out)
assert written.exists()
assert written.parent == out
def test_writer_idempotent_on_existing_dir(tmp_path, sample_verdict):
out = tmp_path / "exists"
out.mkdir()
written = write_recommendation(sample_verdict, out_dir=out, now=datetime(2026, 6, 4, 0, 0, 0, tzinfo=UTC))
assert written.exists()
# ---------- Atomic write + cleanup on failure (AC-14, AC-16) ----------
def test_writer_atomic_no_temp_left_behind_on_success(tmp_path, sample_verdict):
write_recommendation(sample_verdict, out_dir=tmp_path)
assert list(tmp_path.glob("*.tmp")) == []
def test_writer_cleans_up_tmp_when_os_replace_raises(tmp_path, sample_verdict, monkeypatch):
target_dt = datetime(2026, 6, 4, 12, 30, 45, tzinfo=UTC)
def boom(_src, _dst):
raise OSError("disk full")
monkeypatch.setattr("codegenie.eval.recommendation.os.replace", boom)
with pytest.raises(OSError, match="disk full"):
write_recommendation(sample_verdict, out_dir=tmp_path, now=target_dt)
# Target absent, no .tmp leak.
assert not (tmp_path / "2026-06-04T12-30-45Z.json").exists()
assert list(tmp_path.glob("*.tmp")) == [], (
"atomic-write failure path must unlink the temp file"
)
# ---------- Mode 0o600 (AC-15) ----------
def test_writer_mode_is_0600(tmp_path, sample_verdict):
written = write_recommendation(sample_verdict, out_dir=tmp_path)
assert stat.S_IMODE(os.stat(written).st_mode) == 0o600
# ---------- Payload + round-trip + byte-identical reruns (AC-17, AC-18) ----------
def test_writer_payload_round_trips_to_verdict(tmp_path, sample_verdict):
written = write_recommendation(sample_verdict, out_dir=tmp_path)
text = written.read_text()
assert "\n" in text, "indent=2 pretty-printed for operator UX"
assert text == sample_verdict.model_dump_json(indent=2)
reread = PromotionVerdict.model_validate(json.loads(text))
assert reread == sample_verdict
# And the rehydrated model serializes byte-equal.
assert reread.model_dump_json(indent=2) == text
def test_writer_byte_identical_reruns_same_verdict_same_now(tmp_path, sample_verdict):
fixed = datetime(2026, 6, 4, 12, 30, 45, tzinfo=UTC)
out_a = tmp_path / "a"
out_b = tmp_path / "b"
w_a = write_recommendation(sample_verdict, out_dir=out_a, now=fixed)
w_b = write_recommendation(sample_verdict, out_dir=out_b, now=fixed)
assert w_a.read_bytes() == w_b.read_bytes()
# ---------- Default-dir constant pin (AC-9) ----------
def test_default_recommendation_dir_is_module_constant():
assert DEFAULT_RECOMMENDATION_DIR == Path(".codegenie/eval/recommendations")
# tests/unit/test_recommendation_not_consumed.py
"""Static guard: no module under src/codegenie/eval/ may read .codegenie/eval/recommendations
as a control signal. Recommendations are write-only from this phase per ADR-0009 and
phase-arch-design §Failure modes ("the system never branches on 'evidence sufficient'
automatically"). Scoped to `src/codegenie/eval/` only — the writer module itself is exempt.
The walk is AST-narrowed: only flags string literals passed as arguments to read-shaped calls
(open / read_text / read_bytes / glob / iterdir / rglob), so docstrings and log messages
mentioning the word do not false-positive."""
from __future__ import annotations
import ast
from pathlib import Path
SRC_ROOT = Path("src/codegenie/eval") # phase-scoped, not repo-wide
FORBIDDEN_SUBSTR = "recommendations"
READ_CALL_NAMES = frozenset({"open", "read_text", "read_bytes", "glob", "iterdir", "rglob"})
EXEMPT_FILES = frozenset({"recommendation.py"})
def _is_read_call(call: ast.Call) -> bool:
func = call.func
if isinstance(func, ast.Attribute):
return func.attr in READ_CALL_NAMES
if isinstance(func, ast.Name):
return func.id in READ_CALL_NAMES
return False
def _literal_contains_forbidden(node: ast.AST) -> bool:
return (
isinstance(node, ast.Constant)
and isinstance(node.value, str)
and FORBIDDEN_SUBSTR in node.value
)
def test_no_eval_module_reads_recommendation_files_via_read_calls():
offenders: list[tuple[Path, int, str]] = []
for f in SRC_ROOT.rglob("*.py"):
if f.name in EXEMPT_FILES:
continue
try:
tree = ast.parse(f.read_text())
except SyntaxError:
continue
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
if not _is_read_call(node):
continue
# Check every arg + keyword value for a literal with the forbidden substring.
candidates: list[ast.AST] = list(node.args) + [kw.value for kw in node.keywords]
# Also check Path(...)/Path("...") receivers: e.g. Path("…recommendations…").glob(...)
if isinstance(node.func, ast.Attribute):
candidates.append(node.func.value)
for cand in candidates:
if _literal_contains_forbidden(cand):
offenders.append((f, node.lineno, getattr(cand, "value", "")))
assert offenders == [], (
f"src/codegenie/eval/ modules must not read recommendation files as a control signal: {offenders}"
)
Run; confirm failures (missing YAML, missing module). Commit as the red marker.
Green — make it pass¶
- Author
docs/trust-tiers.yamlper AC-1. - Extend
src/codegenie/eval/tier_config.py(existing) with the forward-compat fence per AC-7. Sketch:_ALLOWED_TOP_LEVEL_KEYS = frozenset({"schema_version", "thresholds", "current_tiers"}) _SUPPORTED_SCHEMA_VERSIONS: Final[tuple[int, ...]] = (1,) def load_tier_config(path: Path) -> TierConfig: data = yaml.safe_load(path.read_text()) or {} if not isinstance(data, dict): raise TierConfigInvalid("top_level_not_mapping", type(data).__name__) extras = sorted(set(data) - _ALLOWED_TOP_LEVEL_KEYS) if extras: # First extra wins for diagnostic clarity; sorted available set for "did you mean". raise TierConfigInvalid( "unknown_key", extras[0], tuple(sorted(_ALLOWED_TOP_LEVEL_KEYS)) ) if "schema_version" not in data: raise TierConfigInvalid("schema_version_missing", _SUPPORTED_SCHEMA_VERSIONS) if data["schema_version"] not in _SUPPORTED_SCHEMA_VERSIONS: raise TierConfigInvalid( "schema_version", data["schema_version"], _SUPPORTED_SCHEMA_VERSIONS ) return TierConfig( thresholds=data.get("thresholds", {}), current_tiers=data.get("current_tiers", {}), ) - Implement
src/codegenie/eval/recommendation.py:from __future__ import annotations import os from datetime import UTC, datetime from pathlib import Path from typing import Final from codegenie.eval.models import PromotionVerdict DEFAULT_RECOMMENDATION_DIR: Final[Path] = Path(".codegenie/eval/recommendations") def _utc_iso_filename(now: datetime) -> str: return now.astimezone(UTC).strftime("%Y-%m-%dT%H-%M-%SZ") + ".json" def write_recommendation( verdict: PromotionVerdict, out_dir: Path = DEFAULT_RECOMMENDATION_DIR, *, now: datetime | None = None, ) -> Path: resolved_now = now if now is not None else datetime.now(UTC) out_dir.mkdir(parents=True, exist_ok=True) target = out_dir / _utc_iso_filename(resolved_now) tmp = target.with_suffix(target.suffix + ".tmp") tmp.write_text(verdict.model_dump_json(indent=2)) os.chmod(tmp, 0o600) try: os.replace(tmp, target) except OSError: tmp.unlink(missing_ok=True) raise return target - Wire S4-02's
--with-verdictcall site to import this module lazily and callwrite_recommendation(verdict)aftergate.evaluate(report, target_tier).
Refactor — clean up¶
- Type hints on every callable;
mypy --strictclean. - Module docstring cites ADR-0003 (YAML is contract data) and ADR-0009 (recommendation is the side-effect surface).
- The
_utc_iso_filenamehelper is a single-line function but it's worth its own name because S4-02's audit JSON uses the same convention; if S4-02 already exports a similar helper, reuse rather than duplicate (Rule 8 — read before you write). If duplication is unavoidable, file a follow-up to consolidate into a shared_paths.py. - The static-introspection test (
test_recommendation_not_consumed.py) is a "fence" assertion in the same family as S1-05's import-ban tests; it gets stricter over time as the codebase grows. Document it in a comment so future contributors understand the intent. - The YAML's
schema_version: 1field is forward-looking — when production ADR-0015 calibrates real numbers, the schema may bump. Phase 6.5 ships v1; readers know how to detect drift.
Files to touch¶
| Path | Why |
|---|---|
docs/trust-tiers.yaml |
New file — minimal schema (schema_version: 1 load-bearing), bronze candidate, UNCALIBRATED header per ADR-0003 + ADR-0015. |
src/codegenie/eval/tier_config.py |
Surgical extension of the module S4-04 created — load_tier_config gains the forward-compat fence (allowed-keys set + schema_version validation). The TierConfig dataclass and MappingProxyType normalization stay as S4-04 shipped them. |
src/codegenie/eval/recommendation.py |
New file — DEFAULT_RECOMMENDATION_DIR constant + _utc_iso_filename(now) pure helper + write_recommendation(verdict, out_dir, *, now=None) impure shell with atomic write, mode 0600, and OSError-cleanup. |
tests/unit/test_trust_tiers_yaml.py |
New file — YAML existence, 500-byte UNCALIBRATED disclaimer, bronze-only, empty current_tiers, gate constructs against fresh registry, parametrized forward-compat fence (unknown_top_level_key, schema_version_missing, schema_version_not_one). |
tests/unit/test_recommendation_writer.py |
New file — _utc_iso_filename pure pin (+ tz-normalization), filename exact-with-injected-now + ±10s default-now proximity, 3-level parents=True mkdir, atomic + OSError-cleanup, mode 0600, body round-trip, byte-identical reruns, DEFAULT_RECOMMENDATION_DIR constant. |
tests/unit/test_recommendation_not_consumed.py |
New file — AST-narrowed static guard: walks src/codegenie/eval/ only, flags only string literals passed to read-shaped calls (open / read_text / read_bytes / glob / iterdir / rglob) containing recommendations; recommendation.py exempt. |
src/codegenie/eval/cli.py |
Surgical edit — wire write_recommendation call into S4-02's --with-verdict block (deferred import). Tests for this wire-up live in S4-02. |
Out of scope¶
- Schema versioning beyond v1 —
schema_version: 1is the lone field; perphase-arch-design.md §Open Q #5, versioning rules defer to ADR-0015. - Per-task-class threshold overrides — also deferred per
phase-arch-design.md §Open Q #5. The Phase 6.5 schema has only a globalthresholdsmapping. - Downgrade-threshold equality — also deferred per
phase-arch-design.md §Open Q #5. silver,gold,platinumtier numbers — Phase 7 (silver for migration), Phase 15 (recipe authoring), and ADR-0015 calibration will add these in their own PRs + ADR amendments.- Recommendation file consumers (Phase 11/12) —
phase-arch-design.md §Open Q #7defers; Phase 6.5 writes the contract; consumers ship later. - Auto-flip detection in the CLI loop — the "flip from
FalsetoTruebetween consecutive runs writes a recommendation" path is documented in §Acceptance criteria but the CLI implementation defers to a follow-up if needed; for Phase 6.5, the--with-verdictflag is the explicit trigger. - YAML migration tooling — when ADR-0015 calibration ships real numbers, a script may convert candidate values; Phase 6.5 ships the file by hand.
Notes for the implementer¶
- The
UNCALIBRATEDheader is load-bearing UX. Future contributors whogrepdocs/trust-tiers.yamlfor tier numbers must immediately understand these are not committed. The first 500 bytes test pins this — do not strip the comment to "save space." schema_version: 1is load-bearing, not decorative. The forward-compat fence (AC-7) raises on missing or non-1values AND on any unknown top-level key. Whensilver/gold/platinumthresholds land in a later phase, that phase's ADR amendment must either (a) keepschema_version: 1AND extend the allowed-keys set intier_config.py(extension by addition), or (b) bump toschema_version: 2with a v2 loader. Addingper_task_class_thresholdswithout one of those — silent admission — is exactly the failure mode the fence prevents. Do not weaken_ALLOWED_TOP_LEVEL_KEYSto make a v2 YAML "just work"; force the ADR amendment loud.load_tier_configis the loader.TierConfigis the value. Per S4-04 Design-Patterns F-DP-3 (functional-core / imperative-shell), the loader lives intier_config.pywith the onlyimport yamlinsrc/codegenie/eval/. Do not movepyyamlintopromotion.pyorrecommendation.py.- Filename helper is pure.
_utc_iso_filename(now: datetime) -> strtakesnowas a parameter so tests can pin the byte string without monkeypatchingdatetime. The impure shellwrite_recommendation(..., now=None)resolves the default. This is functional-core / imperative-shell at the smallest possible scope; do not collapse them. - Same-second collision is an accepted residual.
phase-arch-design.md §Dynamic view → Sequenceline 496 specifies the filename literal<utc-iso>.json— no-<short>hex suffix. Two writes in the same UTC second collide; the second'sos.replaceclobbers the first. Operators rarely flip verdicts twice in one second; if it happens, both writes contain the same verdict shape if the eval inputs are identical, so the loss is point-in-time advisory data only. Document, do not fix. - Atomic write discipline —
os.replaceis the POSIX-atomic rename; on Windowsos.replacealso works (Python 3.3+). Do not usePath.write_textdirectly because it overwrites partial-write debris on crash. The same pattern applies to the audit JSON in S4-02. - Mode
0600— recommendation files may contain task-class names and verdict reasons that name internal failure codes; default0644would expose them to other unprivileged users on shared CI runners. The mode-0600 discipline is consistent with the audit chain in S2-04. The chmod is ontmpbeforeos.replace, so the renamed target inherits the mode atomically on both Linux and macOS. - OSError-cleanup branch is contract, not "nice to have." AC-16 pins it. A future "let's simplify" PR that removes the
try/except + unlink + raisewould silently leave.tmpfiles on disk-full scenarios. The test will fail loud; do not skip it. PromotionVerdictfields the fixture pins. All eight S1-02-required fields:task_class,current_tier,target_tier,evidence_sufficient,reasons,lower_bound_95,threshold_at_target,requires_human_approval=True.chain_headandrun_idbelong toBenchRunReport, notPromotionVerdict— including them would violateextra="forbid"and raiseValidationError.PromotionVerdict.model_constructis forbidden per S4-04 AC-10 — full Pydantic validation must succeed.- Why pretty-print (
indent=2) for recommendations but not necessarily for audit JSON? Operators read recommendation files (decide whether to open a tier-promotion PR); audit JSON is machine-consumed (Phase 11 reads it). Pretty-printing audit JSON is fine but not required; for recommendations it is operator UX. - The static
test_recommendation_not_consumedtest is paranoid by design. It will false-positive on any harmless string literal containing "recommendations" elsewhere insrc/codegenie/. The mitigation is: there shouldn't be any such literal in Phase 6.5 code outsiderecommendation.py. If a legitimate use case appears in a later phase, refine the test; do not weaken it pre-emptively. - Coordinate the filename helper with S4-02. S4-02 names audit JSONs
<run_started_iso>-<short>.json; recommendation files use<utc-iso>.json(no run_id suffix, because a recommendation is point-in-time, not run-scoped). Different conventions are correct here — do not unify under pressure. - The YAML's
current_tiers: {}empty mapping is intentional. Phase 6.5 emits no committed tiers. Evenvuln-remediation(which Phase 6.5 backfills with ≥10 cases) is not listed as bronze incurrent_tiers— its bronze status requires a human PR after S5-05 produces the firstBenchRunReport.lower_bound_95. This is the architecture's commitment to humans-always-merge taken to its logical conclusion.