The session held a wallet of up to six accounts, and a request picked one by a header or an ?account= parameter. It now holds one: user_id and its epoch. A sign-in replaces whoever was signed in, sign-out and account deletion end the session, and an account refused mid-session (revoked, suspended, deactivated) clears it. /api/me/accounts and /api/me/switch are gone, and with them their refusal codes and the UI copy for them. A cookie the multi-account build wrote carries no single epoch and is refused: its holder signs in once more. A Google link finishing in a browser now signed in as someone else is refused, as before for an account outside the wallet. A test holds that no app code reads the wallet keys or the account header again.
Backend conventions
The rules below are already followed across backend/app — they lived only in a few module
docstrings (userscope.py, routes/_common.py) until this file collected them. When you add code,
match these so the next reader doesn't have to guess "which pattern do I copy".
Module layering
Three layers, by who runs the code — not by feature:
-
routes/…— request-only. A route module holds HTTP concerns: auth dependencies, request/ response models, status-code mapping, serialization. It owns logic that runs only on a request. Business logic that a request happens to trigger does not live here if anything else runs it too. -
The units and the other domain packages — logic shared by more than one caller. A unit's database work (below), and anything the scheduler or the worker runs (not just a request), lives in a domain package, so the route, the worker, and the scheduler share one definition. Examples from this codebase:
downloads/service.pyowns enqueue + the job state transitions (routes call it);plex/sync.pyis run by both the admin route and the scheduler;downloads/pipeline.pyis the worker's child pipeline;issues/bundle.pyis the one definition of a bug report's files, for the zip export and the tracker. -
Flat
app/*.pycore modules — cross-cutting primitives.auth(withapitokens, a machine's folder-scoped way into the Files library),sysconfig,quota,audit,jobruns(every background run's durable row, its words inrunstatus),notifications,userscope,ratelimit,progress,logs(the one logging configuration),logsink(itslog_eventstable),diagnostics(a user's consented log level),debugsession(a recording that tags its user's lines, E16) andcollation(A→Z in the reader's alphabet, E30), … are single-file primitives many layers import. They depend on nothing above them (a core module must never import aroutes/…module).
Extraction trigger: the SECOND consumer. Keep new logic inline in its first caller. The moment a second caller needs it (a worker as well as a route; a scheduler as well as a route), extract it down a layer — into the relevant domain package, or a flat core module if it's cross-cutting. Don't pre-abstract for a single caller; don't leave shared logic duplicated once there are two.
Units
The domain is four units, each owning its tables (models.py in its package):
| Unit | Package | Its routes |
|---|---|---|
| Capture | downloads/, the worker |
downloads.py, files.py, public.py |
| Reader | reader/, discover/, feedquery.py, the playlist sync and tag suggestions in sync/ |
feed.py, playlists.py, saved_views.py, discover.py, tags.py |
| Catalog | catalog/, the ingests in sync/ |
channels.py, search.py, sync.py |
| Plex | plex/ |
plex/ |
- A unit's route does no database work. It validates, reads through the unit's
queries.py, writes through itsservice.py, serializes, and commits. It may name a unit's model as a type (underTYPE_CHECKING), never use one as a value, and it passes a kind ("movie"), not a model, to a query. - A unit's code keeps to its edges. It reads another unit's tables only along a declared edge
(Capture and Reader read Catalog's and Plex's: the feed joins
VideowithVideoStatein one query) and writes them only through the owner's service. Core code reaches a unit's tables through that unit'squeries. - Whose a row is,
userscopesays — in every module, core included:owned_by(Model.user_id, user)for a filter,owned_row(db, Model, id, user)for a row by id (someone else's row is the same answer as a missing one). A unit'squeries.own_xreturns None and its route answersnot_found; a core route callsowned_or_404. A ruleowned_rowcannot say — a job its owner shared, a built-in profile nobody owns, a folder read underFOR SHARE— is written out in the unit's own code, beside the reason.
All of it is held by tests/test_unit_fences.py, with no exemptions: the unit map, each unit's
packages and edges (UNIT_PACKAGES, READS) and what may commit (UNIT_COMMITS, CORE_COMMITS)
are listed there. It reads the code's AST, so it holds the forms written here: an owner column
compared inline (a model's, an import alias's or an aliased() one's, by ==, !=, .in_(), or a
filter_by(user_id=…)), a model built or an insert/update/delete (under any import name, of a
model or its __table__) across units, and a .commit() call off the lists all fail it. What an AST
cannot tell is review's to catch: an ORM write on another unit's loaded row (an attribute, a
db.delete), SQL in text(), a column or model reached another way (bound to a variable, through
__table__.c or a module path), and a commit made by a begin() block or a commit handed in.
Transaction ownership
This follows CLAUDE.md's canonical rule — the handler commits; a unit's service and every helper
flush — so read it that way, not as an exception to it. The commit belongs to the operation
owner; anything called inside another transaction only stages its change
(db.add / flush) and lets the owner commit, so the whole operation is one transaction.
audit.record() is the reference: it adds the audit row and the caller commits it together with the
mutation it records, so an audit entry can never exist without its change (or vice-versa).
- The operation owner is the route handler, or a job. A unit's
serviceflushes even when it makes a whole transition (service.cancel): the handler commits it, together with whatever else the request changed. What owns its OWN session commits it:quotaandjobruns' run rows, the scheduler's jobs, a unit's jobs (the worker'sjobdbwrites, the syncs and ingests that commit per page or batch so a later failure loses nothing already stored), and a few core steps that must land whatever their caller does next (a token's last-used stamp, a refreshed YouTube grant) — the fence'sUNIT_COMMITSandCORE_COMMITSname each with its reason,test_route_conventionsa route's own committing helpers. Everything else flushes. SessionLocalisexpire_on_commit=False: after a rawUPDATE+ commit, an ORM object you already loaded is stale. Read the fresh value from the row (RETURNING, or a re-SELECT), not the ORM attribute — seeservice.decr_ref_count.
Shared counters and status: do it atomically at the DB
- Never read-modify-write a shared counter in Python (
x.count = x.count + 1). Concurrent sessions lose an update. Use a DB-atomic statement —col = col + 1, orgreatest(col - 1, 0)withRETURNINGso the caller branches on the post-value under the row lock (service.incr_ref_count/decr_ref_count). - Download job status is a
JobStatusStrEnum (downloads/status.py), not bare strings. TheHOLDINGset defines which states hold aMediaAsset(so release accounting stays correct);STOPPED_BY_ROUTEis the route-initiated stop set. A worker child's terminal write goes throughservice.finalize_if_running(an atomicUPDATE … WHERE status='running') so a finishing child can't resurrect a job a route just stopped.
Config: one home per kind
app.config.settings— boot/env config (DB URL, download root, worker concurrency): read once at startup, not user-tunable at runtime.sysconfig.SPECS— the runtime admin knobs (DB-override-or-default). A new admin-tunable value goes inSPECS; don't invent a fourth store. Each spec states when a change takes effect (applies, required):livekeys are read where they are used, never at import (test-held). The scheduler's job intervals areSPECSkeys too, edited on the Scheduler page, not the Configuration page.
The gate
Everything below must be green before a change is done (run via siftlode check):
ruff + basedpyright (backend) · the lock check + tsc ×3 + eslint + prettier + knip +
vite build (frontend) · vitest · pytest. The pytest lane runs in backend/docker-compose.test.yml
— an ephemeral Postgres + the backend tree bind-mounted — so the DB-backed integration tests (the db
fixture) actually run; a missing database is a hard failure, not a silent skip, unless a hand run asks
for pure-logic only with SIFTLODE_TEST_REQUIRE_DB=0. See docs/development.md, and docs/gates.md
for what each gate deliberately leaves out.