No way to exclude a scan that is not a photograph #106

Closed
opened 2026-07-28 06:03:15 +00:00 by claude-bot · 1 comment

Severity: MEDIUM

The problem

docs/circa-spec.md §6.4 gives every entity a deleted_at, but the Photo model has none and
there is no way to exclude anything from review. Only comments soft-delete.

Real scan batches contain blank pages, envelope scans, album-cover shots, accidental duplicates of
the scanner lid, and outright junk. A reviewer hits "this isn't a photo" within the first hour and
can currently do nothing but leave it pending forever — where it inflates every count, clutters
every queue, and never goes away.

Scope

  • Add deleted_at (or an explicit excluded status with a reason) to Photo.
  • An "Not a photo / exclude" action in the review workspace and from the browser.
  • Excluded photos leave the default queues and counts but remain retrievable, with an audit event.
    Nothing is ever hard-deleted — consistent with §6.4.
  • A filter to view excluded photos and restore one.
  • Exclude them from export by default.

Done when

  • A reviewer can exclude a non-photo in one action
  • Excluded items leave the queues and counts
  • Exclusion is reversible and audited
  • No image data is destroyed

References

  • backend/app/models/models.py (Photo)
  • docs/circa-spec.md §6.4
## Severity: MEDIUM ## The problem `docs/circa-spec.md` §6.4 gives every entity a `deleted_at`, but the `Photo` model has none and there is no way to exclude anything from review. Only comments soft-delete. Real scan batches contain blank pages, envelope scans, album-cover shots, accidental duplicates of the scanner lid, and outright junk. A reviewer hits "this isn't a photo" within the first hour and can currently do nothing but leave it `pending` forever — where it inflates every count, clutters every queue, and never goes away. ## Scope - Add `deleted_at` (or an explicit `excluded` status with a reason) to `Photo`. - An "Not a photo / exclude" action in the review workspace and from the browser. - Excluded photos leave the default queues and counts but remain retrievable, with an audit event. Nothing is ever hard-deleted — consistent with §6.4. - A filter to view excluded photos and restore one. - Exclude them from export by default. ## Done when - [ ] A reviewer can exclude a non-photo in one action - [ ] Excluded items leave the queues and counts - [ ] Exclusion is reversible and audited - [ ] No image data is destroyed ## References - `backend/app/models/models.py` (`Photo`) - `docs/circa-spec.md` §6.4
claude-bot added this to the v0.3.0 milestone 2026-07-28 06:03:15 +00:00
Author

Done in e9eef6a, CI green (run 64).

Soft delete, not a fifth status, and the measurement made that decision look better than it did on paper. PhotoStatus is computed from decision history by services/projections.fold, whose contract is that it is derivable from that history; an excluded member would put a non-derived value inside the one function that may not hold one — #79 exists because status was decided in three places. It would also overwrite the review state, so restoring a misclassified exclusion could not put it back. The end-to-end test approves a photograph, excludes it, restores it and asserts it comes back approved, against a real database.

The database and the wire say deleted (spec §6.4); the interface says excluded. That is load-bearing copy, not fussiness: someone who reads "delete" beside an irreplaceable scan will not press it, and then the queue still never empties.

The reason is picked, and picking it is the action — two interactions, on the thing a reviewer does most in the first hour of a batch. Only "Other" asks for words, because "Other" alone answers nothing a year later.

Two measured results that inverted part of the design

50,000 rows, production-like proportions, best of five.

A plain index on (collection_id, deleted_at, id) for the excluded view is a catastrophe. SQLite reads deleted_at IS NULL as an equality constraint on the second column, judges that index most selective, abandons ix_photo_collection_created and sorts 48,000 rows in a temp B-tree. Every live listing goes 0.06 ms → ~250 ms, 3,800x, with the answers still correct — so nothing above the query plan would report it. #84's finding and migration 012's, a third time: a regression caused by adding an index. Making it partial removes it from consideration.

And that index is (collection_id, created_at, id), not the deleted_at shape it reads as though it should have. The excluded queue is list_photos like any other listing and is ordered by (created_at, id), so an index leading with the predicate seeks and then sorts: 5.357 ms vs 0.062 ms. 86x, and the same lesson date_sort taught — the index must carry the order the query asks for, not the predicate it filters on.

With those settled, the five partial listing indexes earn their keep on the dashboard, not the list: identical listing times either way at 4% excluded, but /photos/stats goes 47.3 ms → 9.4 ms, because a plain index makes SQLite fetch all 50,000 rows to read a deleted_at it does not carry.

Two things found on the way

A partial index is only usable when the query carries the predicate, which makes "the repository forgot deleted_at IS NULL" a 250 ms regression rather than merely a wrong answer — and test_query_plans.py could not have caught it, because by convention it explains hand-written SQL. It now compiles and explains the query list_photos actually sends.

op.add_column with a ForeignKey cannot be used at all: on SQLite it raises NotImplementedError after adding the column, leaving a failed migration with the column and no constraint. batch_alter_table was not the answer either — SQLAlchemy's SQLite reflection skips partial indexes, so rebuilding photo would silently drop uq_photo_canonical_sha256 (#91) along with the five new ones. Plain DDL with a real FK, confirmed by PRAGMA foreign_key_list and a refused insert.

