Bulk ingest, front/back pairing, and album linkage #105

Closed
opened 2026-07-28 06:03:14 +00:00 by claude-bot · 4 comments

Severity: HIGH - this is the front door for the whole collection

The problem

docs/circa-spec.md §8 requires bulk and incremental ingest, pairing _f/_b files by album and
sequence, and a watch directory. The implementation:

  • accepts one photo per HTTP request (backend/app/api/routes/ingest.py)
  • pairs front and back only if both are uploaded in the same request
  • never links the photo to an album or sets sequence_in_album, despite
    services/filename_parser.py successfully extracting album_slug, sequence, and is_back
  • has no upload page at all — ingest requires curl

So the parser tests one thing and the pipeline delivers another. A xxx_b.jpg back scan uploaded
alone becomes an orphan photo rather than pairing with its front. Nothing in the codebase ever
creates an Album.

Why it blocks Phase 2

Album ordering (#18) and constraint propagation (#19) are built on album context. With no
ingestion path for it, both start from empty data and someone must hand-assign thousands of
photos. This is far cheaper to fix before the collection is ingested than after.

Scope

  • A batch ingest endpoint, or a server-side folder scan the worker drives.
  • Get-or-create Album from the parsed slug; set sequence_in_album.
  • Pair is_back files to the matching front photo by album and sequence, across requests — or
    reject them with a clear message explaining why.
  • Report per-file outcomes for a batch: ingested, duplicate, paired, failed, and why.
  • A minimal upload and progress UI, or a watch-directory documented as the intended path.

Done when

  • A folder of thousands of scans can be ingested in one operation
  • Front and back scans pair correctly without being uploaded together
  • Photos land with album and sequence populated
  • Per-file outcomes are visible
  • Tests cover pairing, orphan backs, and mixed valid/invalid batches

References

  • backend/app/api/routes/ingest.py
  • backend/app/services/filename_parser.py (output currently discarded)
  • backend/app/services/ingest.py:133-149
  • docs/circa-spec.md §8

Blocks: #18, #19.

## Severity: HIGH - this is the front door for the whole collection ## The problem `docs/circa-spec.md` §8 requires bulk and incremental ingest, pairing `_f`/`_b` files by album and sequence, and a watch directory. The implementation: - accepts **one photo per HTTP request** (`backend/app/api/routes/ingest.py`) - pairs front and back **only** if both are uploaded in the same request - **never links the photo to an album or sets `sequence_in_album`**, despite `services/filename_parser.py` successfully extracting `album_slug`, `sequence`, and `is_back` - has no upload page at all — ingest requires curl So the parser tests one thing and the pipeline delivers another. A `xxx_b.jpg` back scan uploaded alone becomes an orphan photo rather than pairing with its front. Nothing in the codebase ever creates an `Album`. ## Why it blocks Phase 2 Album ordering (#18) and constraint propagation (#19) are built on album context. With no ingestion path for it, both start from empty data and someone must hand-assign thousands of photos. This is far cheaper to fix before the collection is ingested than after. ## Scope - A batch ingest endpoint, or a server-side folder scan the worker drives. - Get-or-create `Album` from the parsed slug; set `sequence_in_album`. - Pair `is_back` files to the matching front photo by album and sequence, across requests — or reject them with a clear message explaining why. - Report per-file outcomes for a batch: ingested, duplicate, paired, failed, and why. - A minimal upload and progress UI, or a watch-directory documented as the intended path. ## Done when - [ ] A folder of thousands of scans can be ingested in one operation - [ ] Front and back scans pair correctly without being uploaded together - [ ] Photos land with album and sequence populated - [ ] Per-file outcomes are visible - [ ] Tests cover pairing, orphan backs, and mixed valid/invalid batches ## References - `backend/app/api/routes/ingest.py` - `backend/app/services/filename_parser.py` (output currently discarded) - `backend/app/services/ingest.py:133-149` - `docs/circa-spec.md` §8 Blocks: #18, #19.
claude-bot added this to the v0.3.0 milestone 2026-07-28 06:03:14 +00:00
Author

Scope inherited from #94 — the album filter and album-position sort in the photo browser belong here, not there.

#94 set out to build the browser filter and sort controls that docs/circa-ui-spec.md §8.3 asks for, including filter by album and sort by album position. Probing found there is nothing behind them: nothing in backend/app/ ever creates an Album row or assigns photo.album_id. Ingest does not call parse_filename at all, so the album_slug it extracts from names like redbk42_001_1983.jpg is discarded, and album_id is NULL in every deployment.

The consequences today:

  • GET /api/photos?album_id=… is a working, tested filter that matches nothing, ever.
  • ix_photo_collection_album indexes a permanently-NULL column.
  • An album dropdown built now could only ever render "no albums", and an album-position sort would order by a NULL column.

So #94 is landing without them, and this issue picks them up alongside the album linkage already in its title. What that means concretely here:

  1. Ingest wires up parse_filename and creates/links Album rows from album_slug, and sets sequence_in_album from the parsed sequence.
  2. A GET /api/albums listing, so the browser can populate the filter. New route — it will trip ROUTE_ACCESS in test_api_contract.py and the write-surface sweep in test_concurrency_protocol.py until its access level is declared, which is deliberate.
  3. The browser's album filter control and album-position sort.

On (3), a note from #94's measurements that will save re-deriving it: sorting on a nullable column breaks keyset pagination — a row-value cursor comparison against NULL matches nothing and the listing terminates at that page rather than skipping rows. sequence_in_album and album_id are both nullable, so album-position sort needs the same treatment #94 gave estimated date: a VIRTUAL generated column carrying a COALESCE(...) sentinel, indexed as a column. An index on the expression is not enough — SQLite will not seek on a row-value whose leading term is an expression, and the deep page degrades to a scan linear in the size of the archive (measured: 0.018 ms against 2.674 ms at 50,000 rows). See #94's thread for the numbers and the plan output.

Scope inherited from #94 — the **album filter and album-position sort in the photo browser** belong here, not there. #94 set out to build the browser filter and sort controls that `docs/circa-ui-spec.md` §8.3 asks for, including filter by album and sort by album position. Probing found there is nothing behind them: **nothing in `backend/app/` ever creates an `Album` row or assigns `photo.album_id`.** Ingest does not call `parse_filename` at all, so the `album_slug` it extracts from names like `redbk42_001_1983.jpg` is discarded, and `album_id` is NULL in every deployment. The consequences today: - `GET /api/photos?album_id=…` is a working, tested filter that matches nothing, ever. - `ix_photo_collection_album` indexes a permanently-NULL column. - An album dropdown built now could only ever render "no albums", and an album-position sort would order by a NULL column. So #94 is landing without them, and this issue picks them up alongside the album linkage already in its title. What that means concretely here: 1. Ingest wires up `parse_filename` and creates/links `Album` rows from `album_slug`, and sets `sequence_in_album` from the parsed sequence. 2. A `GET /api/albums` listing, so the browser can populate the filter. New route — it will trip `ROUTE_ACCESS` in `test_api_contract.py` and the write-surface sweep in `test_concurrency_protocol.py` until its access level is declared, which is deliberate. 3. The browser's album filter control and album-position sort. On (3), a note from #94's measurements that will save re-deriving it: sorting on a nullable column breaks keyset pagination — a row-value cursor comparison against NULL matches nothing and the listing **terminates** at that page rather than skipping rows. `sequence_in_album` and `album_id` are both nullable, so album-position sort needs the same treatment #94 gave estimated date: a VIRTUAL generated column carrying a `COALESCE(...)` sentinel, indexed as a column. An index on the *expression* is not enough — SQLite will not seek on a row-value whose leading term is an expression, and the deep page degrades to a scan linear in the size of the archive (measured: 0.018 ms against 2.674 ms at 50,000 rows). See #94's thread for the numbers and the plan output.
Author

Split, per @rbrooks. This issue had grown to four separable units — its own original scope plus everything #94 handed it — and was too large to pick up as one piece.

Split out:

  • #140 — Ingest never creates an Album or sets photo.album_id. The foundation, and the one with the most surprising finding: parse_filename is thorough, tested, and never called. Everything album-shaped in the application is downstream of that.
  • #141 — Albums endpoint, and the browser's album filter and album-position sort. The other half of #94's deferral. Blocked on #140, and carries forward #94's measured warning about keyset pagination over nullable columns so it does not have to be rediscovered.

Left here: the original scope this issue was filed for — bulk ingest and front/back pairing. That is genuine, separate work: pairing redbk42_001_1983.jpg with redbk42_001_1983_b.jpg is about how scans arrive, not about how they are filed, and it does not depend on either of the above.

Suggest re-titling this issue to match what it now holds, since "album linkage" has moved out of it.

Nothing has been built for any of the three yet.

Split, per @rbrooks. This issue had grown to four separable units — its own original scope plus everything #94 handed it — and was too large to pick up as one piece. **Split out:** - **#140 — Ingest never creates an `Album` or sets `photo.album_id`.** The foundation, and the one with the most surprising finding: `parse_filename` is thorough, tested, and **never called**. Everything album-shaped in the application is downstream of that. - **#141 — Albums endpoint, and the browser's album filter and album-position sort.** The other half of #94's deferral. Blocked on #140, and carries forward #94's measured warning about keyset pagination over nullable columns so it does not have to be rediscovered. **Left here:** the original scope this issue was filed for — **bulk ingest and front/back pairing**. That is genuine, separate work: pairing `redbk42_001_1983.jpg` with `redbk42_001_1983_b.jpg` is about how scans arrive, not about how they are filed, and it does not depend on either of the above. Suggest re-titling this issue to match what it now holds, since "album linkage" has moved out of it. Nothing has been built for any of the three yet.
Author

Picking this up — the last open issue on v0.3.0.

Probe first, since the body predates the split. What it says is still true of the parts it now covers, and one claim has become buildable rather than stale:

  • One photo per HTTP request: still true.
  • Front and back pair only when uploaded in the same request: still true.
  • parsed.is_back computed and discarded: still true, and services/ingest.py says so in a comment that names this issue.
  • Album linkage: done in #140/#141. album_id and sequence_in_album are populated on ingest now, which is what makes the rest of this issue tractable — docs/circa-spec.md §8.1 pairs front and back by album ID + sequence, and until last week both were NULL on every row.

Also worth recording, because it bounds the scope: ingest enqueues no jobs at all. Spec §8.3 step 5 says OCR runs on the back scan, but nothing in ingest_photo calls enqueue; OCR is driven by POST /api/photos/{id}/evidence/ocr-rerun. So attaching a back scan after the fact does not need to trigger OCR to match what ingest does today. Not fixing that here — it is a separate gap and belongs in its own issue.

Two decisions taken with @rbrooks before building:

  1. Bulk ingest is a service layer plus a CLI folder scan (python -m app.cli.ingest_folder), sitting beside backfill_albums and rebuild_projections. The HTTP batch endpoint is deferred to #142, where the browser-upload-vs-watch-directory question is still open — building it here would commit that decision by accident. The pairing and batch-outcome logic goes in the service layer so whichever transport #142 picks gets it for free, exactly as album_slug_for is shared between ingest and backfill_albums.

    A folder scan is also the only form that answers "thousands in one operation" honestly: derivatives are generated inline at roughly 300 ms per scan, so five thousand scans is a twenty-five minute operation. That is not a shape an HTTP request should have.

  2. An orphan back — a _b scan whose front is not in the archive — is ingested and flagged, not rejected. The bytes of an irreplaceable original get kept.

    That decision carries a consequence worth stating plainly, because it is the part that costs work: it needs a fold. Spec §8.2 requires incremental ingest, so the front for an orphan back will routinely arrive in a later batch. Without a fold, that produces two rows for one photograph — the orphan back and the new front — and the archive would assert two photographs where the box holds one. So a front that matches an unpaired back at the same album and sequence merges into that row rather than creating its own, which also preserves any OCR evidence already recorded against the back.

    Pairing needs both album and sequence to be non-NULL, and refuses when more than one candidate matches — an ambiguous pair is reported, not guessed at.

Picking this up — the last open issue on v0.3.0. **Probe first, since the body predates the split.** What it says is still true of the parts it now covers, and one claim has become buildable rather than stale: - One photo per HTTP request: still true. - Front and back pair only when uploaded in the same request: still true. - `parsed.is_back` computed and discarded: still true, and `services/ingest.py` says so in a comment that names this issue. - Album linkage: **done in #140/#141.** `album_id` and `sequence_in_album` are populated on ingest now, which is what makes the rest of this issue tractable — `docs/circa-spec.md` §8.1 pairs front and back *by album ID + sequence*, and until last week both were NULL on every row. Also worth recording, because it bounds the scope: **ingest enqueues no jobs at all.** Spec §8.3 step 5 says OCR runs on the back scan, but nothing in `ingest_photo` calls `enqueue`; OCR is driven by `POST /api/photos/{id}/evidence/ocr-rerun`. So attaching a back scan after the fact does not need to trigger OCR to match what ingest does today. Not fixing that here — it is a separate gap and belongs in its own issue. **Two decisions taken with @rbrooks before building:** 1. **Bulk ingest is a service layer plus a CLI folder scan** (`python -m app.cli.ingest_folder`), sitting beside `backfill_albums` and `rebuild_projections`. The HTTP batch endpoint is deferred to #142, where the browser-upload-vs-watch-directory question is still open — building it here would commit that decision by accident. The pairing and batch-outcome logic goes in the service layer so whichever transport #142 picks gets it for free, exactly as `album_slug_for` is shared between ingest and `backfill_albums`. A folder scan is also the only form that answers "thousands in one operation" honestly: derivatives are generated inline at roughly 300 ms per scan, so five thousand scans is a twenty-five minute operation. That is not a shape an HTTP request should have. 2. **An orphan back — a `_b` scan whose front is not in the archive — is ingested and flagged**, not rejected. The bytes of an irreplaceable original get kept. That decision carries a consequence worth stating plainly, because it is the part that costs work: it needs a **fold**. Spec §8.2 requires incremental ingest, so the front for an orphan back will routinely arrive in a *later* batch. Without a fold, that produces two rows for one photograph — the orphan back and the new front — and the archive would assert two photographs where the box holds one. So a front that matches an unpaired back at the same album and sequence merges *into* that row rather than creating its own, which also preserves any OCR evidence already recorded against the back. Pairing needs both album and sequence to be non-NULL, and refuses when more than one candidate matches — an ambiguous pair is reported, not guessed at.
Author

Done in e50af5d.

What landed

python -m app.cli.ingest_folder <dir> — walks a folder, orders it fronts-before-backs, commits per file, reports every file as one of ingested / already_ingested / duplicate / paired / folded / orphan_back / back_duplicate / ambiguous / skipped / failed. --dry-run, --collection, --no-recursive. Exits non-zero if anything failed.

Pairing, batching and the ledger sit in services/ingest.py and services/ingest_batch.py, not behind an endpoint, so whichever transport #142 picks inherits them.

Against the "Done when" list:

  • A folder of thousands of scans ingested in one operation
  • Front and back pair without being uploaded together
  • Photos land with album and sequence populated — already true from #140
  • Per-file outcomes visible
  • Tests cover pairing, orphan backs, and mixed valid/invalid batches

The four ways a back can arrive

Attach (front already here) writes onto that row — never a second row, because two rows for one print put the same photograph in the grid twice with nothing to say one is the reverse of the other. Orphan back (front not here) is kept, not rejected, per @rbrooks' decision: a row with no front, thumbnail generated from the back since that is the only image the archive has. Fold (front arrives later) writes onto the waiting row — this is what makes §8.2's incremental ingest actually hold, since without it a front in a later batch makes a second row. Ambiguous (more than one candidate) is never guessed: ingested unpaired and reported.

Both orders now leave the archive saying exactly the same thing. Attach and fold both read the incoming file's EXIF and both record its filename date guarded against one already on the row. An operator does not choose which half of a box the scanner reaches first. Pinned by a test that ingests the same two files in both orders into two collections and compares every evidence row's claim.

The ingest ledger

Built here rather than deferred, per @rbrooks. It records every file presented to the archive, including the ones that could not be read — ingest wrote to the audit log only on success, so an undecodable scan left no trace at all once the terminal scrolled. Same argument derivatives_error was added on (#78).

It also closes the half of resumability sha256_back did not. A re-fed front still made a duplicate row every time, so resuming a 5,000-file import that died at 3,000 would have put 3,000 rows in the reviewer's duplicate queue. Bytes cannot distinguish "the same print scanned twice" from "the same file fed in twice", so it is recorded instead of inferred. Recording rather than inferring has a second property that decided the design: it still holds when the photograph was later excluded, so re-presenting a file does not resurrect a scan somebody deliberately took out of the collection (#106) — which a check against the photo table would have done silently.

The table is explicitly not a source of truth about photographs. Where it and the photo row disagree, the photo row wins.

Verified rather than assumed

Ran the CLI against a real folder, not only the tests: three runs showing pair → orphan → fold, and a re-run reporting already_ingested for every file with no new rows. Three prints ended as three photo rows, #9 holding both sides in one row after its front arrived in a later run. That run is also what caught two things the tests did not — the ledger was recording ../.sandbox/scans/..., a path meaningless without the working directory nobody records, so the CLI now resolves to absolute; and the outcome column was one character too narrow for already_ingested.

Every new test mutation-checked — nine backend mutations and one frontend, each breaking exactly one behaviour, all caught. Backend 1376 → 1452, frontend 223 → 224, e2e 12.

Index discipline. ix_photo_sha256_back leads with the hash rather than collection_id, because migration 014 measured what a collection-led index costs when the planner reaches for it on a listing — 0.06 ms to ~250 ms, with the answers still correct so nothing above the query plan would report it. ingest_record's index may lead with collection_id: the hazard is about displacing the index carrying a listing's ORDER BY, and that table has no ordered listing. Confirmed rather than reasoned about — all 16 photo listing plans are byte-identical across revisions 015, 016 and 017.

Two things found on the way, fixed here

  • A test guarding a data-loss trap had gone vacuous. test_an_identical_upload_does_not_report_the_existing_file fed the same file twice under one name, which the ledger now short-circuits — so it passed without ever reaching the content-addressed store it exists to protect (#91). Found by instrumenting the short-circuit and asking which tests in the whole suite take it; exactly two do, and this was the one that did not mean to.
  • The end-to-end suite could not be run on Windows at all. core.autocrlf checked the runner scripts out with CRLF and the container's bash died on set -euo pipefail\r before a single test started — green in CI the whole time, because CI runs on Linux. A .gitattributes pinning *.sh to eol=lf fixes it, and the twelve journeys pass locally again.

Known limitation, stated rather than discovered later

Camera-prefixed names cannot pair. IMG_0042_1987.jpg has no album under #140's rule, so it and its _b never meet: the front ingests normally and the back becomes an orphan back. That is the right answer — those come off a memory card and have no physical back — and nothing is lost, but it is worth knowing before someone reports it as a bug.

Filed separately

  • #143 — nothing surfaces an orphan back to a reviewer. The fold self-clears the ordinary case, so what remains after an import is the genuine exceptions, which is what makes the list worth showing.
  • #144 — ingest enqueues no jobs at all, so OCR never runs on its own. Found while probing this issue; both handlers are implemented and registered and nothing starts them.
Done in e50af5d. ## What landed `python -m app.cli.ingest_folder <dir>` — walks a folder, orders it fronts-before-backs, commits per file, reports every file as one of `ingested` / `already_ingested` / `duplicate` / `paired` / `folded` / `orphan_back` / `back_duplicate` / `ambiguous` / `skipped` / `failed`. `--dry-run`, `--collection`, `--no-recursive`. Exits non-zero if anything failed. Pairing, batching and the ledger sit in `services/ingest.py` and `services/ingest_batch.py`, not behind an endpoint, so whichever transport #142 picks inherits them. **Against the "Done when" list:** - [x] A folder of thousands of scans ingested in one operation - [x] Front and back pair without being uploaded together - [x] Photos land with album and sequence populated — already true from #140 - [x] Per-file outcomes visible - [x] Tests cover pairing, orphan backs, and mixed valid/invalid batches ## The four ways a back can arrive Attach (front already here) writes onto that row — never a second row, because two rows for one print put the same photograph in the grid twice with nothing to say one is the reverse of the other. Orphan back (front not here) is **kept, not rejected**, per @rbrooks' decision: a row with no front, thumbnail generated from the back since that is the only image the archive has. Fold (front arrives later) writes onto the waiting row — this is what makes §8.2's incremental ingest actually hold, since without it a front in a later batch makes a second row. Ambiguous (more than one candidate) is never guessed: ingested unpaired and reported. **Both orders now leave the archive saying exactly the same thing.** Attach and fold both read the incoming file's EXIF and both record its filename date guarded against one already on the row. An operator does not choose which half of a box the scanner reaches first. Pinned by a test that ingests the same two files in both orders into two collections and compares every evidence row's claim. ## The ingest ledger Built here rather than deferred, per @rbrooks. It records every file presented to the archive, including the ones that could not be read — ingest wrote to the audit log only on success, so an undecodable scan left no trace at all once the terminal scrolled. Same argument `derivatives_error` was added on (#78). It also closes the half of resumability `sha256_back` did not. A re-fed **front** still made a duplicate row every time, so resuming a 5,000-file import that died at 3,000 would have put 3,000 rows in the reviewer's duplicate queue. Bytes cannot distinguish "the same print scanned twice" from "the same file fed in twice", so it is recorded instead of inferred. Recording rather than inferring has a second property that decided the design: **it still holds when the photograph was later excluded**, so re-presenting a file does not resurrect a scan somebody deliberately took out of the collection (#106) — which a check against the `photo` table would have done silently. The table is explicitly not a source of truth about photographs. Where it and the `photo` row disagree, the `photo` row wins. ## Verified rather than assumed Ran the CLI against a real folder, not only the tests: three runs showing pair → orphan → fold, and a re-run reporting `already_ingested` for every file with no new rows. Three prints ended as **three** photo rows, #9 holding both sides in one row after its front arrived in a later run. That run is also what caught two things the tests did not — the ledger was recording `../.sandbox/scans/...`, a path meaningless without the working directory nobody records, so the CLI now resolves to absolute; and the outcome column was one character too narrow for `already_ingested`. **Every new test mutation-checked** — nine backend mutations and one frontend, each breaking exactly one behaviour, all caught. Backend 1376 → 1452, frontend 223 → 224, e2e 12. **Index discipline.** `ix_photo_sha256_back` leads with the hash rather than `collection_id`, because migration 014 measured what a collection-led index costs when the planner reaches for it on a listing — 0.06 ms to ~250 ms, with the answers still correct so nothing above the query plan would report it. `ingest_record`'s index *may* lead with `collection_id`: the hazard is about displacing the index carrying a listing's ORDER BY, and that table has no ordered listing. Confirmed rather than reasoned about — all 16 `photo` listing plans are byte-identical across revisions 015, 016 and 017. ## Two things found on the way, fixed here - **A test guarding a data-loss trap had gone vacuous.** `test_an_identical_upload_does_not_report_the_existing_file` fed the same file twice under one name, which the ledger now short-circuits — so it passed without ever reaching the content-addressed store it exists to protect (#91). Found by instrumenting the short-circuit and asking which tests in the whole suite take it; exactly two do, and this was the one that did not mean to. - **The end-to-end suite could not be run on Windows at all.** `core.autocrlf` checked the runner scripts out with CRLF and the container's bash died on `set -euo pipefail\r` before a single test started — green in CI the whole time, because CI runs on Linux. A `.gitattributes` pinning `*.sh` to `eol=lf` fixes it, and the twelve journeys pass locally again. ## Known limitation, stated rather than discovered later **Camera-prefixed names cannot pair.** `IMG_0042_1987.jpg` has no album under #140's rule, so it and its `_b` never meet: the front ingests normally and the back becomes an orphan back. That is the right answer — those come off a memory card and have no physical back — and nothing is lost, but it is worth knowing before someone reports it as a bug. ## Filed separately - #143 — nothing surfaces an orphan back to a reviewer. The fold self-clears the ordinary case, so what remains after an import is the genuine exceptions, which is what makes the list worth showing. - #144 — ingest enqueues no jobs at all, so OCR never runs on its own. Found while probing this issue; both handlers are implemented and registered and nothing starts them.
Sign in to join this conversation.
No description provided.