Design decisions
The repository keeps short architecture decision records. This is the digest — each entry with the condition that would overturn it, because a decision without a reversal condition is just a preference.
| Decision | Chosen | Rejected | Reverses if |
|---|---|---|---|
| Authentication | Built in-house | Keycloak, Ory, Authentik | Real users appear, or another service needs single sign-on |
| Sessions | Opaque server-side tokens | JWT | Multiple services must verify without a shared database |
| Database | PostgreSQL 17 | SQLite, external instance | Never, realistically — the ceiling is far away |
| Migrations | Embedded, applied at startup | Separate job or CLI step | Migrations grow long enough to delay startup unacceptably |
| Service shape | One Go binary, standard library | Framework, microservices | A second deployable genuinely earns its own lifecycle |
Opaque sessions instead of JWT
Section titled “Opaque sessions instead of JWT”This is the decision people are most likely to disagree with, so it deserves the argument in full.
A JWT is verified by checking a signature, which means any service holding the public key can authenticate a request without asking a database. That is a real benefit — and it buys nothing here, because there is exactly one service and it already has a database connection open.
What it costs is the part that gets underplayed. A signed token is valid until
it expires, so logout cannot actually revoke anything without a server-side
denylist — which reintroduces the database lookup the JWT was supposed to avoid,
plus a new distributed-state problem. There are signing keys to store, rotate,
and eventually leak. And the format has a long history of implementation
footguns, from alg: none acceptance to confusing symmetric and asymmetric
verification.
Opaque tokens cost one indexed lookup per request. In exchange, logout is a
DELETE, there are no keys in the system at all, and the amount of cryptography
that can be got wrong is one call to sha256. At this scale that is a good
trade; at a different scale it stops being one, and the interface the domain
exposes is small enough that a JWT-issuing façade could be added without
touching a handler.
In-house authentication instead of an identity provider
Section titled “In-house authentication instead of an identity provider”The learning argument is in the overview, and it is the honest reason. What is worth adding here is the cost, stated plainly: every security decision in this system is mine, unreviewed, and defended by the notes in authentication rather than by an implementation many people have attacked. For anything with real users, an identity provider is the right answer and this would be the wrong one.
The reversal condition is therefore specific rather than aspirational — the moment another service in the lab wants single sign-on, the sane move is either to deploy a real provider or to make labapp speak OIDC, and the second is attractive mostly because it would be the next interesting thing to build.
Migrations at startup
Section titled “Migrations at startup”The alternative is a separate migration step — a Kubernetes job, or a manual command before rollout. That is the more conventional choice at scale, and it decouples schema changes from deploys.
Startup migrations were chosen because they make “deploy the new binary” a single, atomic-feeling operation with no ordering for me to remember, and because the safety condition is cheap to satisfy: each replica takes a PostgreSQL advisory lock before migrating, so one applies the change and the others wait. The condition that reverses it is duration — the moment a migration takes long enough that pods are killed by their startup probe while waiting, it belongs in its own job.