• v0.3.0 35f9d8b4fe

    v0.3.0 — Phase 2 review ergonomics
    All checks were successful
    CI / frontend (push) Successful in 2m7s
    CI / e2e (push) Successful in 2m56s
    CI / backend (push) Successful in 36m15s
    Stable

    claude-bot released this 2026-08-05 18:23:08 +00:00 | 32 commits to main since this release

    The reviewer's screen, and the front door that fills it. All 15 issues on the milestone are closed — the first milestone this project has finished as scoped.

    Exit criteria met: a full decision costs one or two interactions instead of seven, the browser shows photographs rather than placeholders and can reach every one of them, and a reviewer can resume where they left off.

    1452 backend tests · 224 component tests · 12 end-to-end journeys, up from 1109 / 58 / 7 at v0.2.0.

    The reviewer's screen

    It was showing camera emoji. The grid rendered a placeholder for every photograph, because no derivative had ever been built. Ingest now builds a thumbnail and a review-size image for every scan, and a bare /media/front still returns the untouched original — the archival bytes stay the default and the optimisation is opt-in.

    It could not reach past the first page. Keyset pagination now follows a cursor through the whole collection, including sorts over columns that are NULL for exactly the photographs this application exists to work on — an undated photo at a page boundary used to end the listing silently, looking complete, with the rest of the archive unreachable.

    A decision cost seven interactions. Evidence sits directly above the form it populates; clicking a row fills the form with its dates and precision, and the decision records which evidence it was adopted from as a foreign key rather than as prose, so the history can say what a date was based on. The queue the browser's filter defines is now one the workspace walks — prev/next, a remaining count, auto-advance, and the next photograph prefetched.

    The queue never emptied. A scanner produces a file for every pass, so a box of prints arrives with the lid closed on an empty bed, the envelope, and the album cover. None is a photograph, none can be dated, and needs_review is where each one sat forever. They can now be excluded — a soft delete, recoverable, with the reason recorded.

    Also: zoom and pan for reading a pencilled date on the back of a print, a conflict state that explains itself instead of refusing a save silently, superseded evidence marked and made inert, display names wherever a raw UUID used to be shown, a logout, and an accessibility pass over the controls.

    The front door

    Nothing had ever created an album. parse_filename extracted an album slug on every ingest and the result was discarded, so album_id was NULL in every deployment — GET /api/photos?album_id=… was a working, tested filter that matched nothing, ever. Ingest now files a scan into its album, conservatively: a name must carry structure past the slug, and camera prefixes are not albums, because an unfiled scan is easy to revisit and an invented album has to be un-invented by hand. The browser reads the collection by album and in album order.

    Ingest took one photograph per HTTP request. A folder now goes in at once, with each print's two sides paired by album and sequence — across separate runs, not only within a single upload. A back whose front is already here is written onto that row rather than becoming a second one. A back whose front is not here is kept rather than rejected, and folds into its front whenever that arrives, so a box scanned over two sessions still ends with one row per print.

    Every file presented to the archive is now recorded, including the ones that could not be read. Ingest wrote to the audit log only on success, so an undecodable scan left no trace once the terminal scrolled. "Box 12 had four files nobody could ever read" is the kind of fact an archive of irreplaceable objects has to be able to state a year later.

    Under it

    Four index regressions were measured rather than assumed, after one earlier attempt sent every listing from 0.06 ms to roughly 250 ms with the answers still correct — the kind of fault nothing above the query plan would ever report. The rule that came out of it, written into three migrations: an index must carry the order a query asks for, not merely the predicate it filters on.

    Full detail in CHANGELOG.md.

    Downloads
  • v0.2.0 c5499728be

    v0.2.0 — Phase 1 completion
    All checks were successful
    CI / frontend (push) Successful in 1m18s
    CI / e2e (push) Successful in 2m27s
    CI / backend (push) Successful in 12m59s
    Stable

    claude-bot released this 2026-08-03 18:34:16 +00:00 | 50 commits to main since this release

    Phase 1 is finished and verified. All 34 issues on the milestone are closed.

    Exit criteria met: every Phase 1 acceptance criterion is verified by an automated test, projections are provably derivable from history, and no known silently-wrong-data path remains.

    1109 backend tests · 58 component tests · 7 end-to-end journeys.

    This tag also carries the v0.1.1 security milestone, which closed on 2026-07-30 and was never tagged separately — the unauthenticated-to-RCE chain, revocable sessions, the Pillow sandbox, rate limiting, and the audit ledger's append-only triggers. The changelog splits the two.

    What landed

    Things that had nothing behind them. ai-rerun and ocr-rerun created jobs from v0.1.0 onward and the Jobs page rendered them, but app/workers/ held only an __init__.py — nothing ever claimed a job. The worker runtime, the Claude vision backend with its cost ledger and spend ceilings, and Tesseract OCR now make those buttons do something.

    Bugs that were writing wrong data. Scanner timestamps recorded as capture dates. A review_version guard that raced. Pagination that silently dropped photographs from a bulk-ingested collection. A filename parser that read a year as a sequence number and accepted 3999-01-01 as a day-precision date at medium reliability — which is what puts a photo in front of a reviewer.

    Silent-failure paths closed. patchNotes and patchFlags had no error handling at all, so a refused save left the reviewer's text on screen with nothing to say it had not been written. A failed evidence fetch rendered as "No evidence yet." — a claim about the archive that nothing had established.

    The contract. Every route returned an untyped dict; the frontend's types were hand-maintained and had drifted twice. Now Pydantic response models end to end, with openapi.json and the generated TypeScript regenerated and diffed in CI.

    Security. Production now refuses to start on an insecure configuration, and CIRCA_ENVIRONMENT defaults to production so an unconfigured deployment fails closed rather than serving family photographs behind a cookie signed with the key printed in the README. Storage keys gained a containment check. A development login bypass exists, with three independent guards keeping it out of production.

    Performance, measured. The photo list went from 15.2 ms to 0.150 ms with the right index. Media was never cacheable — every revisit re-sent a multi-megabyte original. Ingest was the only async data handler in the codebase, blocking the event loop on every hash and copy.

    Tests. 1109 of them, including an isolation failure they were hiding: 257 scan files had accumulated in the real backend/storage/ because three suites called ingest_photo() without redirecting storage. Nothing had failed.

    Full detail in CHANGELOG.md.

    Downloads