Scope notes

  • Excluded in one action, reversible, audited with actor and reason, nothing destroyed (media still served)
  • Leaves the listings, queues and status counts; own dashboard card, because 400 photographs silently leaving the counts reads as an archive that lost them
  • Filter to view them and restore one
  • Export: not implemented yet (v0.4.0), but list_photos(excluded=False) is the default, so export inherits the exclusion when it is built rather than needing to remember it.

One deliberate deviation from spec §6.4, which says deleted records are "recoverable by an admin": both exclude and restore use the reviewer floor. Nothing is destroyed, exclusion is the fast path used dozens of times in the first hour of a batch, and requiring an admin to undo a misclick on a scanner-lid scan would make the excluded queue unusable by the people who work it. A viewer still cannot reach either. Say if you would rather restore were admin-only — it is a one-line change.

One known cost, not paid: reading the excluded queue in date order is unindexed and sorts ~2,000 rows at about 5.5 ms. A sixth partial index would fix it and would cost writes only for the 4% of rows in it; migration 012 declined that trade for filter-only shapes, so this follows suit.

Backend tests 1206 → 1282, frontend 190 → 202, end-to-end 10 → 11.

Done in `e9eef6a`, CI green (run 64). **Soft delete, not a fifth status**, and the measurement made that decision look better than it did on paper. `PhotoStatus` is computed from decision history by `services/projections.fold`, whose contract is that it is derivable from that history; an `excluded` member would put a non-derived value inside the one function that may not hold one — #79 exists because status was decided in three places. It would also overwrite the review state, so restoring a misclassified exclusion could not put it back. The end-to-end test approves a photograph, excludes it, restores it and asserts it comes back **approved**, against a real database. The database and the wire say `deleted` (spec §6.4); the interface says **excluded**. That is load-bearing copy, not fussiness: someone who reads "delete" beside an irreplaceable scan will not press it, and then the queue still never empties. **The reason is picked, and picking it is the action** — two interactions, on the thing a reviewer does most in the first hour of a batch. Only "Other" asks for words, because "Other" alone answers nothing a year later. ## Two measured results that inverted part of the design 50,000 rows, production-like proportions, best of five. **A plain index on `(collection_id, deleted_at, id)` for the excluded view is a catastrophe.** SQLite reads `deleted_at IS NULL` as an equality constraint on the second column, judges that index most selective, abandons `ix_photo_collection_created` and sorts 48,000 rows in a temp B-tree. **Every live listing goes 0.06 ms → ~250 ms, 3,800x, with the answers still correct** — so nothing above the query plan would report it. #84's finding and migration 012's, a third time: a regression caused by *adding* an index. Making it partial removes it from consideration. **And that index is `(collection_id, created_at, id)`, not the `deleted_at` shape it reads as though it should have.** The excluded queue is `list_photos` like any other listing and is ordered by `(created_at, id)`, so an index leading with the predicate seeks and then sorts: 5.357 ms vs 0.062 ms. 86x, and the same lesson `date_sort` taught — the index must carry the order the query asks for, not the predicate it filters on. With those settled, the five partial listing indexes earn their keep on the **dashboard, not the list**: identical listing times either way at 4% excluded, but `/photos/stats` goes 47.3 ms → 9.4 ms, because a plain index makes SQLite fetch all 50,000 rows to read a `deleted_at` it does not carry. ## Two things found on the way **A partial index is only usable when the query carries the predicate**, which makes "the repository forgot `deleted_at IS NULL`" a 250 ms regression rather than merely a wrong answer — and `test_query_plans.py` could not have caught it, because by convention it explains hand-written SQL. It now compiles and explains the query `list_photos` actually sends. **`op.add_column` with a `ForeignKey` cannot be used at all**: on SQLite it raises `NotImplementedError` *after* adding the column, leaving a failed migration with the column and no constraint. `batch_alter_table` was not the answer either — SQLAlchemy's SQLite reflection skips partial indexes, so rebuilding `photo` would silently drop `uq_photo_canonical_sha256` (#91) along with the five new ones. Plain DDL with a real FK, confirmed by `PRAGMA foreign_key_list` and a refused insert. ## Scope notes - [x] Excluded in one action, reversible, audited with actor and reason, nothing destroyed (media still served) - [x] Leaves the listings, queues and status counts; own dashboard card, because 400 photographs silently leaving the counts reads as an archive that lost them - [x] Filter to view them and restore one - **Export**: not implemented yet (v0.4.0), but `list_photos(excluded=False)` is the default, so export inherits the exclusion when it is built rather than needing to remember it. **One deliberate deviation from spec §6.4**, which says deleted records are "recoverable by an admin": both exclude and restore use the **reviewer** floor. Nothing is destroyed, exclusion is the fast path used dozens of times in the first hour of a batch, and requiring an admin to undo a misclick on a scanner-lid scan would make the excluded queue unusable by the people who work it. A viewer still cannot reach either. Say if you would rather restore were admin-only — it is a one-line change. **One known cost, not paid:** reading the excluded queue in *date* order is unindexed and sorts ~2,000 rows at about 5.5 ms. A sixth partial index would fix it and would cost writes only for the 4% of rows in it; migration 012 declined that trade for filter-only shapes, so this follows suit. Backend tests 1206 → 1282, frontend 190 → 202, end-to-end 10 → 11.
Sign in to join this conversation.
No description provided.