Skip to main content

Architecture Decision Records

An ADR captures one significant architectural decision: the problem we faced, what we chose, and what that choice costs us. The point is that a developer who joins later and asks "why is it like this?" finds the answer here instead of reverse-engineering intent from code or asking someone who has forgotten.

These first twelve records were written retroactively on 2026-07-24, after the decisions were made and shipped. Each one was written against the actual code — the References section links the files that prove the decision is real.

Format

Each ADR is one markdown file, NNNN-short-slug.md, containing:

# ADR-NNNN: Title

**Status:** Accepted | Accepted (retroactive) | Superseded by ADR-XXXX | Deprecated
**Date:** YYYY-MM-DD

## Context — the problem and the constraints that shaped the choice
## Decision — what was chosen, concretely (names, versions, config)
## Consequences — positive AND negative; every ADR lists real trade-offs
## References — relative links to the code/docs that embody the decision

Status conventions

  • Accepted — decided and in force; written at decision time.
  • Accepted (retroactive) — in force, but the record was written after the fact by inspecting the code. All ADRs below carry this status.
  • Superseded by ADR-XXXX — replaced; keep the file, add the pointer.
  • Deprecated — no longer in force and nothing replaced it.

Never delete or rewrite an accepted ADR's Decision; write a new one that supersedes it.

When to write a new ADR

Write one whenever a decision would make a future developer ask "why?": picking or dropping a dependency, a security mechanism, a data-modeling rule, a deployment shape, anything that constrains future work, or anything you had to argue about. Small reversible choices (a variable name, one component's props) do not need one. Next number: 0018.

Index

ADRTitleOne-line summary
0001PostgreSQL 16 + FlywayVersioned SQL migrations own the schema; Hibernate only validates (the legacy app let Hibernate generate MySQL DDL)
0002Monorepo of independent projectsOne repo, no parent POM/workspace — backend, frontend, e2e, legacy app, and inventory microservices build separately
0003JWT + rotating refresh tokens15-min HMAC256 access tokens paired with 14-day rotating opaque refresh tokens with family reuse-detection
0004Argon2id with transparent rehashArgon2id via DelegatingPasswordEncoder; legacy BCrypt hashes upgrade silently on next login, no forced resets
0005Self-hosted ALTCHAProof-of-work CAPTCHA with no third-party calls, at the price of CSP wasm/worker allowances and weaker bot deterrence
0006In-memory Bucket4j rate limitingPer-IP token buckets on auth endpoints, in process memory — justified by one-backend-per-client deployment
0007Per-client isolated stacksOne compose stack (db+backend+frontend) per client instead of a multi-tenant DB; no tenant_id anywhere
0008Traefik edge, TLS at CloudflareTraefik v3 label-routing on :80 only; Cloudflare terminates TLS; origin hop is plain HTTP
0009Redux / React Query / Zustand splitRedux for the auth slice only, React Query for all server state, Zustand for portal/UI islands
0010AG Grid Community + in-house grid subsystemRebuild saved views/tabs/bulk actions/export on Community instead of buying AG Grid Enterprise
0011Permission-name authorities; activeRole is a UI lensBackend authorizes on the union of permission strings; role switching exists only in the frontend
0012springdoc as the single API contractAnnotation-generated OpenAPI at /v3/api-docs, gated by SWAGGER_ENABLED; no hand-maintained Postman collection
0013Shared-database multi-tenancyOne stack serves many companies via a tenant_id discriminator, enforced by Hibernate and again by Postgres row-level security; supersedes 0007 for the hosted offering
0014Caddy origin TLS + GHCR CI/CDCaddy terminates Let's Encrypt TLS behind Cloudflare Full (strict); GitHub Actions builds sha-tagged images to GHCR and deploys over SSH with a health gate and auto-rollback; supersedes 0008 for the hosted shape
0015Staging tier + promote-by-buttonA second full stack on the same VPS at stage.<domain> behind a shared Caddy edge; merges auto-deploy staging, production ships the exact staged image tag — no stage branch, no rebuild (promotion model amended by 0016)
0016Continuous promotion + Secrets as configProduction promotes automatically once staging's health gate passes — no routine manual step; VPS env files are rewritten from PROD_ENV_FILE/STAGE_ENV_FILE GitHub Secrets on every run
0017Documentation Central on Docusaurusdocs/ stays the source of truth; a staging script builds a public Docusaurus site with a generated OpenAPI reference, shipped by its own image, compose project and workflow so docs and app deploys never block each other