- The Identity service is frozen infrastructure — auth + payment + email, ~one deploy, never touched again. Everything else is product. Why: a change to identity/billing/email has the highest blast radius, so freezing it removes the largest class of outages.
- Product services call the Identity service over HTTP — they hold product logic only; zero auth/payments/email code lives in them. Why: fail-safe isolation — a crash or bad deploy in one product cannot cascade into another across an HTTP boundary.
- Each stateful service owns its own managed-Postgres database — the Identity service has its own; each product service gets its own. No service reaches into another's tables; cross-service data goes over HTTP (a product calls
/me, it does not read the Identity service's user table). Own a DB only if you persist — stateless services (the Model gateway) have none. Why: no shared state means no cascading data corruption; each service evolves its schema independently, and there are no cross-service joins — a service asks another over HTTP instead. - The webhook is the sole writer of
plan— no in-app path may set a user to pro. Enforced in code and covered by a regression test. - Identity in the token, entitlement live — the JWT carries who you are;
planis read fresh from/me. - The OpenAPI contract is the seam — frozen + Schemathesis-gated, so each new service is configuration, not code.
/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.
- Contract-first, fuzzed. The API is contract-first — the OpenAPI spec is the single source of truth — and every response is fuzzed with Schemathesis and gated in CI on every change, so a route can't drift from its contract.
- DTO-drift gate. The build regenerates the typed models from the spec and fails if code and contract diverge — the generated client and the server can't silently disagree.
- Docker-boot gate. CI builds the real production image and requires its health check to pass before merge — it exercises the container-init path (config, migrations, boot-gates) that unit tests never touch.
- Full suite, green, no bypass. A unit suite on the order of ~175 tests plus the contract suite run green in CI, lint is enforced, and no gate can be bypassed on the way to production.
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
System design view (L2) — every box a deployable container [tech]; every labelled arrow a real call, in its true direction
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
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
| Relationship | Endpoint | Protocol · auth | Purpose |
|---|---|---|---|
| Frontend → Identity service | /api/auth|billing|email/* | HTTPS · Bearer / guest | sign-in, checkout, plan status, email |
| Frontend → Product | /api/<product>/* | HTTPS · Bearer | the product action (improve, generate…) |
| Product → Identity service | GET /api/auth/me | HTTPS · Bearer (server-side) | identity + live plan (token-identity-not-entitlement) |
| Product → Model gateway | POST /api/text/complete | internal · service token | the model call (product sends its own prompt) |
| Model gateway → LLM provider | provider SDK / HTTP | HTTPS · 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 service | POST /api/billing/webhook | HTTPS · HMAC sig | the sole writer of plan (free→pro) |
| Product → image generation | provider API | HTTPS · API token | image generation (guest flow) |
| Identity service → its DB | managed Postgres | SQL · private | identity + subscriptions — its database alone |
| Product → its DB | managed Postgres · own DB | SQL · private | jobs/results; never reads another service's tables |
| Model gateway → (none) | — stateless | — | holds 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]
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]
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
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 → To | Call | Purpose |
|---|---|---|---|
| 1 | Agent → Identity service | GET /api/auth/me · Bearer | Identity + plan check (has the user paid?) |
| 2 | Agent → Retrieval service | POST /api/memory/query · service token | Retrieve the user's real context from their documents |
| 3 | Agent → Model gateway | POST /api/text/complete · service token | Compose the primary document from the retrieved context |
| 4 | Agent → Model gateway | POST /api/text/complete · service token | Compose a second document grounded in the user's real context |
| 5 | Agent → image generation | provider API · API token | Select the best image from the trained model |
| 6 | Agent → Identity service | POST /api/email/send | Email the complete package (PDF) to the user |
| 7 | Automation → social scheduler | scheduler 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
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.
@/* → VPS
→
Traefik TLS · Let's Encrypt · routes by domain
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.
| View | Status | Question it answers |
|---|---|---|
| Context (L1) | ✅ added | Who 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 / sequence | ✅ | The key flows step-by-step (authed action · billing · guest product). |
| Deployment / runtime | ✅ added | How 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 boundaries | ⬜ | Public 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 / resilience | ⬜ | What 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 / retention | ⬜ | What 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 gateway | ⬜ | One 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.
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 today — Cloud (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.
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.
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.
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.
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.
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.
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.
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.
plan; idempotent AcceptedDecision: 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).
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.
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.
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.
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.
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.
/me AcceptedDecision: 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).
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).
main frozen / feat/ WIP AcceptedDecision: main = production, auto-deploy OFF; feat/<svc> = WIP, auto-deploy ON; merge = graduation.
Why: frozen-once-working is mechanical, not a matter of discipline.
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.
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.
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).
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.