Audit round 7, upload cluster: evidence over inference at every exit

Five blind reviewers swept the real-data-era surface; this lands the
upload findings, all verified against the code and the documented site
behavior before fixing.

The two HIGHs shared a root: logging outcomes the browser never proved.
add_game waited for an "Add To" button that an owned game's page does
not have — so a second-copy add could never succeed, and worse, an add
that LANDED but missed the log became an unretryable failure loop
(every retry: 30s timeout, logged failed, nothing ever settles).
add_game now polls for either button state: "In Collections" without
second_copy returns the previously-dead already_present status (the
landed-but-unlogged case heals itself on retry); with second_copy it
refuses loudly (that flow is unverified — add by hand). A save whose
dialog is slow to hide reloads the page and asks for ownership evidence
instead of guessing "failed". update_entry no longer trusts the editor
merely closing: the cell must settle on text matching the CHOSEN
version, else the AJAX save failed server-side and "updated" would
mark a job done forever that never touched the site.

Per-copy bookkeeping: stale_jobs endorsed per game, so rejecting one
of two queued editions let the rejected copy upload on the survivor's
endorsement — it now counts endorsements per (bgg_id, version) and
retires the game with "re-run diff" when a copy loses its backing.
annotate_queue stamped every row sharing a job key with the same log
status, so one success marked both vetoed duplicates done; completions
are now claimed one row per done log line.

Smaller findings: the version-drift note queued a doomed re-add after
warning about it (now skips — the entry exists on BGG; re-adding only
duplicates); the one-update-per-game deferral rested on a claim the
collid-exact editor disproves (removed — same-game updates run
together); the 3-identical-failures abort compared exception class
only, so three unrelated problems aborted a healthy run (now compares
whole messages).

