Phase 3 (persistence) + Phase 3a (i18n scaffolding) #2

Merged
kiro_agent merged 4 commits from feature/phase-3-persistence-i18n into main 2026-10-23 21:16:40 +00:00
Owner

Summary

Phase 3 adds the persistence layer and Phase 3a adds i18n scaffolding. Review verdict: APPROVED (no blocking findings). Builds on the already-merged Phase 0-2.

What was built (per FEAT)

FEAT-001 — ORM models + Alembic initial migration (tasks.md T3.1, finishes T0.3). All 8 design-§4 models under app/models/: ContestYear, MonthlyContest, Entry, QSO, Flag, DutchLocatorReference, AuditLogEntry, Manager. MonthlyContest.submission_closes_at = contest_date + 14 days at Europe/Amsterdam end-of-day, DST-aware via zoneinfo, stored as aware UTC; is_locked is a derived Python @property, not a column. Entry carries the privacy split (public: own_locator, afdeling; private: email, pin_hash, operator_name, address, other_operators, declaration_*), upload bookkeeping (source_format CBR/EDI/ADIF, version, replaced_at, raw_file_path), override fields, and cached score (monthly_score int + qso_points_per_band/multipliers_per_band JSON). Unique constraint uq_entry_contest_callsign_section. Yearly Standing is intentionally NOT a table. Reuses the existing Phase-1/2 enums. app/db.py lazy engine untouched. Alembic versions/0001_initial_schema.py builds every table/FK/unique constraint/JSON column and named enum types; downgrade drops tables + enum types.

FEAT-002 — Security + audit (tasks.md T3.2, T3.3). app/security.py: salted hash_pin/verify_pin, hash_password/verify_password, and authenticate_manager(session, username, password) supporting multiple managers (returns None for unknown/wrong-password/inactive). app/audit.py: single record_audit(...) choke point that add+flush (does NOT commit — caller owns the txn). See hashing-scheme note below.

FEAT-003 — Persistence integration (tasks.md T3.4, T3.5 + §9 invariant). app/persistence/: raw_files.py (relative path uploads/<year>/<month>/<callsign>-<section>-v<version>.<ext>, DB stores the relative path per ADR D11), errors.py (PinMismatchError, DuplicateEntryError), ingest.py (ingest_parsed_log — raw file written to disk FIRST as the recovery anchor, then one transaction scores + builds Entry/QSOs/Flags; caller-wins defaulting D-1/D-2), and rebuild.py (rebuild_from_raw + python -m app.persistence.rebuild CLI). The §9 invariant: first submit claims the (monthly_contest, callsign, section) slot and stores pin_hash; re-upload requires verify_pin (match -> replace QSOs/Flags, bump version, set replaced_at, new raw file; mismatch -> PinMismatchError, no change); a distinct second Entry is blocked by check-then-act plus IntegrityError -> DuplicateEntryError.

FEAT-004 — i18n scaffolding (Phase 3a, tasks.md T3a.1). app/i18n/: nl default / en selectable / safe key fallback (never raises), dependency-free .po parsing (no babel/gettext, no .mo committed), get_translator(locale) Jinja2-ready. Prose strings translated; jargon (qso/locator/multiplier) identical in both catalogs (R30). Imports nothing from parsing/scoring/rules/reference (R31), verified in a fresh subprocess.

DB verification strategy (Postgres apply status)

Two-path strategy. Path (b) SQLite + revision-coherence runs in tests/test_migration.py and always passes. Path (a) real Postgres: alembic upgrade head was applied cleanly against Postgres 16 via a throwaway python:3.12-slim container joined to the compose DB network — sqlalchemy.inspect confirmed all 8 tables + alembic_version + the unique constraint, then compose was torn down (keeping the ./data/db bind mount).

Pending: the Postgres apply was NOT run from the project venv. This host's pure-Python psycopg has no libpq (the /opt/homebrew/opt/libpq symlink is dangling), so .venv/bin/alembic upgrade head fails with 'no pq wrapper available'. To run alembic/live-DB from the venv on this host, install a working libpq (brew install libpq, likely export DYLD_LIBRARY_PATH) or run inside a container. Pure-logic tests need none of this.

Asserted test facts

  • FEAT-001: full suite 65 passed (51 pre-existing Phase 1/2 + 14 new), no regressions; Postgres-16 alembic upgrade verified via container + SQLite migration-coherence.
  • FEAT-002: full suite 74 passed.
  • FEAT-003: full suite 85 passed (74 + 11 new), no regressions.
  • Acceptance (March PD1SCO Section B, real .cbr): monthly_score == 460, qso_points_per_band {2m:16, 70cm:7}, multipliers_per_band {2m:14, 70cm:6}, 23 QSO rows, raw bytes round-trip; forced-commit-failure still leaves the raw file on disk; wrong PIN -> PinMismatchError (no change); matching PIN -> v2 + replaced_at + fresh QSOs + -v2 raw file; distinct 2nd Entry -> DuplicateEntryError; rebuild reproduces 460 / same per-band breakdown / 23 QSOs / same Flag set and is idempotent (preserving email + pin_hash).

