Audit round 7, web + stages cluster: 13 more verified findings fixed

The web layer's serialization story had three gaps: /api/run started a
stage without the lock, so a decision mid-save could pass the rewrite
guard and still be clobbered by the stage's full rewrite (now the start
itself serializes); /api/photos accepted a replacement photo while
extract was running, permanently pairing the new bytes with the old
photo's reads (now refuses like every other mutation); and /api/queue
read session rows lock-free and stale (now freshens under the lock).
The localhost Host allowlist applied only to writes — a DNS-rebound
page could read pipeline state and shelf photos with plain GETs; it
now covers all methods (foreign-Origin reads still pass: without CORS
headers a cross-origin page can't read the response anyway).

Data-loss finds: the off-BGG edit form re-rendered from games.json,
which only sees hand data after enrich — so a second save resubmitted
pre-save blanks and cleared the first (the detail endpoint now overlays
local_games.json live). The local key embeds the photo list, so a new
sighting orphaned hand-written facts silently; enrich now migrates them
when the title still matches exactly one line, and warns instead of
ever dropping. research() left the previous game's version verdicts on
the row, riding a stale version_id onto the next pick; it clears all
four fields as reopen does. find_row now prefers the version-open
sibling on duplicate keys, mirroring _adopt. Re-adding a removed
hand-added title silently no-opped behind a 200 — it now rescinds the
removal (an explicit undo), and a true duplicate add answers 409.

Smaller: parse_search's dedupe collapsed same-id rows under DIFFERENT
names, discarding the alternate-name row whose exact match downstream
scoring needed (now collapses same-name only; research merges its
ballot per game preferring exact evidence); rpgitems rank in their own
family so their rank parsed null; the pipeline badge counted
review-retired queue rows as pending; the catalog pairing cascade ran
per-entry so a tier-3 claim could steal a sibling's exact row (now
tier-by-tier across all entries, as resolve does); library cards
render a lone player bound without "undefined" and the seats filter
tolerates it; added_no_version reads "done · no version" instead of a
bare green done.

Every finding verified against the code before fixing; each fix
carries a regression test. 337 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-06 00:30:13 -04:00
co-authored by Claude Fable 5
parent 32b6aae841
commit e6d45011cc
11 changed files with 366 additions and 51 deletions
+7 -2
View File
@@ -633,10 +633,15 @@ def test_pipeline_reports_badge_fields(tmp_path):
},
],
)
cfg.to_add_path.write_text("bgg_id,bgg_name\n1,X\n2,Y\n")
# queue rows must be ENDORSED by matches.csv or they count as retired
cfg.to_add_path.write_text("bgg_id,bgg_name,version_id\n13,Catan,1\n")
p = _app(cfg).get("/api/pipeline").json()
assert p["pending_review"] == 2 # one match + one edition decision
assert p["to_add"] == 2 # header excluded
assert p["to_add"] == 1 # header excluded
# a queue row review no longer backs is not pending work
cfg.to_add_path.write_text("bgg_id,bgg_name,version_id\n13,Catan,1\n99,Gone,\n")
p = _app(cfg).get("/api/pipeline").json()
assert p["to_add"] == 1
def test_photo_detail_page_serves_with_photos_nav_active(tmp_path):