Skip to content

Run-scoped eligibility, the value-comparing split, and an archive for superseded votes - #604

Merged
WilfordGrimley merged 2 commits into
masterfrom
feat/run-scoped-eligibility-and-vote-archive
Jul 30, 2026
Merged

Run-scoped eligibility, the value-comparing split, and an archive for superseded votes#604
WilfordGrimley merged 2 commits into
masterfrom
feat/run-scoped-eligibility-and-vote-archive

Conversation

@WilfordGrimley

@WilfordGrimley WilfordGrimley commented Jul 29, 2026

Copy link
Copy Markdown

"Prior runs must not suppress work in a new run. The CURRENT run's own output must, so a killed run resumes rather than redoing completed batches." — owner directive, 2026-07-29

"Keep at least one prior generation of votes, whose votes are NOT counted." — ratified separately

Stacked on #603 (the run_id audit). Review that first; this PR's base is that branch.

Why these three are one PR and not three

They are not independently reviewable, and shipping any one alone is worse than shipping none:

  • Un-suppressing eligibility alone buys exactly nothing. The calculator recomputes the verdict and the pre-write split then drops it before the write — and because purge_and_write_votes scopes its purge to the rows being written, a dropped row purges nothing. The stale vote survives verbatim, with no error, no counter moving, and a recomputation's worth of work discarded. Two independent suppression layers, and the second silently defeats a fix to the first.
  • The archive has no effect until the split permits an overwrite, and the owner ruled it must not ship separately from the work that creates superseded rows in the first place.

Layer 1 — run-scoped eligibility

Every Stage D printing-channel calculator asked "have I EVER voted on / abstained on this card?". That predicate grows monotonically, so each pass could only ever see a subset of the previous pass's pool, and a repaired engine could never re-examine anything the broken one had answered.

Not hypothetical: stage-d-illustration had to be version-bumped v1 → v2 purely to escape its own scan-log rows after its layout_class gate turned out to be reading a border colour — 3,409 wrongly-skipped cards were otherwise unreachable to a repaired v1. Under run-scoping the repair alone would have sufficed.

Both self-suppressing excludes now additionally match the current run_id, in _eligible_cards_queryset (join-key + fallback), _slow_path_eligible_cards_queryset, _eligible_illustration_cards_queryset, and _eligible_base_queryset (opt-in there; only lands passes a run_id today — run_pilot/run_name_frequency_elimination are a different workload with their own fetch budgets and resume semantics, and flipping them is a separate decision with a separate blast radius).

run_id=None keeps the pre-change behaviour byte-identically, deliberately and not vestigially: stream_backstop_sweep.verify_chunk asks "is there ANY Stage D backlog", a question about the catalogue rather than about a run, and answering it run-scoped would report the whole catalogue as backlog on every fresh run_id.

A bug this almost shipped with, found by compiling the SQL rather than reasoning about it

The obvious spelling is .exclude(printing_tags__anonymous_id=X, printing_tags__run_id=Y). Django does not combine those into one subquery the same related row must satisfy — it emits

NOT (EXISTS(... U1.anonymous_id = X ...) AND EXISTS(... U1.run_id = Y ...))

two independent clauses. A card carrying this identity's vote from an old run plus some other identity's vote from this run satisfies both halves and is wrongly excluded — re-creating the exact cross-run suppression this work removes, in the hardest direction to notice: fewer cards processed, no error, no counter. It is the same negated-multi-valued-lookup trap _eligible_base_queryset's own docstring already documented for the scan-log exclusion. The scoped path uses an explicit pk__in subquery instead; TestTheCompiledSqlTrap pins both the SQL shape and the behaviour.

Layer 2 — the split compares the VALUE, not just the key

_split_new_printing_tag_votes compared (card_id, anonymous_id) alone. It now compares the whole set of (printing_id, is_no_match) a batch proposes for a group against what is stored — the shape local_illustration._split_new_illustration_votes has always had, whose own docstring calls it "THE ONE DIFFERENCE, AND IT IS LOAD-BEARING".

stored vs proposed outcome
same verdict skipped, counted in already_voted — re-running over a converged catalogue stays a no-op
different verdict kept → purge moves the stale generation into the archive, new one lands
no existing row kept