Flagged ambiguities / notes

  • Hashing scheme: passlib is pinned >=1.7,<1.8 (1.7.4), whose bcrypt backend is incompatible with the installed bcrypt 5.0.0 (reads removed __about__.__version__; self-test probes a >72-byte secret that bcrypt 5.x rejects). Resolution: the single CryptContext uses pbkdf2_sha256 (pure-Python, salted) for both PIN and manager-password hashing. The contract (R8/N5/R24a, design.md) mandates a salted hash, not a specific algorithm, so this satisfies it. To use bcrypt later, install bcrypt <4.1 or unpin passlib.
  • Review evidence conflict resolved: FEAT-004 reported the full suite hanging on passlib hashing under this host's Python 3.15. A narrow spot-check ran CryptContext.hash+verify in 0.02s with correct round-trip and rejection, so the hang was the known persistent-shell-session issue, not a code defect; the FEAT-002/003 pass evidence stands.
  • FEAT-001 discrepancy vs its own text: alembic/env.py + script.py.mako already existed and were correct; only versions/0001 and the import app.models line were missing.

STILL-OPEN Phase 0-2 reminder (NOT resolved by this PR)

The Dutch Locator Reference starter list is provisional (needs_review=True) and BES squares are 4-char only. Authoritative NL + exact BES 6-char squares are still needed from the user before the multiplier gate is trustworthy. Phase 3 persists the reference as-is; it does not finalize the data. This reminder is surfaced, not actioned.

How to run the tests

python -m venv /Users/jelle/Documents/contestrobot/.worktrees/phase-3/.venv
/Users/jelle/Documents/contestrobot/.worktrees/phase-3/.venv/bin/pip install -e '/Users/jelle/Documents/contestrobot/.worktrees/phase-3[dev]'
cd /tmp && /Users/jelle/Documents/contestrobot/.worktrees/phase-3/.venv/bin/python -m pytest /Users/jelle/Documents/contestrobot/.worktrees/phase-3/tests -q

Run pytest with cwd=/tmp and absolute paths. Pure-logic tests need no Postgres/libpq; the live-DB/alembic path needs a working libpq or a container as noted above.

