oll.am · Systems Architecture

A live, multi-service AI platform

The architecture of oll.am — one account and one payment across a family of AI products, built and run in the open by Samuel Alemu. One idea holds it together: frozen infrastructure services (identity · billing · email, and an any-model gateway) with thin product services that call them over HTTP.
oll.am gathers several small AI products behind two pieces of shared, frozen infrastructure: an Identity service (identity, billing and email) and a Model gateway (provider-agnostic model routing). Each product is a thin service that holds only its own logic and prompts and calls the shared infrastructure over HTTP — so a new product is mostly configuration, not new plumbing. The design is organised around blast radius: identity, billing and email carry the highest cost of failure, so they are frozen and isolated behind an HTTP boundary where a crash or bad deploy in one product cannot cascade into another. This page documents that system top to bottom: its containers, the calls between them, how they map to real infrastructure, the failure isolation between them, and the decisions behind them. It stands on its own.
FROZEN — build once, never touch built — exists now planned — not yet
oll-am/ the monorepo — one repository, an independent deploy per service
├─ identity/ FROZENthe Identity service — frozen identity · billing · email
├─ services/ thin product services — product logic + prompts only; they call the Identity service & Model gateway over HTTP
│ ├─ product-a/ builtimage generation — a guest (no-login) example product
│ └─ product-b/ builta writing assistant — an authenticated example product · adding a product is mostly configuration, not new plumbing
├─ site/ builtthe static landing + these architecture docs (served by nginx)
├─ specs/ builtthe API contract (OpenAPI) — the Schemathesis-gated service seam
├─ content/ builtproject context and documentation source
└─ state/ builtthe single source of truth for the live system state
How it fits (the architecture in six rules):
Failure isolation — the cross-service failure story. Because services share nothing but HTTP, a failure stays where it starts. One service's database goes down → the others are unaffected; there is no shared table to lock and no shared pool to exhaust. Schema migration is per-service — one service can migrate without coordinating a fleet-wide release. And because a service asks another over HTTP (/me) rather than joining its tables, there is no distributed transaction to leave half-committed — a caller that can't reach a dependency degrades to a typed error, not a corrupted write.
How the design is enforced — proof, not assertion. The invariants above are held by CI gates on every change, not by convention:

System architecture — the whole picture

Two frozen infra services — an Identity service (identity·money·email) and a Model gateway (the model call) — and many thin product clients. The browser/app talks to its product over a same-origin seam that splits auth/billing/email to the Identity service, the model call to the Model gateway, and product calls to the product's own backend. Identity rides in a Bearer JWT; the product owns only its prompts + product logic — no auth/billing/email code, no model SDK. Persistence is per-service: every stateful service owns its own private managed-Postgres database (the Identity service and each product each own one), and they integrate over HTTP, never by sharing tables — a stateless relay like the Model gateway owns no DB at all.

C4 context view (L1) — the whole platform as one box, its people + the external systems it depends on

People
✍️ Writer
uses a writing product · pays once
📷 Guest buyer
image product · no login
🤖 AI crawler
llms.txt · sitemap · discovery
— HTTPS →
oll.am
AI workspace — any model, pay once. Frozen infra (Identity service: identity·money·email · Model gateway: the model call) + thin product clients.
one account · pay-once · on-device option
External systems
Payments — checkout + webhook
Email provider — transactional email
Image generation — LoRA train+sample
LLM providers — cloud default · self-hosted runtime
Managed Postgres — one DB per service

System design view (L2) — every box a deployable container [tech]; every labelled arrow a real call, in its true direction

oll.am backend — one VPS · self-hosted Coolify · shared internal Docker net ● User [person] Frontend [Angular/Next + nginx] per product · the seam Managed PGidentity (private) Managed PGper-product (private) no DB — stateless relay FROZEN INFRA 1 FROZEN INFRA 2 Identity svc [Flask] auth · billing · email passwordless → signed JWT webhook = sole plan writer Product svc [Flask] thin client owns prompts + logic no auth/billing/email no model SDK Model gateway [Flask] · service token provider switch cloud · self-hosted any model · any placement Payments[SaaS] Email[SaaS] Image gen[SaaS] LLM cloudinference Self-hostedinference uses auth/billing/email product API · Bearer GET /me /complete SQL · own DB SQL · own DB checkout webhook email image gen internal external

