From 90da4be083621e7b0f7c88e78cf482003f4d6834 Mon Sep 17 00:00:00 2001 From: jay Date: Thu, 11 Jun 2026 19:04:49 -0400 Subject: [PATCH] Pool admin: empty-pool safety net + honest removal copy (Codex audit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two hardening fixes from Codex's audit: - _pick_answer falls back to the curated baseline if the live pool is empty, so an admin tombstoning every answer in a variant can't divide-by-zero the daily picker. Test added (test_picker_survives_empty_live_pool). Chosen over a minimum-count block: robust without refusing legitimate removals. - Removal copy is now honest — "Removed from future puzzles (today's answer is already set)" — since a tombstone doesn't rewrite today's generated daily_puzzles row. Panel intro updated to match. Co-Authored-By: Claude Opus 4.8 --- frontend/src/routes/admin/+page.svelte | 7 ++++--- goodnews/games.py | 2 ++ tests/test_pool_admin.py | 11 +++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 22e0289..0c79079 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -76,7 +76,7 @@ async function removeWord(w) { try { wpPool = await delJSON('/api/admin/word/pool/' + encodeURIComponent(w)); - wpMsg = `Removed “${w}”. It won’t appear as an answer — restore it any time below.`; + wpMsg = `Removed “${w}” from future puzzles (today’s answer is already set). Restore it any time below.`; await refreshLookup(); } catch { /* ignore */ } } @@ -817,8 +817,9 @@ {:else if section === 'games'}

Daily Word pool

-

Look up a word and add it to the answer pool. Only real, 5- or 6-letter - words in the guess dictionary qualify, so the daily answer is always solvable.

+

Look up a word to add or remove it from the answer pool. Only real, 5- or 6-letter + words in the guess dictionary qualify, so the daily answer is always solvable. Removals take + effect for future puzzles and can be restored any time.

set[s def _pick_answer(conn: sqlite3.Connection, date: str, variant: str) -> str: pool = answer_pool(conn, variant) + if not pool: # safety net: removals must never empty the pool — fall back to curated + pool = sorted(_POOL.get(variant, [])) recent = _recent_answers(conn, variant, max(1, len(pool) // 2)) start = _seed(date, "word", variant) % len(pool) for i in range(len(pool)): diff --git a/tests/test_pool_admin.py b/tests/test_pool_admin.py index 80d4462..4c18e5d 100644 --- a/tests/test_pool_admin.py +++ b/tests/test_pool_admin.py @@ -85,6 +85,17 @@ def test_import_dedupes_and_validates(conn): assert new5[1] in games.answer_pool(conn, "5") +def test_picker_survives_empty_live_pool(conn): + """An overzealous admin could tombstone every answer in a variant. The live + pool then reads empty, but the daily picker must not divide-by-zero — it falls + back to the curated baseline rather than crashing.""" + for w in list(games._POOL["5"]): + games.remove_pool_word(conn, w) + assert games.answer_pool(conn, "5") == [] # live pool truly empty + ans = games._pick_answer(conn, "2026-06-11", "5") # must not raise + assert ans in set(games._POOL["5"]) # fell back to curated + + def test_import_relifts_removed_word(conn): w = _a_curated("5") games.remove_pool_word(conn, w)