Also from the test seat: run_upload's stale filtering finally executes
against a real matches.csv in tests; rejected credentials pin that no
anonymous storage state is saved; update_entry's three guarded exits
each have a test; _scrub's newline flattening is pinned.

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:21:52 -04:00
co-authored by Claude Fable 5
parent 22fa17b5ee
commit 32b6aae841
8 changed files with 803 additions and 174 deletions
+98 -20
View File
@@ -58,6 +58,16 @@ DONE_STATUSES = {"added", "added_no_version", "updated", "already_present"}
MAX_VERSION_PAGES = 40
_YEARISH = re.compile(r"[\s(]*\d{4}(?:-\d+)?[\s)]*")
def _loose_version_text(text: str) -> str:
"""Version names as displayed differ from the API's by punctuation,
parens and year qualifiers — compare only the words that name it."""
text = _YEARISH.sub(" ", text or "")
return " ".join(re.sub(r"[^\w\s]", " ", text.casefold()).split())
@dataclass(frozen=True)
class UploadJob:
action: str # "add" | "update"
@@ -99,8 +109,14 @@ def annotate_queue(
job succeeds — so a reader without the log sees finished work as
outstanding forever."""
last: dict[tuple[str, str, str], str] = {}
done_count: Counter[tuple[str, str, str]] = Counter()
for row in log_rows:
last[_job_key(row)] = row["status"]
if row["status"] in DONE_STATUSES:
done_count[_job_key(row)] += 1
# vetoed duplicate copies share a key: one success must mark ONE row
# done, not both, or the queue reports a copy uploaded that never was
claimed: Counter[tuple[str, str, str]] = Counter()
out = []
for row in queue_rows:
key = _key(
@@ -110,6 +126,10 @@ def annotate_queue(
row.get("version_id", ""),
)
status = last.get(key, "")
if status in DONE_STATUSES and claimed[key] >= done_count[key]:
status = "" # completions exhausted: this copy is still pending
if status in DONE_STATUSES:
claimed[key] += 1
out.append(
{
**row,
@@ -134,20 +154,34 @@ def stale_jobs(queue_rows: list[dict], match_rows: list[dict]) -> dict[str, str]
# driving the queue directly): absence is not a verdict
return {}
live: dict[str, list[dict]] = {}
endorsed: Counter[tuple[str, str]] = Counter()
for row in match_rows:
if row.get("bgg_id"):
live.setdefault(row["bgg_id"], []).append(row)
if is_recognized(row):
endorsed[(row["bgg_id"], row.get("version_id", ""))] += 1
queued: Counter[tuple[str, str]] = Counter()
for row in queue_rows:
queued[(row.get("bgg_id") or "", row.get("version_id", ""))] += 1
stale = {}
for row in queue_rows:
bgg_id = row.get("bgg_id") or ""
rows = live.get(bgg_id, [])
if any(is_recognized(r) for r in rows):
continue
if not rows:
stale[bgg_id] = "no longer matched to this game in matches.csv"
else:
continue
if not any(is_recognized(r) for r in rows):
statuses = sorted({r["match_status"] for r in rows})
stale[bgg_id] = f"now {', '.join(statuses)} in matches.csv"
continue
# the game survives, but does THIS copy? Rejecting one of two
# editions must not ride along on the other's endorsement.
pair = (bgg_id, row.get("version_id", ""))
if queued[pair] > endorsed[pair]:
stale[bgg_id] = (
f"queued with version {row.get('version_id') or '(none)'} "
"but matches.csv no longer endorses that copy — re-run diff"
)
return stale
@@ -248,8 +282,7 @@ def build_queue(
jobs: list[UploadJob] = []
skipped_done = skipped_failed = 0
deferred: list[UploadJob] = []
update_game_seen: set[str] = set()
deferred: list[UploadJob] = [] # kept for callers; nothing defers now
seen: Counter[tuple[str, str, str]] = Counter()
queued_versions: dict[str, set[str]] = {}
for job in candidates:
@@ -266,11 +299,13 @@ def build_queue(
prior = set()
if prior and job.version_id not in prior:
typer.echo(
f" note: {job.name} was previously {job.action}ed with a "
f" skipping {job.name}: previously {job.action}ed with a "
f"different version ({', '.join(sorted(prior)) or 'none'}) — "
"if re-review changed the version, the BGG entry needs a "
"manual correction (additive-only rule)"
"the entry exists on BGG, so re-adding can only duplicate "
"it; correct the version by hand (additive-only rule)"
)
skipped_done += 1
continue
occurrence = seen[job.key]
seen[job.key] += 1
if not job.name:
@@ -285,15 +320,10 @@ def build_queue(
skipped_done += 1
elif last_status.get(job.key) == "failed" and not retry_failed:
skipped_failed += 1
elif job.action == "update" and job.bgg_id in update_game_seen:
# The row-edit flow finds rows by game name, not collid — a
# second same-game update this run could reopen the copy the
# first one just versioned and overwrite it. One per run; the
# next run (after --verify) picks up the rest.
deferred.append(job)
else:
if job.action == "update":
update_game_seen.add(job.bgg_id)
# same-game updates coexist in one run: update_entry addresses
# the copy by collid and the edition by radio value, so a
# second update cannot reopen what the first just saved
jobs.append(job)
return jobs, skipped_done, skipped_failed, deferred
@@ -586,13 +616,36 @@ class PlaywrightUploader:
self._page.wait_for_timeout(400)
return False
def _owned_button(self):
"""The owned-game page replaces "Add To" with "In Collections"
positive evidence the collection already holds this game."""
return self._page.get_by_role("button", name="In Collections")
def add_game(self, job: UploadJob) -> tuple[str, str]:
self._ensure_logged_in()
page = self._page
# /boardgame/<id> redirects to the canonical slug for any subtype.
self._goto(f"{BGG}/boardgame/{job.bgg_id}/")
add_btn = page.get_by_role("button", name="Add To").first
dialog = self._open_dialog(add_btn)
add_btn = page.get_by_role("button", name="Add To")
for _ in range(60): # the header hydrates late: poll for EITHER state
if add_btn.count() and add_btn.first.is_visible():
break
owned = self._owned_button()
if owned.count() and owned.first.is_visible():
if job.second_copy:
# the second-copy path goes through the In Collections
# dialog, which this code has never driven live
raise RuntimeError(
"second copy of an owned game — the In Collections "
"add-a-copy flow is unverified; add this copy by hand"
)
return (
"already_present",
"the site already lists this game as owned — a previous "
"attempt likely landed without reaching the log",
)
page.wait_for_timeout(500)
dialog = self._open_dialog(add_btn.first)
# Wait for the form itself, NOT for a heading matching our stored
# name: a match made through an ALTERNATE name (BGG 140509 is
# "Dungeons & Dragons" to search, "Dragones Y Mazmorras" on the
@@ -612,7 +665,21 @@ class PlaywrightUploader:
note = f"version {job.version_name!r}: {why}; added without version"
dialog.get_by_role("button", name="Save").click()
# The dialog is hidden after save, not removed from the DOM.
dialog.wait_for(state="hidden", timeout=15_000)
try:
dialog.wait_for(state="hidden", timeout=15_000)
except self._timeout_error:
# a slow hide is not a failed save: reload and ask the page.
# Logging "failed" for a landed add would double-add on retry.
self._goto(f"{BGG}/boardgame/{job.bgg_id}/")
owned = self._owned_button()
try:
owned.first.wait_for(timeout=15_000)
except self._timeout_error as err:
raise RuntimeError(
"the save dialog never closed and the page does not "
"show the game as owned — the add may not have landed"
) from err
note = (note + "; " if note else "") + "save confirmed via page reload"
return status, note
def update_entry(self, job: UploadJob) -> tuple[str, str]:
@@ -650,9 +717,20 @@ class PlaywrightUploader:
f"{job.collid} — entry left untouched"
) from err
radio.first.click() # fires CE_SaveData: no separate Save button
want = _loose_version_text(job.version_name)
for _ in range(40):
settled = " ".join((cell.first.text_content() or "").split())
if settled and "editing" not in settled.casefold():
got = _loose_version_text(settled)
if want and want not in got and got not in want:
# the editor closed but re-rendered its OLD content:
# the AJAX save failed server-side. "updated" here
# would mark the job done forever without evidence.
raise RuntimeError(
f"the version cell settled on {settled!r}, not the "
f"chosen {job.version_name!r} — the save did not "
"land; safe to retry (same radio, same result)"
)
return "updated", ""
page.wait_for_timeout(500)
raise RuntimeError(
@@ -706,7 +784,7 @@ def _process(
suffix = f"{note}" if note else ""
typer.echo(f" {job.name}: {status}{suffix}")
if status == "failed":
kind = note.split(":", 1)[0] # exception type from _scrub format
kind = note # IDENTICAL means the whole message, not the class
consecutive = (kind, consecutive[1] + 1 if kind == consecutive[0] else 1)
if consecutive[1] >= 3:
typer.echo(