Blog

Inside the UserBaseAPI ADRs

2026-05-15

Nineteen decisions, not nineteen implicit choices

UserBaseAPI is a FastAPI backend for account management, subscriptions and billing, and S3-backed file storage built around a quarantine-first upload and scanning pipeline — nothing lands in the approved bucket until a scanner clears it. It's currently in beta architecture verification: the goal right now is confirming the architecture holds up under real use, not production readiness. That framing matters here specifically, because it's exactly the kind of project where the temptation is to let every technology and design choice live only in the code and in memory — discoverable, if at all, by reading a diff or asking whoever wrote it. UserBaseAPI's answer is to make every one of those choices a numbered, checkable Architecture Decision Record instead. Nineteen of them exist today, covering everything from the web framework itself down to how local dev decides whether it needs Docker.

This post isn't a restatement of what each ADR says — that's what the records themselves are for, and every one of them is linked below. It's a walkthrough of the process: how the records are numbered, what their status vocabulary actually means, what one looks like on the inside, and where all nineteen came from.

The rules the process runs on

The ADR index states the whole process in one line: numbered, chronological, and never edited after acceptance. A decision that changes doesn't get its old file rewritten — it gets a new ADR that points back at the one it replaces, and the old one stays exactly as it was, as a historical record of what was actually decided and when. Three states show up in practice:

  • Accepted — the default state, and where most of the nineteen sit today.
  • Superseded by … — a later ADR replaced this one's decision outright. ADR-0007 picked Authlib for JWT and Google OAuth; once authlib.jose turned out to already be deprecated in the pinned version, ADR-0011 replaced it with PyJWT + httpx-oauth and says so explicitly in its own status line: "Accepted — supersedes ADR-0007." ADR-0007 wasn't deleted or rewritten to match — it's still there, marked superseded, showing what the project actually ran on before and why that stopped being true.
  • Amends / Amended by … — a softer relationship than superseding: a later ADR changes what an earlier one said the future would look like, without necessarily reversing the earlier decision itself. ADR-0010 deferred migrations to SQLAlchemy's create_all() for beta and planned to "introduce Alembic once the schema stabilizes." By the time that plan was revisited — after create_all()'s real limitations had already been hit twice in practice — ADR-0015 amended that stated plan: no Alembic is coming, at any schema-stability point, in favor of plain numbered SQL files plus a version table. The original decision to defer migrations for beta wasn't wrong; what it said would happen next was, and the record shows exactly that distinction instead of blurring the two together.

What one ADR actually looks like

Every record follows the same shape: a short metadata block (Status, Date, and a Source line naming where the decision was originally documented), then Context, Decision, and Consequences — not just what was chosen, but the situation that forced the choice and what it costs going forward. ADR-0014, the quarantine-first upload pipeline the README leads with, is a good example of the shape doing real work rather than just formatting:

  • Context: Linode Object Storage (the choice in ADR-0008) has no built-in scanning and no event triggers to hook a scan into automatically the way AWS S3 + Lambda would — and an upload response can't block on a full scan without making uploads unacceptably slow.
  • Decision: no file is ever written directly to the bucket other code reads from. Every upload lands in a quarantine bucket first, gets SHA-256-deduplicated, and only a CLEAN scan result copies it into the approved bucket — with a cron job separately recovering uploads stuck mid-flight if the API crashed between the S3 write and the database update.
  • Consequences: every approved upload now costs an extra S3 copy operation (quarantine → approved) instead of writing to the final bucket once — stated plainly as the direct cost of not having a cloud-native scanning hook, accepted in exchange for a hard guarantee that unscanned content is never reachable through the approved-files code path.

That last part is the part worth noticing about the format itself: the record doesn't just justify the decision, it names the price being paid for it, in the same document, right next to the decision that incurred it.

Where nineteen records actually came from

Not every ADR here was written the moment its decision was made — the index is honest about that, broken into three batches:

  • 0001–0010 were extracted from the "Architecture Decision Records" section of the original build spec (archive/userbase_build_spec.md, 2026-05-18) and reformatted into individual files after the fact — real decisions, retroactively given their own numbered record.
  • 0011–0018 were pulled from decisions already documented across various planning docs under _userbaseapi/complete/ that hadn't yet been captured as formal ADRs in the build spec.
  • 0019 is the one record with no prior planning document at all — written directly against the working tree, sourced straight from the actual commit (app/core/db_types.py, app/core/database.py) that made SQLite a real local-dev option alongside Postgres. It's the clearest example of the process running the way it's meant to going forward: the record and the decision arriving together, instead of the record catching up to a decision made somewhere else first.

All nineteen, linked

The full, current index — every record on GitHub, in order:

UserBaseAPI Architecture Decision Records
# Title Status
0001 FastAPI over Django or Flask Accepted
0002 UV over Poetry or pip Accepted
0003 SQLAlchemy 2.x over SQLModel or Tortoise ORM Accepted
0004 PostgreSQL over MySQL or MongoDB Accepted (see 0019)
0005 Redis over RabbitMQ or an in-memory broker Accepted
0006 TaskIQ over Celery Accepted
0007 Authlib over python-jose Superseded by 0011
0008 Linode Object Storage over AWS S3 Accepted
0009 Pytest over unittest Accepted
0010 No Alembic in beta Accepted; plan amended by 0015
0011 PyJWT + httpx-oauth over Authlib Accepted — supersedes 0007
0012 Strict layered architecture Accepted
0013 Custom span-based observability logging Accepted
0014 Quarantine-first S3 upload and scan pipeline Accepted
0015 Raw SQL migrations + version table over Alembic Accepted — amends 0010
0016 One shared Traefik across linode-dev/demo/prod Accepted
0017 Four-tier environment strategy Accepted
0018 Dynamic port allocation for local dev Accepted
0019 SQLite for local dev via portable column types Accepted

Browse the adrs/ directory on GitHub for the current, canonical version of this list — it will keep growing past nineteen, and the table above is a snapshot, not a live mirror.

Why this is the discipline worth keeping in beta

None of this is process for its own sake. A project explicitly in architecture-verification mode is, by definition, still finding out which of its own choices actually hold up — and a decision that only lives in code or in someone's memory can't be argued with, revisited cleanly, or handed to the next person (human or agent) touching the codebase. Numbering every decision, dating it, and never rewriting it after the fact turns "why does it work this way" from a question that needs the original author into one the repository can answer on its own.

Get the code

The full UserBaseAPI repository is on GitHub, BSD 3-Clause licensed.

See the UserBaseAPI project outline for the architecture and feature rundown these decisions actually produced.