Correctness fixes from docs/CORE_CORRECTNESS_ISSUES.md:
- Issue 3 (HIGH, exploit): submit_sbc dedups owned_card_ids (HashSet) and
bounds the list (MAX_SBC_CARDS=30) before resolution. A repeated id resolved
the same card N times, passed validation, and granted the reward while only
one card was consumed -> any SBC satisfiable with one duplicated card = free
reward. Now rejected with BadRequest. Regression test added.
- Issue 2 (HIGH): TOCTOU economy mutations closed with single-statement
compare-and-swap (no transaction plumbing): club::spend_coins conditional
debit (WHERE coins >= ?) + rows_affected, also rejects negative amounts;
pack::open_pack claims the pack before minting; market::buy_listing claims
the listing before charging and releases on debit failure; market::sell_card
guards the DELETE with owner + rows_affected; checkin::claim uses a
conditional INSERT ... WHERE NOT EXISTS (today) before paying out.
- Issue 4 (LOW): season.rs .expect() on missing rows -> graceful AppError;
checkin index (streak-1) % 7 -> .rem_euclid(7) (guards negative index panic).
- Issue 1 (LOW): migration 0019 adds sbc_submissions.club_id + backfill;
submit_sbc binds it so the MY CLUB milestone query stops silently reading 0.
Core suite 179 green + clippy clean.
- migrations/0011_season_history.sql: persist one row per completed season
- models/season.rs: SeasonHistoryEntry struct; public SEASON_LENGTH /
PROMOTION_PTS / RELEGATION_PTS consts; pts_above_safe, can_be_relegated,
promotion_achievable helpers
- services/season.rs: write history entry on season rollover; get_history()
returns last 20 seasons newest-first
- routes/division.rs: GET /division now includes promotion_pts, relegation_pts,
season_length, pts_above_safe, promotion_achievable, can_be_relegated;
new GET /division/history endpoint
- 3 new integration tests: history empty, history records after promotion,
division response has zone fields
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>