Skip to content

Attempt log — Phase 02 / S1-01 — IndexFreshness sum type

Attempt 1 — 2026-05-15 — GREEN

Result: DONE — all 12 ACs pass, all gates green, no rescue needed.

What was built

  • src/codegenie/indices/__init__.py (new package; re-exports 8 names; sorted __all__).
  • src/codegenie/indices/freshness.py (new; the discriminated-union sum type — Fresh | Stale(reason: StaleReason) with four StaleReason variants; frozen=True, extra="forbid" on every model; stdlib + Pydantic only — no logger, no I/O, no registry).
  • tests/unit/indices/__init__.py + tests/unit/indices/test_freshness.py (10 unit tests covering every AC).
  • tests/property/__init__.py + tests/property/test_index_freshness_roundtrip.py (Hypothesis property test, AC-11; required deliverable per 02-ADR-0006 §Consequences).
  • pyproject.toml — added hypothesis to [project.optional-dependencies].dev.
  • uv.lock — regenerated via uv lock (adds hypothesis v6.152.7; required by test_uv_lock_is_in_lockstep_with_pyproject_dep_set).

Red → Green → Refactor evidence

  • Red: pytest tests/unit/indices/test_freshness.py tests/property/test_index_freshness_roundtrip.pyModuleNotFoundError: No module named 'codegenie.indices'.
  • Green: same command → 15 passed in 0.19s (10 unit + 1 property + 4 parametrize sub-tests for AC-4).
  • Refactor: ruff format reflowed line breaks; ruff check --fix upgraded Union[X,Y]X | Y (UP007) and timezone.utcdatetime.UTC (UP017). Pydantic v2 discriminated-union still routes correctly under the X | Y syntax inside Annotated[..., Field(discriminator="kind")].

Gates

  • ruff format --check . — 182 files already formatted.
  • ruff check . — All checks passed.
  • mypy --strict src/ — Success: no issues found in 56 source files.
  • pytest (full suite) — 1562 passed, 3 deselected (bench), 2 xfailed (pre-existing). Coverage 93.20% ≥ 85% gate.
  • Phase 0 fence stays green (no new LLM / network imports introduced).

What worked

  1. Following the story's TDD-plan code blocks verbatim and then letting ruff --fix reconcile the style with the codebase's UP007/UP017 rules. The story's prescription was correct; ruff's autofix is content-preserving for these two rules in Pydantic v2.
  2. Confirming the RED state with the literal exception text before writing any implementation — caught a moment where I considered shortcutting.
  3. Discovering that adding any new [project.optional-dependencies].dev package triggers test_uv_lock_is_in_lockstep_with_pyproject_dep_set until uv lock is re-run. Recorded in _lessons.md so future story attempts don't burn a cycle on this.

What didn't work / friction

  • hypothesis was missing from the local venv on first test run (it's in [dev] but the venv was pre-S1-01's pyproject; uv lock-then-uv sync would have caught this without manual pip install). The new entry in _lessons.md covers both halves of this trap.
  • lint-imports console script was not on PATH locally; pre-existing environment gap (would have hit any prior phase-2 story too). Fix was pip install -e .[dev] to install import-linter.

Pattern-fit decisions (refactor lens)

  • Sum type + Make-illegal-states-unrepresentable: applied as-prescribed. Fresh | Stale(reason: StaleReason) collapses what would otherwise be (is_fresh: bool, reason: Optional[str]) into a closed variant set.
  • Schema-with-consumer: consumer (S8-01 renderer) is not in this story but is named in the module docstring and ADR-0006. The discipline survives because the renderer is committed work, not speculation.
  • Open/Closed for variants — INTENTIONALLY NOT a registry/plugin pattern. Variant-set extension is ADR-amendment-gated (named-trigger discipline). assert_never in every consumer's match is the structural enforcement. The Plugin/Registry seam lives at codegenie.indices.registry (S1-02) for new index sources, not new variants. Resisted the temptation to pre-stub the registry here — that belongs in S1-02.
  • Newtype for last_indexed? Considered and rejected — commit SHAs are I/O-boundary strings, not kernel identifiers. IndexId/SkillId newtypes (S1-05) are a separate concern.

Files touched

Path Reason
src/codegenie/indices/__init__.py new package; re-export 8 names
src/codegenie/indices/freshness.py new module; discriminated-union sum type
tests/unit/indices/__init__.py new package marker
tests/unit/indices/test_freshness.py 10 unit tests covering every AC
tests/property/__init__.py new package marker
tests/property/test_index_freshness_roundtrip.py Hypothesis property test (AC-11)
pyproject.toml add hypothesis to [dev] extras
uv.lock regenerated via uv lock after pyproject change

Acceptance criteria — evidence

  • AC-1test_all_exports_full_variant_set.
  • AC-2test_discriminator_strings_are_exactly_pinned + test_models_are_frozen_and_forbid_extra.
  • AC-3 — module-scope StaleReason / IndexFreshness aliases at freshness.py:84,110; imported directly in every test.
  • AC-4test_index_freshness_roundtrip_identity parametrized over 5 instances; includes the nested Stale.reason type-preservation assertion.
  • AC-5test_top_level_unknown_kind_is_rejected + test_stale_reason_rejects_unknown_kind.
  • AC-6test_match_is_exhaustive_over_stale_reason.
  • AC-6atest_match_is_exhaustive_over_index_freshness_top_level.
  • AC-7 — RED state confirmed (ModuleNotFoundError) before any implementation code was written.
  • AC-8test_freshness_module_has_no_model_construct source-scan.
  • AC-9ruff format --check, ruff check, mypy --strict src/, pytest tests/unit/indices/test_freshness.py tests/property/test_index_freshness_roundtrip.py all green.
  • AC-10test_json_shape_pinned pins literal model_dump(mode="json") dicts at both levels.
  • AC-11tests/property/test_index_freshness_roundtrip.py exists, Hypothesis-driven, one strategy per variant, asserts identity + nested-type preservation.