Solid arrow = a call (direction = who calls whom) · dashed = async callback (the payments webhook) · each cylinder = a service's own private managed-Postgres DB (no shared database; the Model gateway is stateless, so it has none) · the Model gateway is a frozen infra service.

System design view — Diagram 1.2 · one level deeper · inside the Identity service's components & real endpoints · the product fleet · the Model gateway's provider switch · solid = frozen infra, dashed = product service

App shellAngular shell Product client[client] Product client[client] Product client[guest] Landing sitestatic · SEO THE SEAM — PER PRODUCT (no shared gateway) · each frontend ships its OWN nginx route-split (web) · base-URL interceptor (native). The pattern, identical everywhere: /api/auth · /api/billing · /api/email → Identity service (Bearer JWT) /api/text → Model gateway (internal · service token) /api/<product> → product service (Bearer, or guest = no token) / → landing (static · JSON-LD · sitemap · llms.txt) oll.am backend — one VPS · self-hosted Coolify · shared internal Docker net · reached service-to-service by internal DNS FROZEN Identity service private DB Auth · passwordless POST /auth/signin · POST /auth/verify GET /auth/me → { plan } · live, stale-while-revalidate Billing · payments POST /billing/checkout · POST /billing/webhook GET /billing/verify-session · POST /billing/portal webhook = the SOLE writer of plan (idempotent) Email · provider POST /email/send · sign-in links · receipts · delivery identity-only signed JWT · sub = user id PRODUCT SERVICES Product services per-product Product svc A → authed owns prompts · GET /me · POST /text Product svc B → authed product API · GET /me · POST /text Product svc C → guest guest · no login · one-time payment guest-checkout + /email/send image-gen LoRA pipeline → images each service = its own Dockerfile · own Coolify app no auth/billing/email code · no model SDK FROZEN Model gateway · internal POST /text · internal only · service-token gated one provider switch — _PROVIDERS: cloud● default cloudalt self-hostinternal / on-dev one module wraps the vendor · lazy import explicit timeout · narrowed return · mock-default + sovereign tier — privacy: any · eu · on_device (additive) any model · any placement no DB — stateless relay Payments[SaaS] Email[SaaS] Image gen[LoRA · SaaS] Cloudvendor cloud · LLM APImetered Hostedour VPS · self-hosted runtimeflat cost auth/billing/email /api/<product> /api/text GET /me /text checkout webhook send image gen cloud self-host solid = a call (arrow = caller → callee) dashed line = async callback (webhook) frozen infra product service private DB

One level deeper than the L2 view: you can see inside the frozen containers — the Identity service's three components and their real endpoints (auth · billing · email), the product fleet that calls GET /me for the live plan, and the Model gateway's single _PROVIDERS switch (cloud default · self-hosted) behind one internal route. Frozen infra (solid): the Identity service + Model gateway, with product services as their clients. Product services (dashed): thin clients that own only their prompts — an authed variant and a guest, no-login variant — plus the Identity service's additive guest-checkout endpoints.

Container relationships — every call, precisely

The same topology as the diagrams above, as a precise edge list — every relationship with its exact endpoint · protocol · auth · purpose

