# Architecture Millennium is a modular Django monolith that serves a JSON API, plus a Next.js single-page frontend that consumes it. This document covers the runtime topology, the data model, the scheduled jobs, the deploy flow, and the system invariants that the code refers to by number. ## Overview - **Backend:** Django 5.2 + Django REST Framework, organized as apps under `backend/apps/`: `core`, `cards`, `pricing`, `portfolio`, `collection`, `imports`, `valuation`, `alerts`, `decks`, `status`, `audit`. Each app owns its models, serializers, viewsets, and any sync or evaluation logic. - **Frontend:** Next.js (App Router) + React + TypeScript under `frontend/`. It talks to the backend through a same-origin `/api/*` proxy so the browser sees one origin and cookies stay first-party. - **API contract:** drf-spectacular emits an OpenAPI schema; `@hey-api/openapi-ts` generates a typed TypeScript client from it. Both the schema (`frontend/openapi.json`) and the generated client are committed, and CI fails if either drifts from the backend serializers. ## Runtime topology **Development** (`make dev`, Docker Compose in `infra/docker-compose.yml`): Postgres 16, Redis, the Django backend (`runserver`), a Celery worker, Celery beat, and the Next.js dev server. Postgres, Redis, and the backend bind to `127.0.0.1` only. **Production** (config-as-code under `infra/hetzner/`): a standalone Caddy edge is the only public listener. It terminates TLS (automatic certificates) and reverse-proxies to the Next.js standalone frontend, which in turn proxies `/api/*` to a gunicorn backend. Postgres runs alongside them. Nothing except the Caddy edge is published to the public interface; the backend also listens on `127.0.0.1:8001` for an SSH-tunnelled `/admin/`. Production does not run Redis or a Celery worker: the cache is Django's database cache on Postgres, and the daily jobs run as systemd timers instead of beat. ## Data model A three-level card hierarchy with per-acquisition cost basis: - **`cards`** are card identities (name, passcode, archetype). - **`card_printings`** are a specific artwork at a specific rarity in a specific set. Their natural key is `(card, set_code, set_rarity, variant_label)`. - **`collection_items`** are one holding: N copies of one printing in one condition/edition/language/portfolio. - **`collection_lots`** record each acquisition (quantity, unit cost, date) under an item, so an item's quantity and cost roll up from its lots rather than being stored on the item. - **`external_price_ids`** map a printing to each provider's product ID. - **`price_snapshots`** are append-only daily prices per printing and edition, each with a confidence score. Unknown prices are stored as NULL, never zero, so a consumer can tell "unpriced" from "worth nothing". - **`portfolio_value_snapshots`** are append-only daily valuations that carry coverage counts, so a rolled-up total never silently looks complete when some cards are unpriced. TCGCSV is the MVP pricing source; YGOPRODeck supplies metadata only. ## Scheduled jobs Four daily jobs run in sequence: YGOPRODeck metadata sync, TCGCSV price reconcile-then-ingest, portfolio valuation, and price-alert evaluation. Each runs through an orchestration entry point (not the bare functions) that applies a cardinality guard, takes a per-kind Postgres advisory lock so a concurrent run skips rather than doubling up, and records the outcome in append-only run history (`SyncRun`, `ValuationRun`, `AlertRun`). Valuation refuses to run unless a successful same-day pricing run exists, so it never values against stale prices. In development these are Celery beat tasks. In production they are systemd timers. The upstream syncs use `Persistent=true` so they catch up after box downtime; the dependent jobs (valuation, alerts) use `Persistent=false` so they do not fire out of order against a stale upstream. ## Deployment and continuous delivery The deploy config lives under `infra/hetzner/`: the app and edge Compose files, the systemd units, `deploy.sh` (idempotent, with a `caddy validate` gate before reloading the edge), and `backup_db.sh`. Deployment is pull-based. A `millennium-deploy.timer` runs `deploy_poll.sh` every couple of minutes on the box: it fetches `origin/main`, and if the tip has advanced past the recorded deployed commit it checks out the new commit and runs `deploy.sh`. Before it touches the checkout it asks GitHub's public check-runs API, without a token, whether every required check is green on that exact commit: queued, running, or absent checks make it wait, with an alert if the wait passes 30 minutes; a check that finishes with anything but success alerts at once and holds production on the last good commit until a re-run passes or a fix lands. The recorded "deployed commit" marker is the source of truth for what is live, not git `HEAD`: the poller writes the marker only after `deploy.sh` and a public-route health probe both pass, so a failed deploy retries on the next tick instead of reporting a false success. Nothing pushes into the box, so no inbound deploy credential lives on it. A rollback is a `git revert` on `origin/main`, which the poller then deploys like any other change. ## Monitoring and backups - **Host metrics:** a small `/proc` collector runs on the host every couple of minutes and ingests a metric sample into Postgres through a one-off container, feeding the read-only `/status` dashboard. - **Uptime monitoring:** the backup and deploy jobs each ping a dead-man's-switch monitor on success, so a silent failure raises an alert. - **Backups:** `backup_db.sh` runs a daily `pg_dump` to off-box object storage. It fails closed: without a reachable off-box remote it reports failure rather than a false success, and it does not prune local dumps until the upload is confirmed. Restores are tested. ## Invariants These are constraints that are not obvious from reading a single file and that cause real breakage if forgotten. The code refers to them by number; the numbering is stable, so a comment that cites "invariant 7" means the entry below. 1. **Server entrypoints fail closed if `DJANGO_SETTINGS_MODULE` is unset.** The check lives in `backend/config/__init__.py` and raises, and wsgi/asgi/celery inherit it through the import chain. This prevents Django's scaffold default (`os.environ.setdefault(..., "config.settings.dev")`) from silently booting dev settings in production, which would run with `DEBUG=True`, a wide-open `ALLOWED_HOSTS`, and a predictable secret. `manage.py` is the one deliberate exception, as a developer tool. 2. **`backend/config/settings/base.py` defines no environment-sensitive values.** Not `SECRET_KEY`, `DATABASES`, `CACHES`, `CELERY_BROKER_URL`, `CELERY_RESULT_BACKEND`, or `ALLOWED_HOSTS`. Each environment module (`dev`/`prod`/`test`) owns these. If `base.py` supplied a default, `prod.py` would inherit it and a missing production secret would no longer fail closed. 3. **`base.py` does not load any dotenv file.** `environ.Env.read_env(...)` lives in `dev.py` only. Otherwise a developer running the prod settings locally with a dev `.env` present would silently inherit the dev secret and localhost service URLs. 4. **Lint must pass on every commit.** `make lint` runs ruff and mypy in strict mode. A documented gate that is allowed to fail is worse than no gate, because contributors learn to bypass it. 5. **Compose port mappings for `postgres`, `redis`, and the dev `backend` service bind to `127.0.0.1` only,** never `0.0.0.0` or the bare `port:port` form. Postgres ships with default credentials, Redis has no password, and the dev backend runs with `DEBUG=True` (so 500-page tracebacks leak environment values). Binding to all interfaces would expose them on any shared network. 6. **HSTS in `prod.py` defaults off** (`SECURE_HSTS_SECONDS=0`, and the include-subdomains and preload flags off), each overridable via `DJANGO_SECURE_HSTS_*`. Opting into a long max-age with preload before HTTPS is proven on every subdomain pins browsers (and the global preload list) into HTTPS for up to a year, and recovery means waiting out the timer. In the live deployment, HSTS is applied at the Caddy edge instead, because the frontend proxy hop drops `X-Forwarded-Proto` and Django would otherwise see the request as insecure. 7. **The OpenAPI schema and docs require authentication and exclude the read-only demo.** drf-spectacular does not inherit `REST_FRAMEWORK["DEFAULT_PERMISSION_CLASSES"]`, so its permission is set explicitly in `SPECTACULAR_SETTINGS["SERVE_PERMISSIONS"]` to a permission that both requires auth and denies the demo account. The schema describes every endpoint, field, and filter, which is reconnaissance material for a private app; the demo is a publicly obtainable session, so plain "authenticated" would expose the whole surface through it. 8. **The image's Python virtualenv lives at `/opt/venv`, not `/app/.venv`,** and no Compose volume mounts anything at `/app/.venv`. Docker copies image data into a named volume only when the volume is first created; a later rebuild produces a new image but the container still mounts the stale volume on top, so backend, worker, and beat would silently run old packages. 9. **Changes reach `main` only through the reviewed pull-request flow.** The `protect-main` ruleset blocks force-pushes and deletions and requires the status checks to pass; merges are squash-merges of reviewed PRs. Because continuous delivery auto-deploys `main`, anything that lands there ships. 10. **`CSRF_TRUSTED_ORIGINS` must include every frontend origin that POSTs through the Next.js `/api/*` proxy** (env-driven in `dev.py`, required with no default in `prod.py`). The proxy rewrites the Host for Django but the browser still sends its own `Origin`, and Django's CSRF origin check rejects the mismatch on every unsafe method, silently returning 403 on writes. 11. **The session cookie stays HttpOnly and the CSRF cookie stays non-HttpOnly; never override either.** The Django defaults are correct and deliberately left unset. Making the session cookie readable would let an XSS exfiltrate it (account takeover); making the CSRF cookie HttpOnly would stop the proxy from reading the token to echo into `X-CSRFToken`, so every unsafe write would 403. 12. **The edge Caddy Compose mounts the `caddy/` directory, not the single `Caddyfile`.** A single-file bind mount pins Docker to the file's inode at container-create time, but the deploy replaces the file (new inode) on every pull, so a single-file mount would serve the stale config forever and `caddy reload` would report no change. Mounting the directory avoids this. 13. **The `/ops` observability read endpoints set `permission_classes = [IsAuthenticated, IsSuperUser]` explicitly,** never the global default. The global default is passed by the read-only demo for safe methods, so an `/ops`-adjacent GET that inherited it would let the demo (a publicly obtainable session) read the audit trail and error logs.