- Python 73.4%
- TypeScript 23.7%
- CSS 1.8%
- Dockerfile 0.5%
- JavaScript 0.3%
- Other 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| backend | ||
| docs | ||
| e2e | ||
| frontend | ||
| .gitattributes | ||
| .gitignore | ||
| AGENTS.md | ||
| CHANGELOG.md | ||
| LICENSE | ||
| README.md | ||
| renovate.json | ||
Circa
A web-based tool for digitizing, dating, and organizing a large collection of family photographs.
Status
Phase 1 is implemented and running. The core review workflow is functional. Remaining Phase 1 gaps — the background worker that executes AI/OCR rerun jobs, the AI and OCR backends themselves, and the automated test suite — are tracked under the v0.2.0 milestone.
Stack
- Backend: Python 3.11+ · FastAPI · SQLAlchemy 2 · Alembic · SQLite (WAL mode)
- Frontend: React 19 · TypeScript · Vite · TanStack Query · React Router v7
- Auth: OpenID Connect via Authlib — any OIDC provider via discovery (Authentik, Keycloak, Google) · itsdangerous signed session cookies
- Storage: Local filesystem (content-addressed by SHA-256)
Repo Layout
backend/
app/
api/routes/ — HTTP endpoints (auth, photos, jobs, ingest, health)
auth/ — OAuth client, session helpers
db/ — SQLAlchemy engine and session
models/ — ORM models and enums
repositories/ — DB access layer
services/ — business logic (ingest, projections, storage, parsers)
workers/ — background worker: runner, handler registry, handlers
alembic/versions/ — database migrations
frontend/
src/
api/ — typed fetch client
components/ — AppShell
hooks/ — useAuth
pages/ — Dashboard, PhotoBrowser, ReviewWorkspace, Jobs, Login
types/ — shared TypeScript types
docs/ — product, implementation, UI, wireframe, API, and planning docs
Running Locally
Backend
cd backend
python -m venv .venv
.venv/bin/pip install -e ".[dev]"
.venv/bin/python -m alembic upgrade head
.venv/bin/uvicorn app.main:app --reload
The backend starts at http://localhost:8000.
Configuration — copy .env.example to .env or set environment variables with the CIRCA_ prefix:
| Variable | Default | Description |
|---|---|---|
CIRCA_ENVIRONMENT |
production |
development or production. Defaults to production so an unconfigured deployment refuses to start rather than running insecurely — set development locally |
CIRCA_DATABASE_URL |
sqlite:///./circa.db |
SQLAlchemy DB URL |
CIRCA_SECRET_KEY |
change-me-in-production |
Session signing key. Development-only default — refused in production |
CIRCA_DEV_LOGIN_ENABLED |
false |
Development-only login bypass. Refused in production |
CIRCA_LOG_LEVEL |
INFO |
DEBUG/INFO/WARNING/ERROR/CRITICAL |
CIRCA_LOG_FORMAT |
json |
json for shipping, text for a terminal |
CIRCA_SQLITE_ECHO |
false |
Log every SQL statement. Off by default because the statements include note and comment bodies |
CIRCA_OAUTH_PROVIDER |
oidc |
Provider mode. oidc for any OIDC provider; google enables the Google discovery fallback |
CIRCA_OAUTH_SERVER_METADATA_URL |
(empty) | Required for oidc. The provider's OpenID Connect discovery endpoint |
CIRCA_OAUTH_CLIENT_ID |
(empty) | OIDC client ID |
CIRCA_OAUTH_CLIENT_SECRET |
(empty) | OIDC client secret |
CIRCA_OAUTH_REDIRECT_URI |
http://localhost:8000/api/auth/callback |
OIDC callback URL |
CIRCA_OIDC_ADMIN_GROUP |
(empty) | Provider group granting admin. Required in production |
CIRCA_OIDC_REVIEWER_GROUP |
(empty) | Provider group granting reviewer |
CIRCA_OIDC_GROUPS_CLAIM |
groups |
The claim carrying group names |
CIRCA_REQUIRE_VERIFIED_EMAIL |
false |
Refuse a login whose email_verified claim is not truthy |
CIRCA_STORAGE_LOCAL_ROOT |
./storage |
Directory for stored photo files |
CIRCA_SQLITE_SYNCHRONOUS |
NORMAL |
SQLite durability level. NORMAL is the standard pairing with WAL |
CIRCA_SQLITE_BUSY_TIMEOUT_MS |
10000 |
How long a writer waits for the write lock before failing |
CIRCA_SQLITE_STRICT_WRITE_INTENT |
(auto) | Raise on a write outside a write transaction. On outside production |
Set CIRCA_OAUTH_SERVER_METADATA_URL to your provider's discovery endpoint — for Authentik that is https://auth.example.com/application/o/<app-slug>/.well-known/openid-configuration. Leaving it empty raises at startup unless CIRCA_OAUTH_PROVIDER=google, which falls back to Google's well-known URL.
Startup refuses an insecure production configuration. With CIRCA_ENVIRONMENT=production (the default), the app will not start if CIRCA_SECRET_KEY is unset, still the default above, or shorter than 32 characters; if the OAuth client id or secret is missing; if CIRCA_OAUTH_REDIRECT_URI is not https; if CIRCA_OIDC_ADMIN_GROUP is unset; or if CIRCA_DEV_LOGIN_ENABLED is on. It reports every problem at once, names each variable, and does not warn-and-continue — that key signs the session cookies, and a warning in a log nobody reads is how a published default reaches production.
For local work set CIRCA_ENVIRONMENT=development, which permits all of the above deliberately and logs one line saying which concessions are in play.
Signing in locally without OAuth. Set CIRCA_DEV_LOGIN_ENABLED=true (development only) and the sign-in page grows a clearly labelled development panel: enter any email, pick a role, and you get a real session. It goes through the same session code the OAuth callback uses, so what you are holding afterwards is indistinguishable from a genuine login — including revocation and expiry — which is the point, since the end-to-end tests walk the reviewer journey with it. Every use is logged at warning level with the identity. The flag is off by default and a production deployment with it on refuses to start.
Access control comes from the identity provider. Who may authenticate is your provider's policy binding on the Circa application, and authenticating successfully is sufficient for a viewer account. The role is read from the groups claim on every login: a member of CIRCA_OIDC_ADMIN_GROUP is an admin, a member of CIRCA_OIDC_REVIEWER_GROUP is a reviewer, everyone else is a viewer. Removal from a group demotes at the next sign-in, which is what makes "manage it in the IdP" true in both directions.
Bind the Circa application to a group in your provider before you rely on this — otherwise "can authenticate" does mean "can read the photo archive." Because the provider is authoritative, PATCH /api/users/{id} refuses role changes while a group is configured rather than accepting a write the next login would overwrite; deactivating an account still works, since the provider does not set that.
An unset CIRCA_OIDC_ADMIN_GROUP refuses to start in production: with the role coming from groups and nothing else, there would be no route to an administrator at all. If the groups claim is missing from the token the callback logs a warning naming the claim it looked for — that case makes everyone a viewer including the intended admin, and it is not the same as a user who is genuinely in no groups.
Database concurrency. SQLite allows one writer at a time, so Circa declares write intent up front: a request with an unsafe HTTP method gets a session whose transactions BEGIN IMMEDIATE, and reads stay deferred so they neither wait for the write lock nor hold it. This is what lets a background worker write alongside the API — a deferred transaction that reads and then writes fails immediately with SQLITE_BUSY_SNAPSHOT if anyone commits in between, and no busy timeout can retry that. Code that writes without a request behind it (workers, the CLI tools below) must use app.db.session.write_session(). Outside production, writing on a deferred transaction raises rather than waiting to become a race.
Job handlers must never hold a write transaction across a network call: do the slow work first, then open a short transaction to record the result. A handler that calls an AI provider inside its transaction holds the write lock for the whole call and turns concurrent reviewer saves into timeouts.
Display derivatives
Ingest builds a thumbnail (400px) and a review-size image (2048px) for every scan, in the sandboxed subprocess Pillow already runs in. The grid serves /media/thumb, the workspace asks for ?variant=review, and a bare /media/front still returns the untouched original — the archival bytes are the default and the optimisation is opt-in.
Photographs ingested before this existed have no derivatives. Queue them:
cd backend
.venv/bin/python -m app.cli.backfill_derivatives --dry-run
.venv/bin/python -m app.cli.backfill_derivatives
.venv/bin/python -m app.workers.runner # builds them
Until the backfill runs the grid falls back to serving originals, so nothing is broken — just slower than it should be.
Adding photographs
The browser is the way in (#142). Sign in as a reviewer or an admin, click Add photos in the rail, and drop a folder of scans onto the page or choose them with a button. Files go up one at a time, and the page says what happened to each one — added, already here, or needing a look. Re-adding the same folder is safe and adds nothing twice, so an interrupted run is finished by doing it again.
This is the intended path, not a convenience: the people doing the scanning have no shell on the server. POST /api/ingest takes one file per request, which is what gives per-file outcomes and resumability; there is no batch endpoint and no watch directory, and neither is planned.
The folder scan below stays for whoever does have a shell — a one-off import of a directory already sitting on the server, and the only sensible way to move tens of thousands of files.
Bulk ingest from the command line
A whole folder of scans goes in with one command, front and back scans pairing themselves by album and sequence:
cd backend
.venv/bin/python -m app.cli.ingest_folder ~/scans/redbook --dry-run
.venv/bin/python -m app.cli.ingest_folder ~/scans/redbook
It recurses by default (--no-recursive for the top level only), reports every file it found, and exits non-zero if any failed. Files are committed one at a time, so a run that dies partway keeps what it landed and a re-run picks up where it stopped — every file already in is reported already_ingested and nothing is written twice.
A _b scan attaches to the front already in the archive. If the front has not been ingested yet the back is still kept, as a photograph record with no front side, and the front folds onto that same row whenever it arrives — so a box scanned over two sessions ends up with one row per print regardless of which half arrived first. Where more than one candidate matches, nothing is guessed: the file is ingested unpaired and reported.
Every scan that lands a back side queues an OCR job for it, so the handwriting on the reverse is read without anyone asking per photograph. The reading is done by the worker, not by the import — so python -m app.workers.runner (below) has to be running, alongside the import or after it. Until it has, a folder of backs shows no OCR evidence, which looks exactly like OCR being broken. AI analysis is deliberately not queued by ingest: it is a paid call with a daily ceiling, and an import that spent it would permanently fail every job past the limit. Reruns of both stay a per-photograph decision in the review workspace.
Two things worth knowing before a large import. Derivatives are generated inline, in a subprocess, at roughly 300 ms a scan, and that happens inside the write transaction — so a long run holds SQLite's single write lock in short bursts back to back, and a reviewer saving through the web UI will queue behind whichever file is mid-write. Run a big import when nobody is reviewing. And --dry-run still reads every byte of every file, because predicting duplicates means hashing them.
End-to-end tests
The reviewer journey is walked in a real browser against a real backend — e2e/, Playwright, headless Chromium. It signs in through the development bypass, ingests a photograph through POST /api/ingest, browses and filters, opens the photo, comments, edits notes, approves, and queues a rerun; a second suite puts two reviewers on one photograph and asserts that the stale save is refused, explained, and does not overwrite; a third adds two scans through the Add photos page and checks the collection holds them afterwards.
cd e2e
npm ci
npx playwright test
Playwright starts the backend and a vite preview of the built bundle itself, on their own ports, against a temporary database and storage root that are removed afterwards — so a run touches neither your database nor backend/storage/. It needs Python, Node and a browser in one place; on a machine without all three, ./e2e/run-in-docker.sh runs the same suite in a container. CI runs it on every push.
Worker
Background jobs — AI analysis, OCR — are enqueued by the API and, for OCR on a
back scan, by ingest itself, then executed by a separate process. Without it
running, a rerun sits in queued and nothing happens — and so does the reading
of every back a bulk import has just landed.
cd backend
python -m app.workers.runner # or: circa-worker
It polls for the oldest claimable job, runs it, and records the outcome. Stop it
with Ctrl-C or SIGTERM: the job in hand runs to completion and is recorded,
then the process exits. Killing it harder is safe but not free — the job it was
running stays running until its lease lapses (60 s by default), at which point
any worker takes it back and charges one of its retries.
More than one worker may run at once; they will not execute the same job. Useful flags:
| Flag | Effect |
|---|---|
--once |
Claim and run at most one job, then exit. Exits non-zero if the job did not succeed |
--max-jobs N |
Exit after N jobs |
-v |
Debug logging |
| Variable | Default | Description |
|---|---|---|
CIRCA_WORKER_POLL_SECONDS |
1.0 |
How long an idle worker waits before asking for work again |
CIRCA_WORKER_LEASE_SECONDS |
60 |
How long a claim is good for, and so how long a crashed worker's job stays stuck |
CIRCA_WORKER_RETRY_BACKOFF_SECONDS |
30 |
First retry delay; doubles per attempt |
CIRCA_WORKER_RETRY_BACKOFF_MAX_SECONDS |
900 |
Cap on that delay |
A job that fails is retried up to max_retries (3 by default, so four attempts)
with a growing delay, then lands in failed with the error on the Jobs page. A
failure that retrying cannot fix — a photo that no longer exists, a job type with
no handler — fails immediately instead of spending the budget to arrive at the
same answer.
OCR needs Tesseract, a system package rather than a Python one — the backend
calls the binary directly, so pip install will not provide it:
sudo apt-get install tesseract-ocr tesseract-ocr-eng # Debian/Ubuntu
brew install tesseract # macOS
winget install UB-Mannheim.TesseractOCR # Windows
Only the machine running the worker needs it. Without it, CIRCA_OCR_BACKEND
defaults to auto and OCR jobs fail with a message saying the engine is missing,
rather than the worker refusing to start — everything else keeps working.
| Variable | Default | Description |
|---|---|---|
CIRCA_OCR_BACKEND |
auto |
auto uses Tesseract when present, tesseract insists on it, disabled refuses OCR jobs |
CIRCA_OCR_TESSERACT_BINARY |
tesseract |
Path or name of the executable |
CIRCA_OCR_LANGUAGE |
eng |
Tesseract language data; needs the matching tesseract-ocr-<lang> package |
CIRCA_OCR_TIMEOUT_SECONDS |
120 |
Wall clock per image |
AI dating estimates cost money. The ai_analysis handler sends the front
scan to a vision model and records the estimate as evidence. It is off unless an
API key is configured, and it refuses to run once a spend ceiling is reached.
| Variable | Default | Description |
|---|---|---|
CIRCA_AI_BACKEND |
auto |
auto uses Claude when a key is present, mock is a free deterministic backend, disabled refuses AI jobs |
CIRCA_AI_API_KEY |
(empty) | Falls back to ANTHROPIC_API_KEY |
CIRCA_AI_MODEL |
claude-opus-5 |
|
CIRCA_AI_DAILY_BUDGET_USD |
5.00 |
0 means unlimited |
CIRCA_AI_MONTHLY_BUDGET_USD |
30.00 |
0 means unlimited |
CIRCA_AI_INPUT_PRICE_PER_MTOK |
5.00 |
Must match the model's actual price, or the budget is computed from the wrong numbers |
CIRCA_AI_OUTPUT_PRICE_PER_MTOK |
25.00 |
|
CIRCA_AI_CONFIDENCE_THRESHOLD |
0.6 |
At or above this the evidence is medium and queues the photo for review; below it, low |
Every call is written to api_usage_log — succeeded or not, because the money
is spent either way — and the budget is a sum over that table for the current
UTC day and month. A job that would exceed a ceiling fails with a message
saying which one and when it resets, rather than silently skipping.
Set CIRCA_AI_BACKEND=mock to exercise the whole path — job, evidence,
projection, review UI — without spending anything.
Before the first real run, do one photo rather than a batch:
CIRCA_AI_API_KEY=sk-... python -m app.workers.runner --once
Then check the estimate on that photo and the row in api_usage_log before
turning the worker loose.
Frontend
cd frontend
npm install
npm run dev
The frontend starts at http://localhost:5173 and proxies /api to http://localhost:8000.
Deployment
Two images are built and pushed by CI on a version tag (v*):
| Image | Contents |
|---|---|
git.rhoving.com/rbrooks/circa-backend |
The FastAPI application, Alembic, and Tesseract |
git.rhoving.com/rbrooks/circa-frontend |
The built Vite bundle, served by nginx, with /api proxied to the backend |
The compose file that runs them lives in iac-repo (circa-ansible), not here —
this repository builds the images and says how they expect to be run.
Three containers, two images
The worker is a separate process from the API, and it is the same image.
circa-backend is started twice: once as the API and once as the worker, with
different commands.
# API
docker run … git.rhoving.com/rbrooks/circa-backend # default CMD: uvicorn
# Worker
docker run … git.rhoving.com/rbrooks/circa-backend python -m app.workers.runner
Without the worker container, OCR and AI jobs sit in queued and nothing
happens — including the reading of every back scan an import has just landed
(see Worker above). The circa-worker console script from pyproject.toml
is deliberately not installed in the image; python -m app.workers.runner is
the same entry point and needs no packaging step.
The API container should carry the health probe; the worker listens on nothing, so no probe is baked into the image — one that was would mark a perfectly healthy worker unhealthy forever. For the API service:
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=3).status == 200 else 1)"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
GET /api/health is exempt from rate limiting and needs no session.
Migrations are an explicit step
The image does not migrate at startup. Two processes start from it, so an implicit migration on boot is a race between them, and a rollback then has to undo a schema change nobody asked for. Run it as a one-off against the same image, before starting the new containers:
docker compose run --rm api alembic upgrade head
alembic is on PATH, the working directory holds alembic.ini, and the
application is importable — so the command is exactly what it is in a checkout.
The current head is 017.
OCR needs Tesseract, and it is in the image
tesseract-ocr and tesseract-ocr-eng are installed in circa-backend, and
they must stay there. Ingest queues an ocr job for every back scan, and
CIRCA_OCR_BACKEND=auto reacts to a missing binary by failing that job, not
by refusing to start. An image built without it therefore looks entirely
healthy — the API answers, the worker runs, the dashboard loads — while every
back scan's reading fails permanently with "No OCR engine found", and the back
of a print is where a date is most often written down. The failure is per job
and silent; there is no crash to notice.
A collection in another language needs the matching tesseract-ocr-<lang>
package added to backend/Dockerfile as well as CIRCA_OCR_LANGUAGE set.
Volumes, and the uid they expect
Two mount points, and they are not equivalent — neither is reproducible:
| Path | Holds | Default |
|---|---|---|
/data |
The SQLite database: every dating decision, every piece of evidence, the whole audit ledger | CIRCA_DATABASE_URL=sqlite:////data/circa.db |
/storage |
The scans themselves, content-addressed. Not replaceable by rescanning | CIRCA_STORAGE_LOCAL_ROOT=/storage |
Neither is declared as a VOLUME in the image, on purpose: an anonymous volume
conjured for an operator who forgot to mount one is a place data goes to be lost
quietly.
The container runs as uid 10001, gid 10001 (user circa). The entrypoint
starts as root, claims the two mount points for that uid — non-recursively, so
it does not walk an archive of tens of thousands of scans on every start — and
then drops privileges with gosu. So a bind mount only needs
chown 10001:10001 /srv/circa/data /srv/circa/storage
and a named volume needs nothing at all. Two cases need more:
- Overriding the uid. Setting
user: "1001:1001"in compose (as thecarriagerole does, to keep host files owned by a service account) makes the entrypoint skip the handover entirely and exec the command as given — it logs one line saying so. The mounted directories must then already be writable by that uid. - An archive written by some other uid — a restored backup, or a volume from
a deployment that ran as root. Set
CIRCA_CHOWN_RECURSIVE=1for one start to take ownership of everything under both paths, then remove it: it is O(archive) and should be a decision, not a cost paid on every boot.
Configuration
The backend refuses to start on an insecure production configuration (#14) and
says every reason at once. At minimum it needs CIRCA_SECRET_KEY (≥32
characters), CIRCA_OAUTH_CLIENT_ID, CIRCA_OAUTH_CLIENT_SECRET, an https
CIRCA_OAUTH_REDIRECT_URI and an https CIRCA_OAUTH_SERVER_METADATA_URL, with
CIRCA_DEV_LOGIN_ENABLED off. See the table under Backend above; the same
variables apply to the API and the worker, which share a database and a storage
root and so must be configured identically.
Behind a reverse proxy, set CIRCA_TRUST_PROXY_HEADERS=true — otherwise every
request appears to come from the proxy's address and the per-address rate limit
becomes a global one. Only with a proxy in front that overwrites
X-Forwarded-For, never without.
The frontend image takes two variables of its own:
| Variable | Default | Description |
|---|---|---|
CIRCA_BACKEND_ORIGIN |
http://backend:8000 |
Where /api is proxied |
CIRCA_RESOLVER |
127.0.0.11 |
DNS for that name, re-consulted at request time so replacing the backend container does not leave nginx 502ing against a cached address. 127.0.0.11 is Docker's embedded resolver and exists on a compose network; it does not on --network host |
Phase 1 Capabilities
- Ingest: Upload photos via
POST /api/ingest; exact duplicate detection by SHA-256; filename and EXIF date evidence extracted automatically. A structured filename also files the scan into an album —redbk42_001_1983.jpgcreates or reuses albumredbk42and records the photograph's position in it. Filing is deliberately conservative: a name must carry a sequence or a date past the slug, the slug must not be the whole filename, and camera prefixes (IMG_,DSC_, …) are not albums, because an unfiled scan is easy to revisit and an invented album has to be un-invented by hand.python -m app.cli.backfill_albumsre-derives filing for photographs ingested before this existed (--dry-runsupported; it never overwrites an album somebody set). The Add photos page is how a reviewer puts scans in from a browser, one request per file; a whole folder goes in at once from a shell withpython -m app.cli.ingest_folder(see Adding photographs above), which also pairs_bback scans to their fronts by album and sequence — across separate runs, not just within one upload. Every file presented to the archive is recorded in an ingest ledger, including the ones that could not be read, so a re-run writes nothing twice and an unreadable original is a fact the archive can still state a year later rather than a line that scrolled off a terminal - Browse: Virtualized photo grid over the whole collection — scrolls past the first page by following
meta.next_cursor, and remembers where a reviewer was when they come back from a photograph. Filter by status, album, date range, and the undated / rescan-requested / missing-page / duplicate flags, or switch to the excluded-only view; sort by ingest order, estimated date, or album position. The album dropdown lists each album with the number of photographs the grid will actually show, and is absent entirely from a collection whose filenames carry no album structure. Active filters are shown as chips with one-click removal and a clear-all. Confidence filtering and sort wait on confidence scoring - Review workspace: Large image view with front/back toggle; evidence panel, with each row's note shown beneath it and retired rows labelled as superseded rather than merely dimmed; decision form with optimistic concurrency (409 conflict detection); inline notes editing; comments; history tab; AI/OCR rerun job enqueueing; rescan flag
- Attribution: Comments, decision history, note revisions and hand-recorded evidence are signed with the reviewer's display name, never a user id — the session cookie carries a user id, so handing one to a client hands out the material a forged cookie is built from. Nothing reviewer-facing renders a raw identifier: a duplicate names and links to the scan it duplicates, and the Jobs page links to the photograph by filename
- Review panel: Evidence sits directly above the decision form it populates, with the decision controls docked to the panel bottom so Approve does not move as the evidence count changes; notes are a tab beside comments and history. Self-hosted Source Sans 3 and Source Serif Pro (49.1 KB, OFL), the serif reserved for dates so the effective date reads as the panel's most important value; tabular figures throughout, a real heading outline, keyboard-operable buttons and links with visible focus, and AA contrast verified by measurement
- Adopt an evidence date: Click or keyboard-activate a row in the evidence panel to fill the decision form with its dates and precision, instead of retyping what is displayed two rows above. 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; editing a value by hand clears the link, and a row with no parsed date is not activatable
- Review queue: The browser's filter and sort become a queue the workspace walks — Prev/Next without returning to the grid, a
N leftcount, auto-advance after a decision (on by default, remembered when switched off), and the next photograph's data and image prefetched. The queue is a frozen list of ids, so a photograph you have just approved does not vanish and renumber everything behind it; it is persisted, so closing the browser and returning resumes where you were - Jobs: Background job queue visible in the Jobs page (auto-refreshes), executed by the worker process above — claims are leased, failures retried with backoff, and a worker that dies has its jobs taken back
- Exclude a scan that is not a photograph: A box of scans contains blank pages, the scanner lid, envelopes, album covers and accidental re-scans; none can be dated, so without this each one sits in the review queue forever. "Not a photograph…" in the review workspace takes one out in two clicks, recording why from a short list of reasons. Excluded scans leave the grid, the review queues and the status counts, and appear as their own dashboard card and an "Excluded only" view. Nothing is destroyed: it is a soft delete (
deleted_at, per spec §6.4), the media stays served, the review status is kept rather than overwritten — so putting a photograph back returns it to exactly the state it was in — and both excluding and restoring are recorded in the audit ledger with actor and reason - Record evidence by hand: "Edit evidence…" in the evidence panel opens a dialog for adding what a person knows — a date range, a precision, and the sentence it came from ("Mom wrote 1972 on the back") — as
manualevidence that sits beside the OCR and EXIF readings and can be weighed against them, rather than as prose in one decision's rationale. Existing evidence can be replaced, which retires the old row and records the new one in a single transaction so the photograph is never left with neither or both, or retired on its own when there is nothing to put in its place. Retired rows stay visible and are labelled as superseded: they are part of how the date was arrived at - Auth: OpenID Connect login against any provider supporting discovery; first-admin bootstrap via env var; allowlist-gated account creation; sign out from the nav rail, which revokes the session server-side and empties the client cache so nothing of one reviewer's work survives into the next person's session on a shared machine
Phase 1 Explicit Non-Goals
- Near-duplicate resolution UI
- Album drag reorder
- Backup/restore/export flows
- Full admin console
- Event anchor UI
Docs
docs/circa-spec.md— product specdocs/circa-implementation-spec.md— implementation guidancedocs/circa-wireframes.md— UI wireframesdocs/circa-api-spec.md— API spec
Work Tracking
Current and future work is tracked in Forgejo, not in this repository:
- Issues
- Milestones — semantic versions mapped to the phase roadmap in
docs/circa-spec.mdsection 14