RelationshipEndpointProtocol · authPurpose
Frontend → Identity service/api/auth|billing|email/*HTTPS · Bearer / guestsign-in, checkout, plan status, email
Frontend → Product/api/<product>/*HTTPS · Bearerthe product action (improve, generate…)
Product → Identity serviceGET /api/auth/meHTTPS · Bearer (server-side)identity + live plan (token-identity-not-entitlement)
Product → Model gatewayPOST /api/text/completeinternal · service tokenthe model call (product sends its own prompt)
Model gateway → LLM providerprovider SDK / HTTPHTTPS · API key (cloud) / none (local)run the completion · "any model" — a cloud provider is the default; a self-hosted runtime is a bring-your-own path — where no local model is reachable, the request returns a clean 502 LLM_UNAVAILABLE
Payments → Identity servicePOST /api/billing/webhookHTTPS · HMAC sigthe sole writer of plan (free→pro)
Product → image generationprovider APIHTTPS · API tokenimage generation (guest flow)
Identity service → its DBmanaged PostgresSQL · privateidentity + subscriptions — its database alone
Product → its DBmanaged Postgres · own DBSQL · privatejobs/results; never reads another service's tables
Model gateway → (none)— statelessholds no state, so owns no database

Two frozen infra services — the Identity service (identity·money·email) and the Model gateway (the model call) — and thin product containers that own only their prompts. The one invariant that keeps the Model gateway a gateway, not the rejected monolith: the shared layer never owns prompts. See ADR-020.

Two client shapes fall out of this: authed (Bearer JWT + plan read live from /me) and guest (no login, via the additive guest-checkout endpoints).

Interaction view — the calls in order

C4 dynamic view — the same relationships, sequenced by real flow [endpoint · protocol · auth]

A · Authed product action — e.g. "improve my text"
the heart of the platform: identity from the Identity service, the model call from the Model gateway, prompt from the product
1
FrontendProduct
POST /api/<product>/improve · HTTPS · Bearer JWT
the product action the user triggered
2
ProductIdentity
GET /api/auth/me · HTTPS · Bearer (server-side)
identity + live plan (token-identity-not-entitlement) → gate free vs pro
3
ProductModel
POST /api/text/complete · internal · service token
the model call — the product sends its own prompt; the Model gateway never owns it
4
ModelLLM provider
provider SDK / HTTP · API key (cloud) · none (local)
run the completion · two placements today — Cloud (LLM API) + Hosted (self-hosted runtime, our VPS)
← returns up the chain: the Model gateway → Product → Frontend (improved text)
B · Billing / plan — the money path
checkout starts from the browser; the webhook — not the browser — is the only thing that flips the plan
5
FrontendIdentity
POST /api/billing/create-checkout-session · HTTPS · Bearer / guest
start checkout (returns the hosted URL)
6
PaymentsIdentity
POST /api/billing/webhook · HTTPS · HMAC signature
the sole writer of plan (free → pro) — verified by signature, idempotent
C · Guest product — no login
a one-off image purchase: guest checkout via the Identity service, generation via an image-generation provider
7
ProductImage gen
provider API · HTTPS · API token
image generation (guest flow; billing + email still via the Identity service)

The platform in motion — agentic call sequences

The views above are static structure (what exists, what calls what). These are C4-dynamic sequences — the same containers drawn as lifelines with numbered, ordered messages, each a real, proven call labelled endpoint · protocol · auth. Two flows: the agent seam, and a flagship agent-composed task flow.

The agent seam — proven end-to-end

This is the loop we proved end-to-end — an agent runtime drove the MCP seam, which composed the three spines and returned real output over both stdio and HTTP. Drawn in the page's C4-dynamic vocabulary — lifelines + numbered messages, each edge labelled endpoint · protocol · auth.

C4 dynamic view — the proven agent-seam call sequence [endpoint · protocol · auth]

Agent runtimeMCP host MCP seamFastMCP Identityauth·billing·email Model gatewaythe model call RetrievalRAG · pgvector 1MCP · stdio / HTTP 2GET /api/auth/me · HTTP · Bearer JWTidentity + plan 3POST /api/model/complete · HTTP · Bearer JWT→ real output · a small model 4POST /api/memory/query · HTTP · internal service token→ REAL cited chunks · retrieval, NO generation ← returns to the agent runtime · real output over stdio + HTTP Retrieval vs generation: the Retrieval service call returns REAL cited passages — deterministic, un-hallucinated. Generation is the model host's optional choice; the citation is already in hand before any LLM runs.

Proven end-to-end — real output over stdio + HTTP. An agent runtime → the MCP seam → the Identity service (identity/plan, Bearer JWT) → the Model gateway (real output, a small model) → the Retrieval service (real cited chunks, an internal service token). Step 4 is the point: the Retrieval service returns real cited passages, not a generation — retrieval is deterministic and un-hallucinated; the LLM polish is optional garnish.

How grounded retrieval works — real citations, optional generation

Step 4 above returns cited chunks, not a generation — this is why that answer is deterministic. A query is embedded (a self-hosted embedding model), searched with a pgvector similarity search over the Retrieval service's own private database, and returns real passages with exact references. The citation is in hand before any language model runs, so generation is an optional, detachable step.

Retrieval-first pipeline — query → embed → pgvector similarity search → real cited chunks · generation optional

Retrieval is deterministic · generation is optional Querythe product's question Embedding modelself-hosted · 768-dimself-hosted pgvector searchRetrieval servicetop-k over your docs REAL cited chunksdeterministicun-hallucinated · exact refs Generationoptionaldetachable managed Postgres · pgvector embed vector top-k detachable

The cheap, deterministic part is retrieval: embed the query, run a pgvector similarity search over the Retrieval service's own private database, and return real cited passages — exact references, not a paraphrase. Generation is optional (dashed): a model may polish the retrieved chunks, but the value — the citation — already exists before any LLM runs. That is what makes a grounded answer verifiable.

The flagship agent flow — one request, fanned out

The most complex flow: a single request fans out across all three spines, an image-generation provider, and the distribution layer — an agent composes the entire platform in one action. With the agent seam proven, this is the flagship the substrate unblocks.

E · AGENT TASK FLOW — one request fans out across the platform

The user triggers one request. The agent handles everything:

#From → ToCallPurpose
1Agent → Identity serviceGET /api/auth/me · BearerIdentity + plan check (has the user paid?)
2Agent → Retrieval servicePOST /api/memory/query · service tokenRetrieve the user's real context from their documents
3Agent → Model gatewayPOST /api/text/complete · service tokenCompose the primary document from the retrieved context
4Agent → Model gatewayPOST /api/text/complete · service tokenCompose a second document grounded in the user's real context
5Agent → image generationprovider API · API tokenSelect the best image from the trained model
6Agent → Identity servicePOST /api/email/sendEmail the complete package (PDF) to the user
7Automation → social schedulerscheduler API (optional)An optional post to a social channel

← Returns: an image + generated documents (PDF) — all generated, all private by default, all pay-once. The agent composes the platform; no spine knows about the specific use case — the agent is product, the spines are infrastructure.

The same flow (E) as a C4-dynamic sequence — one request fanned out, drawn in the page's lifeline vocabulary · each edge endpoint · protocol · auth

an agent — one request composes the whole platform Agentagent runtime Identityauth·billing·email RetrievalRAG · pgvector Modelthe model call Image genLoRA · SaaS 1GET /api/auth/me · Beareridentity + plan 2POST /api/memory/query · internal service tokenretrieve the user's REAL context — grounding 3POST /api/text/complete · service tokencompose the primary document 4POST /api/text/complete · service tokencompose a second document (grounded in real context) 5provider API · API tokenselect an image from the trained model 6POST /api/email/sendemail the package (PDF) ← returns: an image + generated documents (PDF) · pay once · private by default Invariant: the agent composes the spines; no spine knows the specific use case — the agent is product, the spines are infrastructure.

The flagship flow as a sequence: one request fans out to all three spines + an image-generation provider. Step 2 is the differentiator — the Retrieval service returns the user's real context (grounding), so the generated documents reference true information, not a hallucination. The agent composes; no spine knows the specific use case.

Deployment view — logical containers to real infrastructure

The views above are logical (what calls what). This view is physical: where each container actually runs, how it is reached, and where the freeze lives.

DNS A @/* → VPS Traefik TLS · Let's Encrypt · routes by domain
What runs on the VPS · Coolify — one app per service, each its own container
Frozen spines — shared infrastructure, reused by every product
Identity service FROZEN
auth · billing · email
Model gateway FROZEN
provider-routing · service-token-gated
Retrieval service FROZEN
RAG · pgvector
Product services — thin platform clients, own only their logic + prompts
Product service
calls the platform over HTTP
Product service
calls the platform over HTTP
Product service
guest / no-login variant
… N services · one Coolify app each — a new product is mostly configuration, not new plumbing.
Shared infra on the box
Traefik
reverse proxy · TLS
Redis
shared cache/queue
Postgres
Coolify's own store
Agent gateway
agent runtime
↕ shared internal Docker network — services reach each other by internal DNS, no public hop. Each service runs as its own Coolify app.
🧊 Freeze mechanism: a frozen service only ever changes by a deliberate, gated redeploy — the mechanics live in The Factory.
Managed Postgres — external
One database per stateful service — services integrate over HTTP, never a shared table. Stateless services own none.
External SaaS — off-VPS
Payments · transactional email · image generation · LLM providers — each reached only through the service that owns it. A self-hosted model container on the box serves embeddings + small local models.

Reads top-down: DNS → Traefik (TLS) → the Coolify app for that domain → its peers over the internal network → its own database → external SaaS. Frozen boxes (green, 2px) never redeploy except by an intentional gated push.

Views — and what's not captured yet

A system architecture is a set of views, each answering one question; no single diagram says everything. Above are four (context · containers · dynamic flows · deployment). This is the honest map of what's drawn vs what the next iterations should add — so the picture grows deliberately, not by accretion.

ViewStatusQuestion it answers
Context (L1)✅ addedWho uses oll.am, and which external systems it depends on.
Container (L2)The deployable boxes + every real call (protocol · auth · direction): the system-design SVG, the component-level Diagram 1.2, and the precise relationship table.
Dynamic / sequenceThe key flows step-by-step (authed action · billing · guest product).
Deployment / runtime✅ addedHow the logical containers map to real infra: the VPS, one Coolify app per service, the internal Docker network, per-service managed-Postgres DBs, the routing to each service + where the freeze lives. (The new deployment view above.)
Trust & security boundariesPublic edge vs service-token-gated internals (the Model gateway answers only with a valid service token), where the Bearer JWT / an internal service token / secrets live, and the biometric-data boundary for the image product (DSGVO).
Failure modes / resilienceWhat degrades when a dependency is down — boot-gates, the billing self-heal, a suspended-database bounded boot, a provider 500 → typed error not a crash. (Each is real; none is drawn.)
Data lifecycle / retentionWhat each service stores + for how long: the webhook as sole writer of plan, the image product's delete-selfies-after-training, what's PII vs ephemeral.
Component (L3) — Model gatewayOne zoom-in: the provider switch (_PROVIDERS) + the single SDK boundary that delivers "any model."

This roadmap is deliberate: the picture grows view-by-view as the system does, rather than being drawn all at once.

Architecture Decision Records

Every load-bearing decision, why it was made, and what it costs. Accepted = locked & in effect · Proposed = recommended, not yet locked.

ADR-023 Inference placements — Cloud & Hosted today (On-device later) Accepted

Decision: "the model call" (the Model gateway) resolves to exactly three placements by where the call runs — not by vendor. External = a vendor cloud (a cloud LLM provider is the default), metered, our API key, runs on the vendor's infra. Internal = an open-weight model we self-host on our own server (a self-hosted model runtime), no per-call bill (flat box cost). On-device = the user's own machine (WebGPU in-browser · desktop · the user's own model endpoint) — zero marginal cost, data never leaves the device.
Naming rules (kill the drift): the model runtime is a runtime, not a placement — it appears under Internal (our server) OR On-device (the user's), never bare "local". "Pay once" is honest only for Internal + On-device (unmetered); External is metered → "no subscription / credit-covered", not "pay once per call". The cloud default = External + default, not local, not pay-once.
Status: the diagrams show the two placements we have todayCloud (external) → a cloud LLM provider, and Hosted (internal) → a self-hosted model runtime on our VPS. On-device is a future placement, left off the diagrams for now. Until the hosted runtime is wired, a request to the self-hosted provider without a reachable endpoint returns a clean 502 LLM_UNAVAILABLE. Maps onto the privacy tiers: Cloud=Turbo · Hosted=Balanced · On-device=Vault.

ADR-022 CI/CD = the dark factory — gates buy auto-merge, deploy stays intentional Proposed

Decision: path-filtered CI gates buy auto-merge to stage; deploy to main stays a deliberate, per-service promotion. Full design + reality-check → The Factory.

ADR-021 Image generation stays per-product — extract at the 2nd image consumer Accepted

Decision: the image-generation integration lives in the image product, not the Model gateway — for now.
Why: the image product is the only image-gen consumer today; routing it through the Model gateway would be shared infra for one caller — the infra-before-income trap text-ops avoided. Same 2nd-consumer trigger as ADR-020: extract only when a second image product needs it.
Clean split when it extracts: the Model gateway owns the dumb provider CALL + key custody (an image-gen entry in the provider switch, alongside the LLM providers); the image product keeps the LoRA pipeline + which-model + orchestration (the "prompt" equivalent — always product-side). Cost: a little duplication if a 2nd consumer lands before we extract — cheap to lift then.

ADR-020 Text-ops = ONE frozen Model gateway; prompts stay in products Accepted · Done

Decision: a text op = prompt (volatile, product-owned) + the call (provider switch · timeout · parse · caps · key custody — stable infra). The call extracts into ONE frozen Model gateway service; prompts stay server-side in each product. End state: the Identity service + the Model gateway = the two infra services.
Outcome: the Model gateway is a frozen infra service — every product service points at it; an env-driven provider switch defaults to a cloud provider.
Invariant: the shared layer never owns prompts — that's what keeps the gateway from becoming the rejected iteration-1 monolith.

ADR-019 Bind external confirmations to the order (verify ≠ authorize) Accepted

Decision: a "paid" from a verify endpoint confirms an event happened — the product must still bind it to THIS order (product + amount + currency), never trust paid alone.
Why: it closes a real underpayment exploit — a confirmation is not an authorization.

ADR-016 Frozen-service changes are additive-only Accepted

Decision: when a frozen service must grow (e.g. adding guest checkout), add NEW endpoints on a feat/ branch — existing routes stay byte-for-byte unchanged — reviewed before merge, never edited in place.
Why: the freeze holds because change is additive by construction, not by discipline.

ADR-015 Two product-client patterns: authed + guest Accepted

Decision: AUTHED products use the Identity service's existing Bearer endpoints; GUEST products use the additive no-login guest-checkout pair.
Why: accounts vs no-account are genuinely different flows; don't force one into the other.

ADR-014 Inline background jobs for now Accepted

Decision: run work inline; add a durable queue (Dramatiq) only when load demands.
Cost: a restart can lose an in-flight job (flagged for the photo pipeline) — revisit before high volume.

ADR-013 Webhook is the sole writer of plan; idempotent Accepted

Decision: only the payments webhook flips a user to pro; no in-app path may. Handle-or-ignore returns 200; verify signature; tolerate payload drift. Enforced in code and covered by a regression test (including the retry-storm case that must never 5xx).
Why: one source of truth for entitlement; no retry storms (4xx for bad input, never a 5xx).

ADR-012 The seam: nginx-split (web) vs base-URL interceptor (native) Accepted

Decision: web uses a same-origin nginx split (auth/billing/email → the Identity service, product → local); native uses a base-URL interceptor (same-origin /api on web, absolute origin on Capacitor).
Why: no CORS on web; one build serves both platforms.

ADR-011 Bearer header, not cookies Accepted

Decision: the JWT travels in Authorization: Bearer end to end.
Why: it's what fixes the Capacitor WebView cookie problem; the same seam works web + native.

ADR-010 Unified error envelope Accepted

Decision: every error is {code, message, request_id} from one registered handler set; no route hand-rolls its own body.
Why: the client always parses one shape across the Identity service and every product.

ADR-009 Contract-first (OpenAPI) + drift gate Accepted

Decision: openapi.yaml is the single source of truth → generated DTOs + typed clients → Schemathesis fuzz + a DTO-drift CI gate.
Why: the frozen contract is the seam; a spec/code mismatch fails the build, so each new client is configuration, not code.

ADR-008 Database-per-service on managed Postgres Accepted

Decision: every stateful service owns exactly one private managed-Postgres database — the Identity service and each product service own their own. No service connects to another's DB; cross-service data goes over HTTP (a product doesn't read the Identity service's user table — it calls GET /api/auth/me). Stateless services own no DB at all (the Model gateway relay holds no state). Supersedes: the earlier "managed Postgres for the Identity service only; products use filesystem/SQLite" — SQLite/filesystem is dropped, replaced by per-service managed Postgres.
Why: independent deploys + scaling, blast-radius isolation (one DB's problem can't corrupt another's), clean boundaries — the same frozen/disposable thesis. This is why the ownership FK was dropped when the Identity service took its own DB: ownership now keys off the JWT sub. Cost: no cross-service JOINs, eventual consistency over HTTP — the accepted, standard database-per-service tradeoff.

ADR-007 Identity in token, entitlement live from /me Accepted

Decision: the JWT carries who you are; plan is read fresh from /me (stale-while-revalidate cache), never baked into the token.
Why: mutable authz must not ride in a token; upgrades take effect without re-issuing. Grounded in springular (roles per-request) + ionstarter (live entitlement).

ADR-006 Passwordless sign-in; signed identity JWT Accepted

Decision: auth is passwordless sign-in only (single-use, hashed-at-rest, short-expiry token → find-or-create user → a signed JWT carrying sub+email).
Why: no passwords to store/leak; the JWT is identity only. Refresh-token revocation is decoupled (a session-longevity nicety, off the critical path).

ADR-005 main frozen / feat/ WIP Accepted

Decision: main = production, auto-deploy OFF; feat/<svc> = WIP, auto-deploy ON; merge = graduation.
Why: frozen-once-working is mechanical, not a matter of discipline.

ADR-004 Monorepo, Dockerfile-per-service, Coolify Base Directory Accepted

Decision: one repo; each service its own Dockerfile + Coolify app via Base Directory + Watch Paths, so only the changed service redeploys.
Why: unified version control, independent deploys; a stable service can't be disturbed by another's change.

ADR-003 Reuse-not-rebuild; lift-and-move, origin app first Accepted

Decision: the Identity service was lifted verbatim out of an existing app (not rewritten); the origin app becomes the Identity service's first HTTP client (strangler-fig).
Why: less surface to break; the origin app = the hardest real consumer, so it proves the seam. Copy verbatim wherever a working version exists.

ADR-002 The Identity service is frozen infrastructure Accepted

Decision: build the Identity service once, deploy once, never touch again (main = auto-deploy OFF, Watch Paths scoped to the service).
Why: a frozen money/identity layer can't be destabilised by product work. Cost: changes need the additive-extension discipline (ADR-016).

ADR-001 Identity service + product services over HTTP Accepted

Context: 6 products each duplicate auth/billing/email. Rejected: monolith + X-Product header (one bug crashes all), shared library (still per-product payments/email config), N-instances-by-env (Coolify env-leak).
Decision: a single frozen Identity service owns auth/billing/email; products call it over HTTP.
Cost: a network hop per call; mitigated by a same-origin seam + SWR caching.

Companion doc: CI/CD & deployment — how this system is tested, gated and shipped.