Story S1-01 — IndexFreshness sum type at codegenie.indices.freshness¶
Step: Step 1 — Plant new domain primitives, kernel contracts, and the nine new ADRs
Status: Done (executed by phase-story-executor 2026-05-15; attempt log: _attempts/S1-01.md)
Effort: M
Depends on: —
ADRs honored: 02-ADR-0006
Execution evidence¶
All 12 acceptance criteria pass with runtime evidence (see _attempts/S1-01.md §"Acceptance criteria — evidence" for the AC-by-AC mapping). Summary:
- Tests added: 10 unit tests in
tests/unit/indices/test_freshness.py+ 1 Hypothesis property test intests/property/test_index_freshness_roundtrip.py. - Implementation:
src/codegenie/indices/__init__.py+src/codegenie/indices/freshness.py(stdlib + Pydantic only; no logger, no I/O, no registry — Open/Closed seam for new index sources lives in S1-02). - Gates:
ruff format --check,ruff check,mypy --strict src/, full pytest suite (1562 passed, coverage 93.20%). - Dep change: added
hypothesisto[project.optional-dependencies].devand regenerateduv.lock.
Validation notes¶
Hardened by phase-story-validator on 2026-05-15 (report: _validation/S1-01-index-freshness-sum-type.md). Four critic lenses (coverage, test-quality, consistency, design-patterns) ran; verdict HARDENED, no RESCUE. Substantive changes:
- AC-4 tightened — round-trip preserves the typed nested reason, not just the top-level Stale type. A mutation that drops the
Field(discriminator="kind")wrapper fromStaleReasoncould pass the original AC-4 (Stale comes back as Stale) yet leavedecoded.reasonas a plain dict. Added per-variant assertion thattype(decoded.reason) is type(instance.reason)for everyStalecase. - AC-2 tightened — discriminator strings are pinned values, not implementation choices. Each
kindLiteral must be exactly the string named in02-ADR-0006 §Decisionandphase-arch-design.md §"Data model". A swap (e.g.,CommitsBehind.kind = "digest_mismatch") would route correctly through the round-trip and break every real consumer; now caught by a dedicated assertion. - AC-6a added — exhaustive
matchtest at the top-levelIndexFreshness(Fresh | Stale), symmetric with the existingStaleReasonexhaustive test. The renderer (S8-01) mustmatchat this layer; the discipline is rehearsed in this story. - AC-10 added — JSON-shape pin. A representative
FreshandStale(reason=…)instance'smodel_dump()is compared against a literal dict ({"kind": "fresh", "indexed_at": …}/{"kind": "stale", "reason": {"kind": "commits_behind", "n": …, "last_indexed": …}}). Round-trip identity alone tolerates a symmetric rename ofkind→tag; the JSON-shape pin does not. - AC-11 added — Hypothesis property test
tests/property/test_index_freshness_roundtrip.py, scaffolding the deliverable named in02-ADR-0006 §Consequences(tests/property/test_index_freshness_roundtrip.py— Hypothesis: anyIndexFreshnessround-trips identity-equal).High-level-impl.mdStep 7 §247 references this test as "already may exist from Step 1's freshness tests; extended here for portfolio-wide round-trip" — Step 1 (this story) owns the scaffold. - Notes for implementer extended — Open/Closed seam handoff to S1-02. S1-02 lands
@register_index_freshness_check(index_name: IndexName)on top of this module.freshness.pyMUST remain pure data (no decorator, no registry dict, no I/O) so S1-02 can layer the registry insrc/codegenie/indices/registry.pywithout circular imports. - Notes for implementer extended — variant-set extension is intentionally ADR-amendment-gated (named-trigger per ADR-0006 §Consequences). Adding a fifth
StaleReasonis NOT an Open/Closed-by-addition seam — that's by design. Theassert_neverin every consumer'smatchis the structural enforcement against silent widening.
No RESCUE-tier findings. No NEEDS RESEARCH (Stage 3 skipped — every gap was answerable from arch + ADR-0006).
Context¶
IndexFreshness is the typed answer Phase 2 gives to commitment §2.3 (silent index staleness is the worst failure mode in the system). The probe (IndexHealthProbe / B2) returns it; the renderer (ConfidenceSection) consumes it; Phase 3 plugins read it as bundle metadata. This story is the very first thing landed in Phase 2 — every later step depends on this discriminated union existing, byte-stable through JSON round-trip, and exhaustively pattern-matchable.
References — where to look¶
- Architecture:
../phase-arch-design.md §"Component design" #2 — IndexFreshness sum type— public interface, smart-constructor commitment, why the package lives outsideprobes/.../phase-arch-design.md §"Data model"— the exact Pydantic shape (frozen=True, extra="forbid",Literal["..."]discriminators onkind,Annotated[Union[...], Field(discriminator="kind")]).../phase-arch-design.md §"Design patterns applied"row 1 — sum type / make-illegal-states-unrepresentable; whyOptional[str]andNull Objectwere rejected.../phase-arch-design.md §"Edge cases"row 11 — the load-bearing stale-scip case asserts exact typed outcome.- Phase ADRs (rules this story must honor):
../ADRs/0006-index-freshness-sum-type-location.md— 02-ADR-0006 —IndexFreshnesslives atcodegenie.indices.freshness, with one Phase-2 consumer (report/confidence_section.py);AdapterConfidence/IndexConfidenceare NOT Phase 2.- Production ADRs (if applicable):
../../../production/adrs/0005-no-llm-in-gather-pipeline.md— composes: typed freshness is the gather-side honest-confidence surface a Planner will later read.- Source design:
../final-design.md §"Synthesis ledger" — IndexFreshness sum type, single Phase-2 consumer— the synthesizer's commitment to "one name, one module, one consumer."- Existing code:
src/codegenie/parsers/safe_yaml.py— Pydantic-shaped Phase 1 style; mirror itsfrozen=True, extra="forbid"discipline.src/codegenie/probes/base.py— theProbeOutput.schema_slice: dict[str, Any]boundaryIndexFreshnessvalues flow through.- External docs (only if directly relevant):
- https://docs.pydantic.dev/latest/concepts/unions/#discriminated-unions — discriminated-union shape (
Annotated[Union[...], Field(discriminator="kind")]).
Goal¶
Implement src/codegenie/indices/{__init__.py,freshness.py} as a Pydantic discriminated-union sum type — IndexFreshness = Fresh | Stale(reason: StaleReason) with StaleReason = CommitsBehind | DigestMismatch | CoverageGap | IndexerError — that round-trips identity through model_dump_json / model_validate_json and is exhaustively matchable with assert_never.
Acceptance criteria¶
- [ ] AC-1.
src/codegenie/indices/__init__.pyexports the full variant set in__all__:["IndexFreshness", "Fresh", "Stale", "StaleReason", "CommitsBehind", "DigestMismatch", "CoverageGap", "IndexerError"]. Each name resolves to a class object importable asfrom codegenie.indices import Fresh, Stale, …. - [ ] AC-2. Every concrete model in
freshness.pyis PydanticBaseModelwithmodel_config = ConfigDict(frozen=True, extra="forbid")and akind: Literal["..."]field with the exact discriminator string named in02-ADR-0006 §Decision/phase-arch-design.md §"Data model":Fresh.kind == "fresh",Stale.kind == "stale",CommitsBehind.kind == "commits_behind",DigestMismatch.kind == "digest_mismatch",CoverageGap.kind == "coverage_gap",IndexerError.kind == "indexer_error". Each discriminator string is unique within itsUnion. The strings are a cross-ADR contract; changing one is an ADR amendment, not a refactor. - [ ] AC-3.
StaleReason = Annotated[Union[CommitsBehind, DigestMismatch, CoverageGap, IndexerError], Field(discriminator="kind")]andIndexFreshness = Annotated[Union[Fresh, Stale], Field(discriminator="kind")]; type aliases are at module scope; importable directly. - [ ] AC-4. For every variant,
TypeAdapter(IndexFreshness).validate_json(TypeAdapter(IndexFreshness).dump_json(instance)) == instance(round-trip identity, parametrized). For everyStale(reason=R)instance the round-trip additionally preserves the nested typed reason:type(decoded.reason) is type(instance.reason). (Top-level type equality alone is insufficient — a regression that drops the discriminator wrapper fromStaleReasoncould leavedecoded.reasonas a plaindictwhileStaleitself still round-trips equal.) - [ ] AC-5. Constructing
Stale(reason=…)succeeds for everyStaleReasonvariant; constructingStale.model_validate({"kind": "stale", "reason": {"kind": "bogus_reason", "x": 1}})raisespydantic.ValidationError. ConstructingTypeAdapter(IndexFreshness).validate_python({"kind": "bogus_freshness"})likewise raises (the top-level discriminator is enforced). - [ ] AC-6. Exhaustive
matchtest over everyStaleReasonvariant terminates withassert_never(reason)in the default arm; with all four arms present, mypy is silent. Removing one arm and re-runningmypy --warn-unreachableon this module is a build error (verified by S1-11 once the per-module override lands; this story documents the expectation in the test docstring). - [ ] AC-6a. Exhaustive
matchtest over the top-levelIndexFreshness(Fresh | Stale) terminates withassert_never(freshness)in the default arm. Symmetric with AC-6; mirrors the disciplinereport/confidence_section.py(S8-01) will inherit. - [ ] AC-7. The TDD plan's red test exists, was committed, and is green.
- [ ] AC-8.
model_constructis not used anywhere infreshness.py(the forbidden-patterns rule lands in S1-11 but the discipline starts here); verified by a source-scan test. - [ ] AC-9.
ruff check,ruff format --check,mypy --strict, andpytest tests/unit/indices/test_freshness.py tests/property/test_index_freshness_roundtrip.pyall pass on the touched files. - [ ] AC-10. JSON shape pin.
Fresh(indexed_at=datetime(2026,1,1,tzinfo=timezone.utc)).model_dump(mode="json")has exactly{"kind", "indexed_at"}as its keys withkind == "fresh".Stale(reason=CommitsBehind(n=3, last_indexed="abc1234")).model_dump(mode="json")equals{"kind": "stale", "reason": {"kind": "commits_behind", "n": 3, "last_indexed": "abc1234"}}— nestedkindis present at both levels; no extra fields. Catches a symmetrickind → tagrename that AC-4's round-trip identity would tolerate. - [ ] AC-11.
tests/property/test_index_freshness_roundtrip.pyexists, uses Hypothesis to build arbitraryIndexFreshnessvalues (one composite strategy per variant), and asserts round-trip identity (TypeAdapter(IndexFreshness)dump/load returns an equal value AND preserves the nestedStale.reasonconcrete type). Required by02-ADR-0006 §Consequences; referenced by Step 7 (High-level-impl.md §247) as already existing from Step 1.
Implementation outline¶
- Create
src/codegenie/indices/__init__.pyre-exporting the variant set via__all__. - Create
src/codegenie/indices/freshness.pywith the fourStaleReasonvariants, thenFreshandStale, then the twoAnnotated[Union[...], Field(discriminator="kind")]aliases. - Use
pydantic.BaseModel+ConfigDict(frozen=True, extra="forbid"); populate every field with explicit types (n: int,last_indexed: strraw — commit shas are str at the I/O boundary by design, see../phase-arch-design.md §"Data model"). - Add module docstring naming the consumer (
codegenie.report.confidence_section) and pointing at ADR-0006. - Write the red test first (see TDD plan); confirm
ImportError/AttributeError; commit. - Implement the module; confirm the test goes green.
- Refactor: add docstrings on each class naming when the variant is constructed; ensure
__all__is sorted; re-run gates.
TDD plan — red / green / refactor¶
Red — write the failing test first¶
Test file path: tests/unit/indices/test_freshness.py
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import assert_never
import pytest
from pydantic import ValidationError
from codegenie.indices import (
CommitsBehind,
CoverageGap,
DigestMismatch,
Fresh,
IndexerError,
IndexFreshness,
Stale,
StaleReason,
)
STALE_REASONS: list[StaleReason] = [
CommitsBehind(n=3, last_indexed="abc1234"),
DigestMismatch(expected="sha256:aaa", actual="sha256:bbb"),
CoverageGap(files_indexed=900, files_in_repo=1000),
IndexerError(message="strace_unavailable"),
]
FRESHNESS_INSTANCES: list[IndexFreshness] = [
Fresh(indexed_at=datetime(2026, 5, 14, tzinfo=timezone.utc)),
*(Stale(reason=r) for r in STALE_REASONS),
]
@pytest.mark.parametrize("instance", FRESHNESS_INSTANCES)
def test_index_freshness_roundtrip_identity(instance: IndexFreshness) -> None:
# arrange: a freshness value
# act: dump → load via discriminated union
from pydantic import TypeAdapter
adapter = TypeAdapter(IndexFreshness)
encoded = adapter.dump_json(instance)
decoded = adapter.validate_json(encoded)
# assert: round-trip is identity; the discriminator routes correctly.
assert decoded == instance
assert type(decoded) is type(instance)
# AC-4: nested reason concrete type is preserved (guards against a
# regression that drops Field(discriminator="kind") from StaleReason,
# which would leave decoded.reason as a plain dict while Stale itself
# still round-trips equal).
if isinstance(instance, Stale):
assert isinstance(decoded, Stale)
assert type(decoded.reason) is type(instance.reason)
def test_discriminator_strings_are_exactly_pinned() -> None:
"""AC-2: discriminator strings are a cross-ADR contract (02-ADR-0006).
A symmetric swap (CommitsBehind.kind = "digest_mismatch" + DigestMismatch.kind = "commits_behind")
would pass the round-trip test but break every real consumer; pin the exact strings."""
assert Fresh(indexed_at=datetime(2026, 1, 1, tzinfo=timezone.utc)).kind == "fresh"
assert Stale(reason=CommitsBehind(n=1, last_indexed="x")).kind == "stale"
assert CommitsBehind(n=1, last_indexed="x").kind == "commits_behind"
assert DigestMismatch(expected="a", actual="b").kind == "digest_mismatch"
assert CoverageGap(files_indexed=1, files_in_repo=2).kind == "coverage_gap"
assert IndexerError(message="x").kind == "indexer_error"
def test_json_shape_pinned() -> None:
"""AC-10: round-trip identity alone tolerates a symmetric `kind → tag` rename;
the JSON-shape pin does not."""
fresh_dump = Fresh(indexed_at=datetime(2026, 1, 1, tzinfo=timezone.utc)).model_dump(mode="json")
assert fresh_dump["kind"] == "fresh"
assert "indexed_at" in fresh_dump
assert set(fresh_dump.keys()) == {"kind", "indexed_at"}
stale_dump = Stale(reason=CommitsBehind(n=3, last_indexed="abc1234")).model_dump(mode="json")
assert stale_dump == {
"kind": "stale",
"reason": {"kind": "commits_behind", "n": 3, "last_indexed": "abc1234"},
}
def test_top_level_unknown_kind_is_rejected() -> None:
"""AC-5: the top-level IndexFreshness discriminator is enforced."""
from pydantic import TypeAdapter
adapter = TypeAdapter(IndexFreshness)
with pytest.raises(ValidationError):
adapter.validate_python({"kind": "bogus_freshness"})
def test_stale_reason_rejects_unknown_kind() -> None:
# Construction via raw dict must reject an unknown discriminator.
with pytest.raises(ValidationError):
Stale.model_validate({"kind": "stale", "reason": {"kind": "bogus", "x": 1}})
def test_models_are_frozen_and_forbid_extra() -> None:
inst = CommitsBehind(n=1, last_indexed="abc")
with pytest.raises(ValidationError):
# extra="forbid"
CommitsBehind.model_validate({"kind": "commits_behind", "n": 1, "last_indexed": "x", "extra": "no"})
with pytest.raises(ValidationError):
# frozen=True
inst.n = 2 # type: ignore[misc]
def test_match_is_exhaustive_over_stale_reason() -> None:
"""If a future contributor adds a fifth StaleReason without updating this
match, mypy --warn-unreachable on this module (S1-11 override) will flag
the `assert_never(reason)` line. At runtime this test only confirms every
current variant routes."""
seen: set[str] = set()
for reason in STALE_REASONS:
match reason:
case CommitsBehind():
seen.add("commits_behind")
case DigestMismatch():
seen.add("digest_mismatch")
case CoverageGap():
seen.add("coverage_gap")
case IndexerError():
seen.add("indexer_error")
case _ as unexpected:
assert_never(unexpected)
assert seen == {"commits_behind", "digest_mismatch", "coverage_gap", "indexer_error"}
def test_match_is_exhaustive_over_index_freshness_top_level() -> None:
"""AC-6a: symmetric with test_match_is_exhaustive_over_stale_reason.
The renderer (S8-01 — `report/confidence_section.py`) must `match` at this
layer; the exhaustive discipline is rehearsed here so a future third
top-level variant cannot be added without breaking every consumer's mypy
build (via S1-11's `mypy --warn-unreachable` per-module override)."""
seen: set[str] = set()
for freshness in FRESHNESS_INSTANCES:
match freshness:
case Fresh():
seen.add("fresh")
case Stale():
seen.add("stale")
case _ as unexpected:
assert_never(unexpected)
assert seen == {"fresh", "stale"}
def test_all_exports_full_variant_set() -> None:
import codegenie.indices as m
assert set(m.__all__) == {
"IndexFreshness", "Fresh", "Stale", "StaleReason",
"CommitsBehind", "DigestMismatch", "CoverageGap", "IndexerError",
}
def test_freshness_module_has_no_model_construct() -> None:
"""AC-8: `model_construct` bypasses validation; the forbidden-patterns
rule lands in S1-11, but the discipline starts here. Source-scan guard."""
from pathlib import Path
import codegenie.indices.freshness as freshness_mod
source = Path(freshness_mod.__file__).read_text()
assert "model_construct" not in source
Run — confirm ImportError: cannot import name 'IndexFreshness' from 'codegenie.indices'. Commit as the red marker.
Property test — tests/property/test_index_freshness_roundtrip.py (AC-11; required by 02-ADR-0006 §Consequences):
from __future__ import annotations
from datetime import datetime, timezone
from hypothesis import given, strategies as st
from pydantic import TypeAdapter
from codegenie.indices import (
CommitsBehind, CoverageGap, DigestMismatch, Fresh, IndexFreshness,
IndexerError, Stale, StaleReason,
)
# Strategies — one per concrete variant.
_aware_datetimes = st.datetimes(
min_value=datetime(2020, 1, 1), max_value=datetime(2030, 1, 1),
timezones=st.just(timezone.utc),
)
_commits_behind = st.builds(
CommitsBehind,
n=st.integers(min_value=0, max_value=10_000),
last_indexed=st.text(alphabet="0123456789abcdef", min_size=7, max_size=40),
)
_digest_mismatch = st.builds(
DigestMismatch,
expected=st.text(min_size=1, max_size=80),
actual=st.text(min_size=1, max_size=80),
)
_coverage_gap = st.builds(
CoverageGap,
files_indexed=st.integers(min_value=0, max_value=1_000_000),
files_in_repo=st.integers(min_value=0, max_value=1_000_000),
)
_indexer_error = st.builds(IndexerError, message=st.text(min_size=1, max_size=120))
_stale_reasons: st.SearchStrategy[StaleReason] = st.one_of(
_commits_behind, _digest_mismatch, _coverage_gap, _indexer_error,
)
_freshness: st.SearchStrategy[IndexFreshness] = st.one_of(
st.builds(Fresh, indexed_at=_aware_datetimes),
st.builds(Stale, reason=_stale_reasons),
)
_adapter: TypeAdapter[IndexFreshness] = TypeAdapter(IndexFreshness)
@given(value=_freshness)
def test_index_freshness_roundtrips_identity(value: IndexFreshness) -> None:
decoded = _adapter.validate_json(_adapter.dump_json(value))
# Top-level: identity-equal and concrete type preserved.
assert decoded == value
assert type(decoded) is type(value)
# Nested reason for Stale: concrete type preserved (guards against
# silent loss of Field(discriminator="kind") on StaleReason).
if isinstance(value, Stale):
assert isinstance(decoded, Stale)
assert type(decoded.reason) is type(value.reason)
Green — make it pass¶
Write src/codegenie/indices/freshness.py:
from __future__ import annotations
from datetime import datetime
from typing import Annotated, Literal, Union
from pydantic import BaseModel, ConfigDict, Field
class CommitsBehind(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
kind: Literal["commits_behind"] = "commits_behind"
n: int
last_indexed: str # commit sha — raw str at the I/O boundary by design
class DigestMismatch(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
kind: Literal["digest_mismatch"] = "digest_mismatch"
expected: str
actual: str
class CoverageGap(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
kind: Literal["coverage_gap"] = "coverage_gap"
files_indexed: int
files_in_repo: int
class IndexerError(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
kind: Literal["indexer_error"] = "indexer_error"
message: str
StaleReason = Annotated[
Union[CommitsBehind, DigestMismatch, CoverageGap, IndexerError],
Field(discriminator="kind"),
]
class Fresh(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
kind: Literal["fresh"] = "fresh"
indexed_at: datetime
class Stale(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
kind: Literal["stale"] = "stale"
reason: StaleReason
IndexFreshness = Annotated[Union[Fresh, Stale], Field(discriminator="kind")]
Then src/codegenie/indices/__init__.py:
from codegenie.indices.freshness import (
CommitsBehind, CoverageGap, DigestMismatch, Fresh, IndexFreshness,
IndexerError, Stale, StaleReason,
)
__all__ = [
"CommitsBehind", "CoverageGap", "DigestMismatch", "Fresh", "IndexFreshness",
"IndexerError", "Stale", "StaleReason",
]
Refactor — clean up¶
- Add a module-level docstring on
freshness.pynaming the consumer (codegenie.report.confidence_section) and ADR-0006. - Add per-class docstrings (one line each) naming when the variant is constructed (e.g.,
CommitsBehind: "constructed byIndexHealthProbewhenlast_indexed_commit != git rev-parse HEAD";IndexerError: "constructed when the upstream indexer is unavailable; messages are stable identifiers like'strace_unavailable','timeout','upstream_X_unavailable'"). - Sort
__all__alphabetically. - Confirm: no
model_constructcalls (S1-11 will enforce — fail-loud here). - Confirm: no logger / no I/O / stdlib + pydantic only (S1-11's
mypy --warn-unreachableoverride oncodegenie.indices/**is the structural protection). - Run
ruff format,ruff check,mypy --strict src/codegenie/indices/ tests/unit/indices/test_freshness.py,pytest tests/unit/indices/test_freshness.py -v.
Files to touch¶
| Path | Why |
|---|---|
src/codegenie/indices/__init__.py |
New package; re-export the eight names; pin __all__. |
src/codegenie/indices/freshness.py |
New module; the discriminated-union sum type. |
tests/unit/indices/test_freshness.py |
Red-then-green tests covering round-trip identity (incl. nested Stale.reason type preservation), discriminator-string pin, JSON-shape pin, extra="forbid", frozen=True, exhaustive match over both StaleReason and top-level IndexFreshness, source-scan for model_construct. |
tests/property/__init__.py |
New package marker (if not already present from Phase 1 — adapt at impl time). |
tests/property/test_index_freshness_roundtrip.py |
Hypothesis property test (AC-11); required by 02-ADR-0006 §Consequences. |
Out of scope¶
@register_index_freshness_checkdecorator-registry — handled by S1-02; the freshness module ships first, the registry layers on top.IndexHealthProbe— handled by S4-01; it constructsIndexFreshnessvalues but does not define them.ConfidenceSectionrenderer with exhaustivematch+assert_neverenforcement — handled by S8-01; the consumer ships much later.mypy --warn-unreachableper-module override oncodegenie.indices/**— handled by S1-11 (thepyproject.tomlrollout).AdapterConfidence/IndexConfidencetype — explicitly NOT in Phase 2 per 02-ADR-0006; the Phase 3 plugin owns adapter-side confidence.- Phase 1 retrofit — Phase 1 probes use
confidence: Literal["high","medium","low"]strings; Phase 2 does not migrate them. New code usesIndexFreshness.
Notes for the implementer¶
extra="forbid"is load-bearing. Pydantic's default permissive behavior on unknown keys silently swallows a typo (commit_behindinstead ofcommits_behind) and the discriminated union mis-routes.extra="forbid"is the Phase 2 discipline; do not relax it.last_indexed: stris the I/O-boundary type — not a newtype. Commit SHAs come fromgit rev-parse HEADas raw bytes/strings; this module does not import S1-05'sIndexId/SkillIdnewtypes (those are kernel identifiers, not git-commit SHAs). Resist the temptation to invent aCommitShanewtype here — that's a separate, larger story and is not in the architecture.- No timezone-naïve datetimes.
Fresh.indexed_at: datetimeaccepts anydatetime, but the producer (IndexHealthProbein S4-01) MUST construct timezone-aware UTC datetimes. The test usesdatetime(2026,5,14,tzinfo=timezone.utc); do not change to naïve. - Do not co-locate with
probes/layer_b/index_health.py. 02-ADR-0006 explicitly places this incodegenie.indices.freshnessso the renderer (report/confidence_section.py) can consume it without pulling in the probe registry. The mypy--warn-unreachableper-module override (S1-11) is oncodegenie.indices/**; co-locating defeats the surgical-rollout discipline (final-design Open Q 5). TypeAdapteris the canonical way to drive a discriminated-union round-trip in tests.IndexFreshnessis anAnnotated[Union, Field(discriminator=…)]type alias, not a class — there is noIndexFreshness.model_validate_json. Usepydantic.TypeAdapter(IndexFreshness)and call its.dump_json/.validate_jsonmethods.assert_neverfromtyping. Python 3.11+ ships it intyping; do not import fromtyping_extensions(Phase 0 baseline is 3.11+, seepyproject.toml).- Mutation discipline. A frequent silent regression here is "I added a fifth
StaleReasonand forgot to extendStaleReason = Union[...]." S1-11'smypy --warn-unreachableon this module is the structural safety net; this story's test makes the discipline observable today. - Open/Closed seam for new index sources lives in S1-02, NOT here. S1-02 introduces
@register_index_freshness_check(index_name: IndexName)insrc/codegenie/indices/registry.py.freshness.pyMUST remain pure data: no decorator, no registry dict, no I/O, no logger, no imports beyond stdlib + Pydantic. The registry layers on top by import; the seam is "new index source → new file + new decorator," never an edit tofreshness.py. Resist any temptation to "while we're here" pre-stub the registry — it belongs in S1-02. - Variant-set extension is intentionally ADR-amendment-gated, not Open/Closed-by-addition. Adding a fifth
StaleReason(e.g., a hypotheticalIndexerRefusedfor a future trust-policy reason) requires an amendment to 02-ADR-0006 — named-trigger discipline mirroring 02-ADR-0002. Theassert_neverarms in every consumer'smatch(this story's tests, S8-01's renderer, S1-11's per-module--warn-unreachableoverride) are the structural enforcement: silent widening via PydanticUnionextension is impossible without breaking the renderer's exhaustive match. The pattern fit is "Sum type + Make-illegal-states-unrepresentable" — not a Plugin/Strategy/Registry over variants. That is correct; do not architect for "pluggable freshness reasons." - Discriminator strings are a cross-ADR contract. The six strings (
"fresh","stale","commits_behind","digest_mismatch","coverage_gap","indexer_error") appear by name in02-ADR-0006 §Decision,phase-arch-design.md §"Data model", and (forthcoming)confidence_section.pygolden files. AC-2 + AC-10 pin them. Treat a rename as an ADR-touching change, not a refactor. - The property test (AC-11) is small and additive. ~50 LOC; one Hypothesis strategy per variant; one assertion (round-trip identity + nested-type preservation). It is a deliverable named in
02-ADR-0006 §Consequencesand referenced by Step 7 ofHigh-level-impl.mdas already existing from Step 1. Skipping it is a Consistency violation against the ADR.