ADR-0006: IndexFreshness sum type lives at codegenie.indices.freshness with one Phase-2 consumer¶
Status: Accepted Date: 2026-05-14 Tags: typing · sum-type · domain-modeling · open-closed · schema-with-consumer · honest-confidence Related: 02-ADR-0007, production ADR-0033, production ADR-0032, production design.md §2.3 honest confidence
Context¶
Production design.md §2.3 names "Honest confidence" as a load-bearing commitment and IndexHealthProbe (B2) as its canonical example in the POC. Silent index staleness is the worst failure mode of the entire system: a RepoContext slice that says it's current but isn't propagates wrong evidence through every downstream consumer. The roadmap's Phase 2 exit criterion is operational: a deliberately-seeded stale-scip fixture in tests/fixtures/portfolio/ must be caught by B2; build FAILS otherwise. The probe is what makes the commitment real.
All three input lenses proposed the same concept under three different names:
- Performance lens — AdapterConfidence = Trusted | Degraded(reason) | Unavailable(reason), used for both probes and adapters. Critic finding #3 attacked this as conflating ADR-0033's prescription for ADR-0032 adapter outputs (Phase 3) with Phase 2's probe outputs.
- Security lens — IndexConfidence, B2-only. Localized but collides with the human-readable confidence: "high" | "medium" | "low" flat string the localv2.md §5.2 B2 slice already carries.
- Best-practices lens — IndexFreshness = Fresh | Stale(reason: StaleReason) with four StaleReason variants (CommitsBehind, DigestMismatch, CoverageGap, IndexerError). One name, one module, but the proposed module location (codegenie.indices.freshness) was away from IndexHealthProbe itself — defended on grounds that Phase 8 Bundle Builder and ADR-0032 adapters would import it without pulling in the probe registry.
The critic (critique.md §"Attacks on the best-practices design" #6) attacked the location choice: Phase 8 doesn't exist yet, ADR-0032 adapters don't exist yet (Phase 3 owns them), so the import-direction argument is hypothetical and the layout decision is speculative. Co-location in probes/index_health.py is the boring default until the second consumer exists. All three lenses also shared blind spot #1 — pre-shipping a sum type whose only real consumer is the probe that defines it.
The synthesis (final-design.md §"Components" #1, #2, §"Conflict-resolution table" row 3, row 11, §"Shared blind spots considered" #1) made two choices: (1) pick IndexFreshness as the name; (2) close the schema-without-consumer gap by shipping one Phase-2-internal consumer — src/codegenie/report/confidence_section.py, which renders a CONTEXT_REPORT.md Confidence section by pattern-matching every IndexFreshness value with assert_never. With a real consumer, the separate-module location earns its keep: the renderer must import IndexFreshness without pulling in the probe registry (the renderer runs alongside, not inside, the gather coordinator). The --warn-unreachable per-module mypy flag on the renderer makes a missed match arm a build error from day 1.
AdapterConfidence is a separate concern owned by Phase 3 (ADR-0032). Phase 2 ships AdapterConfidence = Trusted | Degraded(reason) | Unavailable(reason) in codegenie/adapters/confidence.py as a placeholder — the variant set is owned by Phase 3 when the first adapter ships (the placeholder gives Phase 3 a typed target without binding the eventual shape).
Options considered¶
- Option A —
AdapterConfidenceused for both probes and adapters. Pattern: Sum type, but conflated. Performance lens's pick. Contract conflation — probes and adapters have different output contracts; one type for both forces Phase 3 adapter authors to inherit shape constraints set by Phase-2 probes. - Option B —
IndexConfidenceB2-only, co-located inprobes/index_health.py. Pattern: Sum type, narrowly scoped. Security lens's pick. Name collides with the human-readableconfidence: high|medium|lowstring; if Phase 3+ adapters need an analogous shape, they'd need a parallel sum type. - Option C —
IndexFreshness = Fresh | Stale(reason)in a separate module (codegenie.indices.freshness), with no Phase-2 consumer. Best-practices lens's initial proposal. Critic-attacked as schema-without-consumer + speculative-import-direction. - Option D —
IndexFreshness = Fresh | Stale(reason)incodegenie.indices.freshness, with one Phase-2 consumer (report/confidence_section.pyrendering CONTEXT_REPORT.md),--warn-unreachableper-module mypy enforcement, andAdapterConfidenceas a separate placeholder incodegenie.adapters.confidence(Phase 3 owns the variant set). Pattern: Sum type + Make-illegal-states-unrepresentable + schema-paired-with-consumer. Synthesis pick.
Decision¶
Adopt Option D. IndexFreshness = Annotated[Union[Fresh, Stale], Field(discriminator="kind")] lives at src/codegenie/indices/freshness.py (the only file in the codegenie.indices package for Phase 2). The four StaleReason variants are CommitsBehind(n, last_indexed), DigestMismatch(expected, actual), CoverageGap(files_indexed, files_in_repo), IndexerError(message). The Phase-2 consumer is src/codegenie/report/confidence_section.py, which pattern-matches every IndexFreshness value via match + assert_never. mypy --warn-unreachable is enabled per-module on codegenie.{indices, probes/index_health.py, report, adapters, tccm}/** — a missed case is a build error. AdapterConfidence is a placeholder sum (Trusted | Degraded(reason) | Unavailable(reason)) at codegenie/adapters/confidence.py; the variant set is owned by Phase 3 (revisable when the first adapter ships). Pattern: Sum type + Make-illegal-states-unrepresentable + schema-paired-with-consumer.
Tradeoffs¶
| Gain | Cost |
|---|---|
Three competing names ([P]'s AdapterConfidence / [S]'s IndexConfidence / [B]'s IndexFreshness) collapse to one name, one module, one Phase-2 consumer. The "what does freshness mean here?" question has one answer |
The separate indices/ package adds one more top-level directory under src/codegenie/ (Phase 1 added parsers/; Phase 2 adds indices/, adapters/, tccm/, skills/, conventions/, depgraph/, report/ — package count grows; critic [B] finding #1 noted the ratchet risk) |
| Schema-with-consumer discipline survives — the renderer is real code (not test scaffolding) that exercises every variant on every gather; the variant set is rehearsed continuously | The consumer is one module; if the variant set is wrong (e.g., a fifth StaleReason is needed), discovery is at Phase 3 land time, requiring an ADR amendment here. Mitigation: Gap 3 improvement adds @register_index_freshness_check(index_name) so new index sources extend B2 by addition, not edit |
mypy --warn-unreachable per-module catches a missed case at build time on the renderer — exhaustive match is enforced, not asserted |
Per-module config in pyproject.toml ([[tool.mypy.overrides]] blocks) — six modules opt in (the listed set); Phase 0/1 modules don't get the discipline. Full-repo rollout is a tracked backlog item |
AdapterConfidence as a placeholder gives Phase 3 a typed target without locking it — the variant set is revisable when the first adapter ships, owned by Phase 3 author |
A Phase 3 adapter author may want AdapterConfidence to layer over IndexFreshness (e.g., "adapter is Degraded because its underlying index is Stale(CommitsBehind(17))"). Phase 2 does not pre-decide the layering; Phase 3 owns it |
Phase 3 adapters import IndexFreshness without pulling in the probe registry — the renderer is the proof; the import-direction argument is no longer hypothetical |
A future "render report from cache without re-running probes" workflow (Phase 9? Phase 13?) is the second real consumer Phase 2's argument anticipated; Phase 2 has only one consumer today |
Discriminated-union variants are Pydantic-modeled (frozen=True, extra="forbid"); round-trips through model_dump_json ↔ model_validate_json identity-equal (property test) |
Pydantic's discriminator-field discipline requires a Literal["..."] kind field on every variant — slight verbosity vs. a dataclass-based sum, but the JSON round-trip is what consumers need |
The probe's flat string confidence: "high" | "medium" | "low" (slice shape per localv2.md §5.2 B2) is derived from the typed value — backward compat preserved at the slice surface |
Two representations of the same fact exist at the slice boundary: the typed IndexFreshness (for in-process consumers) and the flat string (for repo-context.yaml's human reader). The renderer is the source of truth for the typed value; the flat string is derived |
Pattern fit¶
Pattern: Sum type + Make-illegal-states-unrepresentable (design-patterns-toolkit.md §"Tagged union / sum type for state", §"Make illegal states unrepresentable", production ADR-0033 §3–4). The toolkit's prescription — "model state machines, failure-mode taxonomies, edge classifications as discriminated unions; avoid booleans for state" — is honored exactly: Fresh(indexed_at) vs Stale(reason) is the binary status; the StaleReason discriminator captures the why, never collapsing to Optional[str]. The pattern's failure mode the toolkit warns against ("booleans for state — is_pending: bool, is_running: bool, is_done: bool instead of Status = Literal["pending","running","done"]") is avoided. Composes with Schema-with-consumer (the toolkit's anti-pattern "premature pluggability" generalized to types — "schema before consumer"): the renderer is the consumer that earns the type. Composes with production ADR-0033's newtype + smart-constructor discipline at the variant level (e.g., CommitsBehind.n: int is constructed; round-trip identity is tested).
Consequences¶
src/codegenie/indices/freshness.pyis the single file incodegenie.indicesfor Phase 2.__init__.py:__all__ = ["IndexFreshness", "Fresh", "Stale", "StaleReason", "CommitsBehind", "DigestMismatch", "CoverageGap", "IndexerError"].src/codegenie/report/confidence_section.pyconsumesIndexFreshnessvia exhaustivematch. Every golden-file test exercises this renderer.pyproject.tomlper-module mypy config enables--warn-unreachableoncodegenie.{indices, probes/index_health.py, report, adapters, tccm}/**. Phase 0/1 modules don't get the flag (Rule 3 — surgical changes); full-repo rollout is a tracked backlog item (Open Q §5).src/codegenie/adapters/confidence.pyshipsAdapterConfidence = Annotated[Union[Trusted, Degraded, Unavailable], Field(discriminator="kind")]as a placeholder, with a module docstring stating "Phase 3 plugin may extend; revise on first concrete adapter."tests/unit/indices/test_freshness.pyasserts round-trip identity (model_dump_json→model_validate_json= identity) and exhaustivematchtest withassert_never. A missing case is amypy --warn-unreachablebuild error inconfidence_section.py.tests/property/test_index_freshness_roundtrip.py(Hypothesis) — anyIndexFreshnessround-trips identity-equal.- A Phase 3 adapter that needs a fifth
StaleReasonvariant requires an ADR amendment to this one (named-trigger discipline mirroring 02-ADR-0002). Theassert_neverin the renderer is the structural enforcement: silent extension via PydanticUnionwidening is impossible without breaking the renderer's exhaustive match. - Gap 3 improvement (
@register_index_freshness_check(index_name: IndexName)decorator-registry infreshness.py) closes the Open/Closed gap for new index sources: Phase 3+ adds new index types by new file + new decorator, never by editing B2'srun()method. The decorator-registry pattern symmetry with@register_probeand@register_dep_graph_strategyis itself a documentation win.
Reversibility¶
Medium. Renaming IndexFreshness to something else later (e.g., AdapterConfidence after Phase 3 discovers the shapes are the same) is a git grep rewrite — name change, consumer rewrite, exposed in __all__. Collapsing the separate indices/ module into probes/index_health.py is a file move + import-path rewrite; the renderer would then need to import from probes/, which couples the renderer to the probe registry. The harder reversal is losing the typed sum in favor of Optional[str] — that's a regression on production design.md §2.3's honest-confidence commitment and a load-bearing test failure (test_stale_scip_fixture.py asserts typed Stale(reason=CommitsBehind(...))). The typed-sum direction is one-way by Phase 2 commitment.
Evidence / sources¶
../final-design.md §"Components" #1 IndexHealthProbe, §"Components" #2IndexFreshness sum type module— name + module + consumer rationale../final-design.md §"Conflict-resolution table" row 3, row 11— name selection + module location../final-design.md §"Shared blind spots considered" #1— schema-without-consumer fix../phase-arch-design.md §"Component design" #1, #2— load-bearing-citizen framing; consumer requirement../phase-arch-design.md §"Data model"— Pydantic discriminated-union shape../phase-arch-design.md §"Gap analysis & improvements" Gap 3—@register_index_freshness_checkOpen/Closed extension../critique.md §"Attacks on the best-practices design" #6— module-location attack and synthesis response../critique.md §"Attacks on the performance-first design" #5—AdapterConfidenceconflation attack- Production ADR-0033 §3–4 — make-illegal-states-unrepresentable discipline
- Production ADR-0032 — Phase 3 adapter contract that
AdapterConfidenceplaceholder anticipates - Production design.md §2.3 — honest confidence commitment