## Summary Phase 3 adds the persistence layer and Phase 3a adds i18n scaffolding. Review verdict: **APPROVED** (no blocking findings). Builds on the already-merged Phase 0-2. ## What was built (per FEAT) **FEAT-001 — ORM models + Alembic initial migration (tasks.md T3.1, finishes T0.3).** All 8 design-§4 models under `app/models/`: ContestYear, MonthlyContest, Entry, QSO, Flag, DutchLocatorReference, AuditLogEntry, Manager. `MonthlyContest.submission_closes_at` = contest_date + 14 days at Europe/Amsterdam end-of-day, DST-aware via zoneinfo, stored as aware UTC; `is_locked` is a derived Python `@property`, not a column. Entry carries the privacy split (public: own_locator, afdeling; private: email, pin_hash, operator_name, address, other_operators, declaration_*), upload bookkeeping (source_format CBR/EDI/ADIF, version, replaced_at, raw_file_path), override fields, and cached score (`monthly_score` int + `qso_points_per_band`/`multipliers_per_band` JSON). Unique constraint `uq_entry_contest_callsign_section`. Yearly Standing is intentionally NOT a table. Reuses the existing Phase-1/2 enums. `app/db.py` lazy engine untouched. Alembic `versions/0001_initial_schema.py` builds every table/FK/unique constraint/JSON column and named enum types; downgrade drops tables + enum types. **FEAT-002 — Security + audit (tasks.md T3.2, T3.3).** `app/security.py`: salted `hash_pin`/`verify_pin`, `hash_password`/`verify_password`, and `authenticate_manager(session, username, password)` supporting multiple managers (returns None for unknown/wrong-password/inactive). `app/audit.py`: single `record_audit(...)` choke point that add+flush (does NOT commit — caller owns the txn). See hashing-scheme note below. **FEAT-003 — Persistence integration (tasks.md T3.4, T3.5 + §9 invariant).** `app/persistence/`: `raw_files.py` (relative path `uploads/<year>/<month>/<callsign>-<section>-v<version>.<ext>`, DB stores the relative path per ADR D11), `errors.py` (PinMismatchError, DuplicateEntryError), `ingest.py` (`ingest_parsed_log` — raw file written to disk FIRST as the recovery anchor, then one transaction scores + builds Entry/QSOs/Flags; caller-wins defaulting D-1/D-2), and `rebuild.py` (`rebuild_from_raw` + `python -m app.persistence.rebuild` CLI). The §9 invariant: first submit claims the (monthly_contest, callsign, section) slot and stores pin_hash; re-upload requires verify_pin (match -> replace QSOs/Flags, bump version, set replaced_at, new raw file; mismatch -> PinMismatchError, no change); a distinct second Entry is blocked by check-then-act plus IntegrityError -> DuplicateEntryError. **FEAT-004 — i18n scaffolding (Phase 3a, tasks.md T3a.1).** `app/i18n/`: nl default / en selectable / safe key fallback (never raises), dependency-free `.po` parsing (no babel/gettext, no `.mo` committed), `get_translator(locale)` Jinja2-ready. Prose strings translated; jargon (qso/locator/multiplier) identical in both catalogs (R30). Imports nothing from parsing/scoring/rules/reference (R31), verified in a fresh subprocess. ## DB verification strategy (Postgres apply status) Two-path strategy. Path (b) SQLite + revision-coherence runs in `tests/test_migration.py` and always passes. Path (a) real Postgres: `alembic upgrade head` was applied cleanly against **Postgres 16** via a throwaway `python:3.12-slim` container joined to the compose DB network — `sqlalchemy.inspect` confirmed all 8 tables + `alembic_version` + the unique constraint, then compose was torn down (keeping the ./data/db bind mount). **Pending:** the Postgres apply was NOT run from the project venv. This host's pure-Python psycopg has no libpq (the `/opt/homebrew/opt/libpq` symlink is dangling), so `.venv/bin/alembic upgrade head` fails with 'no pq wrapper available'. To run alembic/live-DB from the venv on this host, install a working libpq (`brew install libpq`, likely export `DYLD_LIBRARY_PATH`) or run inside a container. Pure-logic tests need none of this. ## Asserted test facts - FEAT-001: full suite **65 passed** (51 pre-existing Phase 1/2 + 14 new), no regressions; Postgres-16 alembic upgrade verified via container + SQLite migration-coherence. - FEAT-002: full suite **74 passed**. - FEAT-003: full suite **85 passed** (74 + 11 new), no regressions. - Acceptance (March PD1SCO Section B, real .cbr): `monthly_score == 460`, `qso_points_per_band {2m:16, 70cm:7}`, `multipliers_per_band {2m:14, 70cm:6}`, 23 QSO rows, raw bytes round-trip; forced-commit-failure still leaves the raw file on disk; wrong PIN -> PinMismatchError (no change); matching PIN -> v2 + replaced_at + fresh QSOs + -v2 raw file; distinct 2nd Entry -> DuplicateEntryError; rebuild reproduces 460 / same per-band breakdown / 23 QSOs / same Flag set and is idempotent (preserving email + pin_hash). ## Flagged ambiguities / notes - **Hashing scheme:** passlib is pinned `>=1.7,<1.8` (1.7.4), whose bcrypt backend is incompatible with the installed bcrypt 5.0.0 (reads removed `__about__.__version__`; self-test probes a >72-byte secret that bcrypt 5.x rejects). Resolution: the single CryptContext uses `pbkdf2_sha256` (pure-Python, salted) for both PIN and manager-password hashing. The contract (R8/N5/R24a, design.md) mandates a *salted* hash, not a specific algorithm, so this satisfies it. To use bcrypt later, install bcrypt <4.1 or unpin passlib. - **Review evidence conflict resolved:** FEAT-004 reported the full suite hanging on passlib hashing under this host's Python 3.15. A narrow spot-check ran `CryptContext.hash`+`verify` in 0.02s with correct round-trip and rejection, so the hang was the known persistent-shell-session issue, not a code defect; the FEAT-002/003 pass evidence stands. - **FEAT-001 discrepancy vs its own text:** `alembic/env.py` + `script.py.mako` already existed and were correct; only `versions/0001` and the `import app.models` line were missing. ## STILL-OPEN Phase 0-2 reminder (NOT resolved by this PR) The Dutch Locator Reference starter list is **provisional** (`needs_review=True`) and BES squares are **4-char only**. Authoritative NL + exact BES 6-char squares are still needed from the user before the multiplier gate is trustworthy. Phase 3 **persists the reference as-is; it does not finalize the data.** This reminder is surfaced, not actioned. ## How to run the tests ``` python -m venv /Users/jelle/Documents/contestrobot/.worktrees/phase-3/.venv /Users/jelle/Documents/contestrobot/.worktrees/phase-3/.venv/bin/pip install -e '/Users/jelle/Documents/contestrobot/.worktrees/phase-3[dev]' cd /tmp && /Users/jelle/Documents/contestrobot/.worktrees/phase-3/.venv/bin/python -m pytest /Users/jelle/Documents/contestrobot/.worktrees/phase-3/tests -q ``` Run pytest with cwd=`/tmp` and absolute paths. Pure-logic tests need no Postgres/libpq; the live-DB/alembic path needs a working libpq or a container as noted above.
Add app/models for every design §4 entity (ContestYear, MonthlyContest,
Entry, QSO, Flag, DutchLocatorReference, AuditLogEntry, Manager), reusing
the Phase 1/2 enums and adding a SourceFormat enum. Wire alembic/env.py to
import the models and add versions/0001_initial_schema.py building the full
schema, the one-Entry unique constraint, enum types and JSON columns.
Add a db_session pytest fixture plus model and migration tests.

Preserves app/db.py's lazy get_engine()/get_sessionmaker() (no libpq at
import). Yearly Standing stays derived, not a table.
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
public/vrza-contestrobot!2
No description provided.