Compared per group, all-or-nothing, because one identity can legally hold several rows for a card (cardprintingtag_unique_printing_vote constrains the triple, not the pair — run_illustration_calculator's loop over verdict.printing_pks is a live caller shaped that way) and the purge is family-keyed on card_id. Keeping only part of a group would delete the rest and never re-insert them.

The "skip-and-count, not retract-and-recast" reasoning is untouched: it was always conditioned on both racing invocations computing the same verdict, which is exactly the case still skipped. What no longer gets swallowed is the case that reasoning already named as not covered — a genuinely changed conclusion.

local_lands_identify._split_new_votes needed no change (it already compares the full triple). A test now pins that rather than leaving it a coincidence.

Layer 3 — ArchivedCardPrintingTag

purge_stale_machine_votes copies every row it is about to delete into the archive first, stamped with the run that overwrote it. That function is the choke point for "a machine vote is superseded by a later machine vote", so no caller can supersede without archiving and no new caller has to remember to. vote_write.purge_and_write_votes supplies superseded_by_run_id; its existing transaction.atomic() now covers three statements instead of two — same cancel-safety property, one statement wider. A batch that cannot name a single run_id records NULL rather than a guess: a wrong stamp is worse than a missing one, since the diff report and #575's janitor both select on it.

Why a separate table and not retained generations in the live one — measured, not preferred

Of the 13 modules that read CardPrintingTag, 9 bypass resolve_vote_weight entirely: views.py, catalog_stats.py, local_calculate_verdicts.py, models.py, local_identify_printing_tags.py, soak_gate.py, harvest_probe.py, illustration_vote.py, local_lands_identify.py. A zero-weight-by-run_id rule (migration 0097's pattern) protects only the four that route through weight resolution. A retained generation left in the live table would still be displayed by views, counted by catalog-stats, and — fatally — would make eligibility treat the card as already voted, re-creating the very suppression this work removes.

Keeping the live table single-generation means no consumer can be wrong about it: no unique-constraint change, no audit of thirteen modules, no new rule any future reader has to know.

Rows are unreachable from Card/CanonicalCard (related_name="+" on both FKs), are not an AbstractWeightedVote subclass, and copy created_at verbatim rather than re-stamping it (archived_at is the separate honest answer to "when did this stop being live"). Append-only, no unique constraints — the same shape CardScanLog already has. Human votes never reach it: calculator_family returns None for the UUIDs humans use and the purge returns before touching anything.

Retention is #575's janitor's ("keep the N most recent runs per calculator, sweep the oldest, operator-authorised with a dry run, never delete wholesale"). Both run_id and superseded_by_run_id are indexed so a sweep can select a generation without a table scan.

The only reader is manage.py local_calculate_verdicts --generation-diff <path>. Per the owner's ruling, generation-diffing is an opt-in debug flag, never a default write path — the archive write is unconditional (a paper trail that only exists when somebody remembered to ask for it is not a paper trail); the read is what is opt-in.

Order is a CORRECTNESS constraint, not a performance one

fallback, illustration and slow-path all select positively from join-key's output. So "purge everything, then run the calculators in parallel" gives three of the four an empty eligible set — a silent near-no-op that reports success. Required order stays join-key → fallback → illustration → slow-path, which is what both dispatchers already do.

This is also why the upstream populations are deliberately not run-scoped, and that asymmetry is the whole correctness argument rather than an oversight: a converged join-key pass writes nothing under a fresh run_id, so "cards join-key voted no-match in this run" is empty on every re-run. Slow-path's fallback-voted exclusion must stay unscoped for the same reason and in a worse direction: a run-scoped version would route a card fallback solved in an earlier run to a human reviewer.

TestDependencyOrdering constructs the out-of-order failure on purpose — a suite that only ever runs them in the right order is not evidence that the order matters.

Consequence worth knowing: the retraction runbooks are partly obsoleted

reparse_collector_evidence and rejudge_fallback_channel's two-step runbook existed partly because a stale scan-log row permanently locked a card out of its calculator. It no longer does. Their retraction step is still needed to remove the stale record (and, for votes, to stop a stale row being counted by consensus), but it is no longer what unlocks eligibility. Those tests now assert the exclusion under the recorded row's own run_id and say why, rather than quietly pinning the weaker claim.

A resumption gap that is accepted, not overlooked

A card whose verdict a run recomputes as unchanged writes no row, so it carries no marker for that run and a restarted run recomputes it. Resumption skips completed writes, not completed recomputations. Closing it would mean stamping the current run_id onto an existing row — destroying the provenance migration 0097's frozen cohort depends on being able to state. Not worth it.

Verification

  • cardpicker/tests/test_run_scoped_eligibility.py: 29 new tests — prior-run/current-run eligibility, the compiled-SQL trap, illustration's own duplicated copy of the exclusion, changed-verdict overwrite + archiving, archive-is-not-a-live-vote, superseding-run stamping, dependency ordering (including the empty-set failure), and two- and three-pass convergence.
  • 26 mutations applied one at a time, each run red, then restored: every exclusion (vote and scan-log, in all four eligibility functions), every calculator's forwarding of its run_id, the split's value comparison, the split's skip-if-identical branch, the archive copy, related_name="+", created_at fidelity, original_id, superseded_by_run_id, and each of the three upstream populations that must not be run-scoped.
    Three mutants came back green on the first pass and each was a genuine coverage gap rather than a false alarm — the illustration calculator's run_id forwarding, and both call sites of the evidence-transfer stamp — so tests were added until every one went red.
  • A pre-existing latent flake fixed on the way past: test_local_illustration.TestPrintingsForIllustration::test_the_scope_reaches_the_compiled_sql asserted str(pk) not in sql, which is only true while the pk is a digit string appearing nowhere else — and the query embeds illustration_id UUIDs verbatim, so a single-digit pk is a substring of almost any of them. It passed or failed on where the sequence happened to be, i.e. on which other test files ran first. Now asserted on the predicate it actually means.
  • Full pytest cardpicker: 3192 passed, 8 skipped. black, ruff, isort, mypy clean; docs-lint clean; makemigrations --check: no changes, single leaf.

⚠️ Migration numbering — 0100, on a dependency that does not exist yet

Rebased onto current origin/master (post-#573, #579, #581, #582, #584, #586).

0100_superseded_card_printing_tag_archive, depending on 0099_rename_printings_count_catalogued (PR #601).

That migration does not exist anywhere yet — not on master, and not yet on #601's own branch, which still carries the file under its original name 0098_rename_printings_count_catalogued. The name assumed here is #601's file renumbered 0098 → 0099 with its slug unchanged, exactly the transformation #576 performed (0096_freeze_… → 0097_freeze_…, slug preserved). If #601 lands under any other name this dependency string must be corrected before merge, or migrate fails with NodeNotFoundError and no test database can be built on any branch.

This PR therefore cannot merge before #601.

The chain 0098 (#573, merged) → 0099 (#601) → 0100 (this) is coordinator-assigned. There is no substantive ordering constraint between the three — #601 renames a column on cardpicker_canonicalcard, #573 added columns to cardpicker_cardillustrationvote, this creates a new table. The chain exists solely to keep cardpicker at a single leaf node; two leaves make manage.py migrate refuse to run at all, which breaks test-database setup on every branch in the repo, not just the offender's.

How this was originally got wrong

Recorded so the reasoning is not repeated. It was first numbered 0098-on-0097, on the then-correct reasoning that #573 was still open and that depending on a migration absent from master makes a branch unmigratable today, with certainty, to avoid a collision that might never happen. #573 then merged, inverting the trade-off: the collision stopped being hypothetical and became a fact on master — and one invisible to every normal signal, since different filenames mean no textual conflict, GitHub still reported this PR mergeable, and CI stayed green because it had run against the pre-#573 tree. Only makemigrations --check and the migration loader's leaf count catch it.

Verified after the renumber

Rebase note: this PR and #579 touch the same querysets

#579 (push card_ids into every Stage D dependency subquery) landed while this was open and refactored the join-key no-hit population into a shared _join_key_no_hit_subqueries(card_ids) helper. Two conflicts, both resolved by composing rather than choosing: _fallback_eligible_cards_queryset and _slow_path_eligible_cards_queryset keep #579's helper and card_ids pushdown and gain this PR's run_id narrowing (which applies to slow-path's own already_routed sub-population only, never to the upstream join-key/fallback ones). The mutants that target those two functions were respelled against the new code shape and re-run — both still red.

Docs: two runbook claims this invalidates, corrected in place

  • docs/troubleshooting.md's "A reparse_collector_evidence/Stage D retraction pass silently never routes its own newly-touched cards to slow-path review" is resolved by this change — and not by the fix it had spec'd. Run-scoping means a stale stage-d-slow-path-v1 marker no longer excludes anything from a new run. The spec'd fix (teach reparse_and_retract to also delete the slow-path row) was deliberately not built: it would make every retraction command responsible for knowing which downstream calculators had left markers, which is the coupling that produced the symptom in the first place.
  • docs/features/stage-e-operations.md's rejudge_fallback_channel runbook described retraction as "making those cards eligible for a fresh local_calculate_verdicts pass". That was the mechanism and no longer is. Revised to say what retraction still buys: removing a stale record, which for a vote is load-bearing (an un-retracted stale vote keeps its consensus weight until something overwrites it) — while eligibility is now unlocked by every new run regardless.

Post-rebase verification

Full pytest cardpicker: 3304 passed, 8 skipped. black, ruff, isort, mypy clean; docs-lint clean. Seven mutations re-run against the rebased tree (including the two respelled for #579's refactor) — all red.

🤖 Generated with Claude Code

https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN

WilfordGrimley and others added 2 commits July 30, 2026 07:19
… superseded votes

    "Prior runs must not suppress work in a new run. The CURRENT run's own
    output must, so a killed run resumes rather than redoing completed batches."
                                                    - owner directive, 2026-07-29

    "Keep at least one prior generation of votes, whose votes are NOT counted."
                                                    - ratified separately

WHY THESE THREE ARE ONE COMMIT AND NOT THREE

They are not independently reviewable, and shipping any one alone is worse than
shipping none:

  - Un-suppressing eligibility ALONE buys exactly nothing. The calculator
    recomputes the verdict and the pre-write split then drops it before the
    write, and because `purge_and_write_votes` scopes its purge to the rows
    being written, a dropped row purges NOTHING - the stale vote survives
    verbatim, with no error, no counter moving, and a recomputation's worth of
    work discarded. Two independent suppression layers, and the second silently
    defeats a fix to the first.
  - The archive has no effect until the split permits an overwrite, and the
    owner ruled it must not ship separately from the work that creates
    superseded rows in the first place.

The run_id AUDIT this depends on is a separate, genuinely independent PR
(`fix/run-id-population-gaps`), which this is stacked on.

LAYER 1 - RUN-SCOPED ELIGIBILITY

Every Stage D printing-channel calculator asked "have I EVER voted on / abstained
on this card?". That predicate grows monotonically, so each pass could only ever
see a subset of the previous pass's pool, and a repaired engine could never
re-examine anything the broken one had answered. Not hypothetical:
`stage-d-illustration` had to be version-bumped v1 -> v2 purely to escape its own
non-rescannable scan-log rows after its layout_class gate turned out to be
reading a border colour - 3,409 wrongly-skipped cards were otherwise unreachable
to a repaired v1. Under run-scoping the repair alone would have sufficed.

Both self-suppressing excludes - the printing-tag vote exclude and the
non-rescannable `CardScanLog` exclude - now additionally match the CURRENT
run_id, in `_eligible_cards_queryset` (join-key + fallback),
`_slow_path_eligible_cards_queryset`, `_eligible_illustration_cards_queryset`
and `_eligible_base_queryset` (opt-in there; only lands passes a run_id today -
`run_pilot`/`run_name_frequency_elimination` are a different workload with their
own fetch budgets and resume semantics, and flipping them is a separate decision
with a separate blast radius).

`run_id=None` keeps the pre-change behaviour BYTE-IDENTICALLY, deliberately and
not vestigially: `stream_backstop_sweep.verify_chunk` asks "is there ANY Stage D
backlog", a question about the catalogue rather than about a run, and answering
it run-scoped would report the whole catalogue as backlog on every fresh run_id.

A BUG THIS ALMOST SHIPPED WITH, FOUND BY COMPILING THE SQL RATHER THAN REASONING
ABOUT IT. The obvious spelling is
`.exclude(printing_tags__anonymous_id=X, printing_tags__run_id=Y)`. Django does
NOT combine those into one subquery the same related row must satisfy - it emits
`NOT (EXISTS(... anonymous_id=X ...) AND EXISTS(... run_id=Y ...))`, two
INDEPENDENT clauses. A card carrying THIS identity's vote from an OLD run plus
some OTHER identity's vote from THIS run satisfies both halves and is wrongly
excluded, re-creating the exact cross-run suppression this work removes, in the
hardest direction to notice: fewer cards processed, no error, no counter. It is
the same negated-multi-valued-lookup trap `_eligible_base_queryset`'s own
docstring already documented for the scan-log exclusion. The scoped path uses an
explicit `pk__in` subquery instead; `TestTheCompiledSqlTrap` pins both the SQL
shape and the behaviour.

LAYER 2 - THE SPLIT COMPARES THE VALUE, NOT JUST THE KEY

`_split_new_printing_tag_votes` compared `(card_id, anonymous_id)` alone. It now
compares the whole SET of `(printing_id, is_no_match)` a batch proposes for a
group against what is stored - the shape
`local_illustration._split_new_illustration_votes` has always had, whose own
docstring calls it "THE ONE DIFFERENCE, AND IT IS LOAD-BEARING".

  - existing rows, SAME verdict      -> skipped, counted in `already_voted`.
    Re-running over a converged catalogue stays a no-op, which is what stops
    run-scoping becoming an overwrite-everything churn machine.
  - existing rows, DIFFERENT verdict -> kept. The purge moves the stale
    generation into the archive and the new one lands.
  - no existing row                  -> kept.

Compared PER GROUP, ALL-OR-NOTHING, because one identity can legally hold
several rows for a card (`cardprintingtag_unique_printing_vote` constrains the
triple, not the pair - `run_illustration_calculator`'s loop over
`verdict.printing_pks` is a live caller shaped that way) and the purge is
family-keyed on `card_id`. Keeping only PART of a group would delete the rest
and never re-insert them.

The "skip-and-count, not retract-and-recast" reasoning is untouched: it was
always conditioned on both racing invocations computing the SAME verdict, which
is exactly the case still skipped. What no longer gets swallowed is the case
that reasoning already named as NOT covered - a genuinely changed conclusion.

`local_lands_identify._split_new_votes` needed no change: it already compares the
full (card, printing, anonymous_id) triple, so a changed answer already reached
its purge. A test now pins that rather than leaving it a coincidence.

LAYER 3 - `ArchivedCardPrintingTag`

`purge_stale_machine_votes` copies every row it is about to delete into the
archive first, stamped with the run that overwrote it. That function is THE choke
point for "a machine vote is superseded by a later machine vote", so no caller
can supersede without archiving and no new caller has to remember to.
`vote_write.purge_and_write_votes` supplies `superseded_by_run_id`, derived from
the batch it is writing, and its existing `transaction.atomic()` now covers three
statements instead of two - the same cancel-safety property, one statement wider.
A batch that cannot name a single run_id records NULL rather than a guess: a
wrong stamp is worse than a missing one, since the diff report and issue #575's
janitor both select on it and a plausible wrong value is indistinguishable from a
right one after the fact.

WHY A SEPARATE TABLE AND NOT RETAINED GENERATIONS IN THE LIVE ONE - MEASURED, NOT
PREFERRED. Of the thirteen modules that read `CardPrintingTag`, NINE bypass
`vote_consensus.resolve_vote_weight` entirely: `views.py`, `catalog_stats.py`,
`local_calculate_verdicts.py`, `models.py`, `local_identify_printing_tags.py`,
`soak_gate.py`, `harvest_probe.py`, `illustration_vote.py`,
`local_lands_identify.py`. A zero-weight-by-run_id rule (migration 0097's
pattern) protects only the four that route through weight resolution. A retained
generation left in the live table would still be DISPLAYED by views, COUNTED by
catalog-stats, and - fatally - would make eligibility treat the card as already
voted, re-creating the very suppression this work removes. Keeping the live table
single-generation means no consumer can be wrong about it: no unique-constraint
change, no audit of thirteen modules, no new rule any future reader has to know.

Rows are unreachable from `Card`/`CanonicalCard` (`related_name="+"` on both
FKs), are not an `AbstractWeightedVote` subclass, and copy `created_at` verbatim
rather than re-stamping it, with `archived_at` as the separate honest answer to
"when did this stop being live". Append-only, no unique constraints - the same
shape `CardScanLog` already has. Human votes never reach it: `calculator_family`
returns None for the UUIDs humans use and the purge returns before touching
anything.

Retention is issue #575's janitor's ("keep the N most recent runs per calculator,
sweep the oldest, operator-authorised with a dry run, never delete wholesale").
Both `run_id` (the superseded generation's own run) and `superseded_by_run_id`
(the run that overwrote it) are indexed so a sweep can select a generation
without a table scan.

THE ONLY READER: `manage.py local_calculate_verdicts --generation-diff <path>`,
one JSONL line per vote this run superseded. Per the owner's ruling,
generation-diffing is an opt-in DEBUG FLAG, never a default write path - the
archive WRITE is unconditional (a paper trail that only exists when somebody
remembered to ask for it is not a paper trail); the READ is what is opt-in.

ORDER IS A CORRECTNESS CONSTRAINT, NOT A PERFORMANCE ONE

`fallback`, `illustration` and `slow-path` select POSITIVELY from join-key's
output. So "purge everything, then run the calculators in parallel" gives THREE
OF THE FOUR an empty eligible set - a silent near-no-op that reports success.
Required order stays join-key -> fallback -> illustration -> slow-path, which is
what both dispatchers already do.

This is also why the upstream populations are deliberately NOT run-scoped, and
that asymmetry is the whole correctness argument rather than an oversight: a
converged join-key pass writes NOTHING under a fresh run_id (an identical
recomputed verdict is skipped, and the stored row keeps its original run), so
"cards join-key voted no-match IN THIS RUN" is empty on every re-run. Slow-path's
fallback-voted exclusion must stay unscoped for the same reason and in a worse
direction: a run-scoped version would route a card fallback SOLVED in an earlier
run to a human reviewer.

CONSEQUENCE WORTH KNOWING: THE RETRACTION RUNBOOKS ARE PARTLY OBSOLETED

`reparse_collector_evidence` and `rejudge_fallback_channel`'s two-step runbook
existed partly because a stale scan-log row permanently locked a card out of its
calculator. It no longer does. Their retraction step is still needed to remove
the stale RECORD (and, for votes, to stop a stale row being counted by
consensus), but it is no longer what unlocks eligibility. Those tests now assert
the exclusion under the recorded row's OWN run_id and say why, rather than
quietly pinning the weaker claim.

A RESUMPTION GAP THAT IS ACCEPTED, NOT OVERLOOKED

A card whose verdict a run recomputes as UNCHANGED writes no row, so it carries
no marker for that run and a restarted run recomputes it. Resumption skips
completed WRITES, not completed recomputations. Closing it would mean stamping
the current run_id onto an existing row, destroying the provenance migration
0097's frozen cohort depends on being able to state. Not worth it.

VERIFICATION

  - `cardpicker/tests/test_run_scoped_eligibility.py`: 29 new tests across
    prior-run/current-run eligibility, the compiled-SQL trap, illustration's own
    duplicated copy of the exclusion, changed-verdict overwrite + archiving,
    archive-is-not-a-live-vote, superseding-run stamping, dependency ordering
    (including the out-of-order empty-set failure, constructed on purpose), and
    two- and three-pass convergence.
  - 26 MUTATIONS applied one at a time, each run red, then restored: every
    exclusion (vote and scan-log, in all four eligibility functions), every
    calculator's forwarding of its run_id, the split's value comparison, the
    split's skip-if-identical branch, the archive copy, `related_name="+"`,
    `created_at` fidelity, `original_id`, `superseded_by_run_id`, and each of the
    three upstream populations that must NOT be run-scoped. Three mutants came
    back GREEN on the first pass and each was a genuine coverage gap rather than
    a false alarm - the illustration calculator's run_id forwarding, and both
    call sites of the evidence-transfer stamp - so tests were added until every
    one went red.
  - A pre-existing latent flake fixed on the way past:
    `test_local_illustration.TestPrintingsForIllustration::
    test_the_scope_reaches_the_compiled_sql` asserted `str(pk) not in sql`, which
    is only true while the pk is a digit string appearing nowhere else - and the
    query embeds `illustration_id` UUIDs verbatim, so a single-digit pk is a
    substring of almost any of them. It passed or failed on where the sequence
    happened to be, i.e. on which other test files ran first. Now asserted on the
    predicate it actually means.
  - Full `pytest cardpicker`: 3192 passed, 8 skipped. black, ruff, isort, mypy
    clean; docs-lint clean; `makemigrations --check`: no changes, single leaf.

MIGRATION NUMBERING - 0100, AND A DEPENDENCY THAT DOES NOT EXIST YET

`0100_superseded_card_printing_tag_archive`, depending on
`0099_rename_printings_count_catalogued` (PR #601). THAT MIGRATION DOES NOT EXIST ANYWHERE YET -
not on master, and not yet on #601's own branch, which still carries the file under its original
name `0098_rename_printings_count_catalogued`. The name assumed here is #601's file renumbered
0098 -> 0099 with its slug unchanged, exactly the transformation #576 performed
(`0096_freeze_...` -> `0097_freeze_...`, slug preserved). IF #601 LANDS UNDER ANY OTHER NAME THIS
STRING MUST BE CORRECTED BEFORE MERGE, or `migrate` fails with NodeNotFoundError and no test
database can be built on any branch. THIS PR THEREFORE CANNOT MERGE BEFORE #601.

The chain 0098 (#573, merged) -> 0099 (#601) -> 0100 (this) is coordinator-assigned. There is no
substantive ordering constraint between the three - #601 renames a column on
`cardpicker_canonicalcard`, #573 added columns to `cardpicker_cardillustrationvote`, this creates a
new table - the chain exists solely to keep `cardpicker` at a SINGLE LEAF NODE.

HOW THIS WAS ORIGINALLY GOT WRONG, recorded so the reasoning is not repeated. It was first numbered
0098-on-0097 on the then-correct reasoning that #573 was still open and that depending on a
migration absent from master makes a branch unmigratable today, with certainty, to avoid a
collision that might never happen. #573 then MERGED, inverting the trade-off: the collision stopped
being hypothetical and became a fact on master - and one invisible to every normal signal, since
different filenames mean no textual conflict, GitHub still reported the PR mergeable, and CI stayed
green because it had run against the pre-#573 tree. Only `makemigrations --check` and the migration
loader's leaf count catch it.

VERIFIED AFTER THE RENUMBER: `makemigrations --check --dry-run` reports "No changes detected";
`MigrationLoader.graph.leaf_nodes()` returns exactly one cardpicker leaf,
`0100_superseded_card_printing_tag_archive`, with the forward plan ending
0097 -> 0098 -> 0099 -> 0100. Verified against a LOCAL STUB standing in for #601's migration (no
operations, so it cannot alter model state); the stub is not committed.

DOCS: TWO RUNBOOK CLAIMS THIS INVALIDATES, CORRECTED IN PLACE

  - `docs/troubleshooting.md`'s "A reparse_collector_evidence/Stage D retraction pass silently
    never routes its own newly-touched cards to slow-path review" is RESOLVED by this change, and
    NOT by the fix it had spec'd. Run-scoping means a stale `stage-d-slow-path-v1` marker no longer
    excludes anything from a new run. The spec'd fix - teach `reparse_and_retract` to also delete
    the slow-path row - was deliberately NOT built: it would make every retraction command
    responsible for knowing which downstream calculators had left markers, which is the coupling
    that produced the symptom.
  - `docs/features/stage-e-operations.md`'s `rejudge_fallback_channel` runbook described retraction
    as "making those cards eligible for a fresh local_calculate_verdicts pass". That was the
    mechanism and no longer is. Revised to say what retraction still buys: removing a stale RECORD,
    which for a VOTE is load-bearing (an un-retracted stale vote keeps its consensus weight until
    something overwrites it), while eligibility is now unlocked by every new run regardless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
…master

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@WilfordGrimley
WilfordGrimley force-pushed the feat/run-scoped-eligibility-and-vote-archive branch from 5f34ad5 to 5d9cd51 Compare July 30, 2026 07:25
WilfordGrimley added a commit that referenced this pull request Jul 30, 2026
…615

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@WilfordGrimley
WilfordGrimley merged commit ee64381 into master Jul 30, 2026
11 checks passed
@WilfordGrimley

Copy link
Copy Markdown
Author

Rebased onto origin/master (4a3babc). The old NodeNotFoundError failure was stale; CI is now green.

The Backend tests FAILURE that sat on this PR was produced before 0099 landed on master. GitHub does not re-run checks when the base moves, so the stale result kept looking authoritative. 0099_rename_printings_count_catalogued is on master now, 0100's dependency on it is correct and unchanged, and the re-run is green including #611's One leaf per app (merged with the base branch) job.

MigrationLoader(None).graph.leaf_nodes() → exactly one cardpicker leaf, 0100_superseded_card_printing_tag_archive. Verified mechanically, not by eye.

Merge order: this PR first, then #615. #615's 0101_delete_printingtagvote has been repointed to depend on 0100, so its own CI is red until this lands — deliberately, and documented there.

Rebase composed rather than chose

The rebase onto post-#601 master was clean, and both PRs' semantics were re-verified present afterwards: #601's printings_countcatalogued_printings_count rename and its deletion of the false "cross-verified against Scryfall" claim, and this PR's run-scoping and superseded-vote archive. 0100's docstring, which had been written against a 0099 that did not exist yet, was rewritten to describe the real merged graph.

The mutation re-run found a genuine coverage gap

Re-running mutants against the rebased tree, targeting _eligible_base_queryset in local_identify_printing_tags.py (one of the files #601 moved):

Mutant Result
Delete run_id narrowing on the own-CardScanLog exclude red — killed by test_a_PRIOR_run_s_scan_log_row_does_not_suppress_a_new_run
Delete run_id from the own-vote CardPrintingTag narrowing GREEN — survived the entire suite (3381 passed)

The function run-scopes two self-suppressing excludes and only the abstention half was asserted anywhere. The vote half — the one the owner directive is actually about — was covered by no test at all. Fixed by adding TestRunLandsIdentify::test_this_run_s_own_VOTE_removes_the_card_from_the_land_pool and ::test_a_PRIOR_run_s_vote_does_not_suppress_a_new_run; the mutant now dies on the second.

Full pytest cardpicker/tests/: 3383 passed, 11 skipped. Merged with #615 on a scratch tree: clean merge, single leaf, 3350 passed.

🤖 Generated with Claude Code

WilfordGrimley added a commit that referenced this pull request Jul 30, 2026
…615

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
WilfordGrimley added a commit that referenced this pull request Jul 30, 2026
…der and no rows (#615)

* Retire PrintingTagVote: the vote channel that had no resolver, no reader and no rows

Owner ruling, 2026-07-29: "i am willing to not need the printing tag. i am happy
to reduce things to the minimum that gives us our expected results." PR #599's
investigation measured the case against production and it is not close.

`PrintingTagVote` held 0 rows, 0 of them human, 0 ever resolved. It had no
consensus resolver anywhere in the repo's history - `printing_tag_consensus.py`,
which two docstrings forward-referenced, never existed on any branch - no reader
outside the Django admin, and no frontend caller of its submit endpoint. Its only
machine writer, `manage.py import_external_ip_tags`, never ran once. Underneath
that sits the reason it was unsalvageable rather than merely unused: an imported
Scryfall fact is not a disputable claim, and `resolve_weighted_consensus`'s
`has_human_backed` gate is absolute, so a machine-only channel returns None at any
volume. It was built to carry an indisputable fact through a mechanism designed
for disputable ones.

Removed: the model and its table (migration 0101), `POST 2/submitPrintingTagVote/`
and its route, the admin registration, the importer and its tests, and the
`PrintingTagVote` arm of `purge_machine_votes` (its report now enumerates three
vote tables, not four - the always-zero counter was dropped rather than pinned,
since a purge report naming a table that no longer exists is a claim about work
it did not do).

NOT touched, despite adjacent names: `CardPrintingTag` (167,229 rows, the whole
Stage D printing channel) and `CardTagVote` (223,999 rows, resolves into
`Card.tags`); nor `PRINTING_TAG_MIN_VOTES` / `PRINTING_TAG_IMPLICIT_CAP` /
`PRINTING_TAG_MACHINE_WEIGHT`, which are the app-wide consensus weights; nor
`_split_new_printing_tag_votes`, a `CardPrintingTag` collision guard.

Knowledge preserved rather than evaporated. `docs/features/printing-tags.md`'s
External-IP entry is rewritten as a retirement record carrying the deleted
importer's algorithm (the art_tags BFS-to-fixpoint over `child_ids`, the
two-pass tag/tagging split, the `illustration_id -> default_cards -> CanonicalCard`
join), the measured numbers on both sides (`promo_types` 10,407 vs `art:external-ip`
~13,166, an uncharacterised ~2,759 delta), what a rebuild must not carry, and the
commit to `git show` the deleted file from. `EXTERNAL_IP_TAG_NAME` moved to
`reason_tags.py` so the one-Tag.name convergence contract outlives the code that
used to honour it. `vote_write`'s `target_field` parameter lost its only
production caller, so its two tests are re-pointed at `CardPrintingTag.printing_id`
rather than deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN

* Repoint 0101_delete_printingtagvote at 0100; merge order is #604 then #615

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Retire scryfall-tagger-v1 from the fidelity-gate roster and the tether test

Master's #588 added a roster entry and a real-repo tether assertion for
scryfall-tagger-v1, the one calculator identity declared under
management/commands/. This PR deletes that command, so both went stale on
rebase: a broken path link in docs/pipeline-fidelity-gate.md and a
tether assertion for an identity that no longer exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
WilfordGrimley added a commit that referenced this pull request Jul 30, 2026
… production

Every change of the last three days was verified in isolation; none was
verified in composition. This enumerates every vote channel, calculator
identity, attribute-chip channel and Stage C extractor from code, measures
each against production, and traces each to the engine that can reach it.

Four findings, ranked:

1. Frame-style (Old/Modern Border) and bleed-edge (appropriate-bleed) chips
   are at ZERO machine rows with no substitute channel AND no wiring into
   either engine. 142,633 + 2,786 votes are re-derivable from stored
   ImageEvidence with no image fetch. Border chips survived the same purge
   only because they were being computed twice.

2. image_evidence.extract_card_evidence has zero production callers - only
   tests. Both engines call compute_card_evidence directly. It is the only
   non-pilot caller of cast_border_attribute_vote. Same defect shape as the
   artist recovery PR #581 fixed.

3. _slow_path_eligible_cards_queryset has no exclusion for the illustration
   calculator's identity; the code comment says the wiring "would need" it.
   A card illustration resolves is still routed to a human. PR #604 does not
   close this.

4. Four Stage D readers gate on one extractor key and then read six
   extractors' fields ungated. Two degrade in the strict direction, one into
   a permanently non-rescannable frame-mismatch skip.

Also: local-name-frequency-v1 and scryfall-tagger-v1 have zero PilotRunLedger
rows ever - never run, not merely dormant. docs/reference/skip-reasons.md was
checked value by value against production and holds unchanged.

READ-ONLY: no write, no management command, no migration, no deploy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
WilfordGrimley added a commit that referenced this pull request Jul 30, 2026
… production

Every change of the last three days was verified in isolation; none was
verified in composition. This enumerates every vote channel, calculator
identity, attribute-chip channel and Stage C extractor from code, measures
each against production, and traces each to the engine that can reach it.

Four findings, ranked:

1. Frame-style (Old/Modern Border) and bleed-edge (appropriate-bleed) chips
   are at ZERO machine rows with no substitute channel AND no wiring into
   either engine. 142,633 + 2,786 votes are re-derivable from stored
   ImageEvidence with no image fetch. Border chips survived the same purge
   only because they were being computed twice.

2. image_evidence.extract_card_evidence has zero production callers - only
   tests. Both engines call compute_card_evidence directly. It is the only
   non-pilot caller of cast_border_attribute_vote. Same defect shape as the
   artist recovery PR #581 fixed.

3. _slow_path_eligible_cards_queryset has no exclusion for the illustration
   calculator's identity; the code comment says the wiring "would need" it.
   A card illustration resolves is still routed to a human. PR #604 does not
   close this.

4. Four Stage D readers gate on one extractor key and then read six
   extractors' fields ungated. Two degrade in the strict direction, one into
   a permanently non-rescannable frame-mismatch skip.

Also: local-name-frequency-v1 and scryfall-tagger-v1 have zero PilotRunLedger
rows ever - never run, not merely dormant. docs/reference/skip-reasons.md was
checked value by value against production and holds unchanged.

READ-ONLY: no write, no management command, no migration, no deploy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
WilfordGrimley added a commit that referenced this pull request Jul 30, 2026
… production (#616)

Every change of the last three days was verified in isolation; none was
verified in composition. This enumerates every vote channel, calculator
identity, attribute-chip channel and Stage C extractor from code, measures
each against production, and traces each to the engine that can reach it.

Four findings, ranked:

1. Frame-style (Old/Modern Border) and bleed-edge (appropriate-bleed) chips
   are at ZERO machine rows with no substitute channel AND no wiring into
   either engine. 142,633 + 2,786 votes are re-derivable from stored
   ImageEvidence with no image fetch. Border chips survived the same purge
   only because they were being computed twice.

2. image_evidence.extract_card_evidence has zero production callers - only
   tests. Both engines call compute_card_evidence directly. It is the only
   non-pilot caller of cast_border_attribute_vote. Same defect shape as the
   artist recovery PR #581 fixed.

3. _slow_path_eligible_cards_queryset has no exclusion for the illustration
   calculator's identity; the code comment says the wiring "would need" it.
   A card illustration resolves is still routed to a human. PR #604 does not
   close this.

4. Four Stage D readers gate on one extractor key and then read six
   extractors' fields ungated. Two degrade in the strict direction, one into
   a permanently non-rescannable frame-mismatch skip.

Also: local-name-frequency-v1 and scryfall-tagger-v1 have zero PilotRunLedger
rows ever - never run, not merely dormant. docs/reference/skip-reasons.md was
checked value by value against production and holds unchanged.

READ-ONLY: no write, no management command, no migration, no deploy.


Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
WilfordGrimley added a commit that referenced this pull request Jul 30, 2026
…e name-only face reading

`_slow_path_eligible_cards_queryset` excluded `already_routed` and
`fallback_voted` and had NOTHING for the illustration calculator.
`management/commands/local_calculate_verdicts.py`'s own sequencing comment
admitted it - "the slow-path queryset would need an additional exclusion for
this identity's votes". PR #604 did not close it.

Failure direction is WRONG HUMAN WORK, not a silent no-op: Stage D sequences
join-key -> fallback -> illustration -> slow-path, so a card the illustration
calculator resolves is routed to a reviewer moments later in the SAME
invocation, asking a human to identify a card the pipeline just identified.
Bounded so far only because `stage-d-illustration-v2` has never run; the
read-only replay in docs/pipeline-fidelity-gate.md projects ~3,233 printing
votes, so it fires on the first `-v2` run. This is a pre-fire fix.

One `.exclude(pk__in=illustration_voted_card_ids)`, built exactly like the
fallback one: `is_no_match=False` qualified (an illustration `is_no_match` vote
is the calculator CONCLUDING it cannot identify the card - precisely a card a
reviewer should see), `card_ids`-pushed-down per PR #579, and deliberately NOT
run-scoped, because "illustration has a confident vote for this card" is a
statement about the catalogue rather than about a run.

The identity is a duplicated literal per this module's established "no hard
import-time dependency between sibling engines" convention - with a test
asserting it equals `local_illustration.ILLUSTRATION_ANONYMOUS_ID`, so a future
`-v3` bump fails there instead of silently reopening the defect.

ALSO, test-only: `CanonicalPrintingMetadata.face_illustrations` is about to be
populated in production. 1,594 of 113,224 printings get a non-empty list, and
60 of those are NAME-ONLY (`{name: ..., illustration_id: None}` throughout) -
non-empty lists carrying no usable illustration, which satisfy the partial
index `cpm_face_illustrations_present`. Any consumer reading list truthiness as
"has back-face art" is wrong for exactly those 60 the moment the importer runs.

Audited every consumer. BOTH are already correct: `IllustrationIndex._build`
tests `illustration_id is None`, and `printings_for_illustration`'s JSONB
containment asks for a real uuid a `None` cannot satisfy. The version stamp's
`.exclude(face_illustrations=[])` is a CHANGE DETECTOR, where counting a
name-only row is the correct behaviour. Nothing to fix - so the readings are
pinned with a name-only fixture instead, proven by mutation to fail against the
truthiness reading in both consumers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
WilfordGrimley added a commit that referenced this pull request Jul 30, 2026
…e name-only face reading

`_slow_path_eligible_cards_queryset` excluded `already_routed` and
`fallback_voted` and had NOTHING for the illustration calculator.
`management/commands/local_calculate_verdicts.py`'s own sequencing comment
admitted it - "the slow-path queryset would need an additional exclusion for
this identity's votes". PR #604 did not close it.

Failure direction is WRONG HUMAN WORK, not a silent no-op: Stage D sequences
join-key -> fallback -> illustration -> slow-path, so a card the illustration
calculator resolves is routed to a reviewer moments later in the SAME
invocation, asking a human to identify a card the pipeline just identified.
Bounded so far only because `stage-d-illustration-v2` has never run; the
read-only replay in docs/pipeline-fidelity-gate.md projects ~3,233 printing
votes, so it fires on the first `-v2` run. This is a pre-fire fix.

One `.exclude(pk__in=illustration_voted_card_ids)`, built exactly like the
fallback one: `is_no_match=False` qualified (an illustration `is_no_match` vote
is the calculator CONCLUDING it cannot identify the card - precisely a card a
reviewer should see), `card_ids`-pushed-down per PR #579, and deliberately NOT
run-scoped, because "illustration has a confident vote for this card" is a
statement about the catalogue rather than about a run.

The identity is a duplicated literal per this module's established "no hard
import-time dependency between sibling engines" convention - with a test
asserting it equals `local_illustration.ILLUSTRATION_ANONYMOUS_ID`, so a future
`-v3` bump fails there instead of silently reopening the defect.

ALSO, test-only: `CanonicalPrintingMetadata.face_illustrations` is about to be
populated in production. 1,594 of 113,224 printings get a non-empty list, and
60 of those are NAME-ONLY (`{name: ..., illustration_id: None}` throughout) -
non-empty lists carrying no usable illustration, which satisfy the partial
index `cpm_face_illustrations_present`. Any consumer reading list truthiness as
"has back-face art" is wrong for exactly those 60 the moment the importer runs.

Audited every consumer. BOTH are already correct: `IllustrationIndex._build`
tests `illustration_id is None`, and `printings_for_illustration`'s JSONB
containment asks for a real uuid a `None` cannot satisfy. The version stamp's
`.exclude(face_illustrations=[])` is a CHANGE DETECTOR, where counting a
name-only row is the correct behaviour. Nothing to fix - so the readings are
pinned with a name-only fixture instead, proven by mutation to fail against the
truthiness reading in both consumers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
WilfordGrimley added a commit that referenced this pull request Jul 30, 2026
…cratch

`run_image_evidence_cohort`'s resume filter asked an IDENTITY-scoped question -
"has this card ever been extracted at these manifest versions" - so a run could
never redo work, only ever see a shrinking subset of the previous run's pool.
PR #604 established the owner's ruling for Stage D ("a bulk run redoes
everything from scratch; flags tell it to narrow"; "prior runs cannot pollute,
but the current run can be resumed") and never reached Stage C. This applies the
same predicate one layer up, via `ImageEvidence.run_id` (already stamped
unconditionally by `persist_evidence` and `transfer_evidence`, already indexed).

The second consequence this closes: a field added WITHOUT a version bump was
permanently unreachable. `bleed_diff_mm` is NULL on 215,921/220,579 rows (97.9%),
213,131 of them on rows whose `bleed_class` is a confident `bleed`, so the
extractor's abstain path does not explain it - every row carries the one
`geometry-bleed-v1` version, so a v1 manifest written before the field existed
reads as current and is skipped forever.

Within-run resume is preserved and tested: `--run-id <the killed run's id>`
skips exactly what that run finished. The command prints its own resume line at
startup, and `--max-rss-mb`'s help no longer claims a bare re-invocation resumes.

`--only-never-extracted` narrows back to the old identity-scoped predicate as an
opt-in flag, per "default the default things, disable them with flags".

`--card-ids-file` is GENERALISED rather than duplicated: forcing re-extraction is
now what the default does, so the flag's hardcoded resume-filter bypass is gone
and it reduces to the pure scope narrowing its name promises. Strict gain - a
killed targeted re-extraction now resumes, which under the bypass it could not.

Its existing test claimed to prove that bypass while seeding `{key: "v1"}`, which
fails the version-aware filter regardless; it now seeds the CURRENT version map
and proves the run-scoped mechanism that replaced the bypass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN
WilfordGrimley added a commit that referenced this pull request Jul 30, 2026
…e name-only face reading (#655)

`_slow_path_eligible_cards_queryset` excluded `already_routed` and
`fallback_voted` and had NOTHING for the illustration calculator.
`management/commands/local_calculate_verdicts.py`'s own sequencing comment
admitted it - "the slow-path queryset would need an additional exclusion for
this identity's votes". PR #604 did not close it.

Failure direction is WRONG HUMAN WORK, not a silent no-op: Stage D sequences
join-key -> fallback -> illustration -> slow-path, so a card the illustration
calculator resolves is routed to a reviewer moments later in the SAME
invocation, asking a human to identify a card the pipeline just identified.
Bounded so far only because `stage-d-illustration-v2` has never run; the
read-only replay in docs/pipeline-fidelity-gate.md projects ~3,233 printing
votes, so it fires on the first `-v2` run. This is a pre-fire fix.

One `.exclude(pk__in=illustration_voted_card_ids)`, built exactly like the
fallback one: `is_no_match=False` qualified (an illustration `is_no_match` vote
is the calculator CONCLUDING it cannot identify the card - precisely a card a
reviewer should see), `card_ids`-pushed-down per PR #579, and deliberately NOT
run-scoped, because "illustration has a confident vote for this card" is a
statement about the catalogue rather than about a run.

The identity is a duplicated literal per this module's established "no hard
import-time dependency between sibling engines" convention - with a test
asserting it equals `local_illustration.ILLUSTRATION_ANONYMOUS_ID`, so a future
`-v3` bump fails there instead of silently reopening the defect.

ALSO, test-only: `CanonicalPrintingMetadata.face_illustrations` is about to be
populated in production. 1,594 of 113,224 printings get a non-empty list, and
60 of those are NAME-ONLY (`{name: ..., illustration_id: None}` throughout) -
non-empty lists carrying no usable illustration, which satisfy the partial
index `cpm_face_illustrations_present`. Any consumer reading list truthiness as
"has back-face art" is wrong for exactly those 60 the moment the importer runs.

Audited every consumer. BOTH are already correct: `IllustrationIndex._build`
tests `illustration_id is None`, and `printings_for_illustration`'s JSONB
containment asks for a real uuid a `None` cannot satisfy. The version stamp's
`.exclude(face_illustrations=[])` is a CHANGE DETECTOR, where counting a
name-only row is the correct behaviour. Nothing to fix - so the readings are
pinned with a name-only fixture instead, proven by mutation to fail against the
truthiness reading in both consumers.


Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
WilfordGrimley added a commit that referenced this pull request Jul 30, 2026
…cratch (#645)

`run_image_evidence_cohort`'s resume filter asked an IDENTITY-scoped question -
"has this card ever been extracted at these manifest versions" - so a run could
never redo work, only ever see a shrinking subset of the previous run's pool.
PR #604 established the owner's ruling for Stage D ("a bulk run redoes
everything from scratch; flags tell it to narrow"; "prior runs cannot pollute,
but the current run can be resumed") and never reached Stage C. This applies the
same predicate one layer up, via `ImageEvidence.run_id` (already stamped
unconditionally by `persist_evidence` and `transfer_evidence`, already indexed).

The second consequence this closes: a field added WITHOUT a version bump was
permanently unreachable. `bleed_diff_mm` is NULL on 215,921/220,579 rows (97.9%),
213,131 of them on rows whose `bleed_class` is a confident `bleed`, so the
extractor's abstain path does not explain it - every row carries the one
`geometry-bleed-v1` version, so a v1 manifest written before the field existed
reads as current and is skipped forever.

Within-run resume is preserved and tested: `--run-id <the killed run's id>`
skips exactly what that run finished. The command prints its own resume line at
startup, and `--max-rss-mb`'s help no longer claims a bare re-invocation resumes.

`--only-never-extracted` narrows back to the old identity-scoped predicate as an
opt-in flag, per "default the default things, disable them with flags".

`--card-ids-file` is GENERALISED rather than duplicated: forcing re-extraction is
now what the default does, so the flag's hardcoded resume-filter bypass is gone
and it reduces to the pure scope narrowing its name promises. Strict gain - a
killed targeted re-extraction now resumes, which under the bypass it could not.

Its existing test claimed to prove that bypass while seeding `{key: "v1"}`, which
fails the version-aware filter regardless; it now seeds the CURRENT version map
and proves the run-scoped mechanism that replaced the bypass.


Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
WilfordGrimley added a commit that referenced this pull request Jul 30, 2026
…mode (#650)

* Record why channel_report is not auto-gating yet (#628)

Owner ruling 2026-07-30: it stays an operator step for now. The decision
belongs in the published doc, not a PR comment.

The command is built to gate, and the roster is KNOWINGLY dirty -
ZERO_DECLARATIONS is empty by design and the coverage audit found three
genuinely silent channels. Auto-gating today means an unrelated command
fails because a channel nobody was touching has been silent for a week.

Sequence recorded: run deliberately -> first real reading -> rule on each
zero -> then wire it in, at which point a new zero genuinely means
something regressed. Also states that the first production reading is
EXPECTED to exit 1, so nobody reads a red first run as a broken tool.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN

* docs: record the push-loses-the-race-with-squash-merge failure mode

Second occurrence: PR #646's final commit landed 76 seconds after the
squash-merge fired, so it never reached master. PR #604's 82360a9 was
the first. Nothing in GitHub or CI reports this - the PR reads MERGED
and CI reads green.

Extends the existing squash-merge orphan entry rather than adding a
sibling: same mechanism, narrower window. Adds the mergedAt vs branch-tip
sweep so it can be detected mechanically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN

* docs: correct recovery PR number to #650

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NhYmT1PxCcyemA16dFDxN

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant