Titandocs

Overview

The four services, what each one is authoritative for, and the single direction data flows between them.

Titan splits into five processes, each authoritative for exactly one thing. When two components disagree, the table below decides who wins.

ComponentLanguageAuthoritative forNever does
Daml contractsDamlCollateral custody, vault state, batch manifestsMatching
EngineGoOrder matching, positions, funding, liquidationCustody
IndexerGoProjecting ledger + engine WAL into PostgresWriting to the engine
BFF (api/)TypeScriptRead aggregation, WS fan-out, signature pass-throughHolding keys
InterfaceReactPresentation, signing UXDeriving truth

Wiring

                     ┌──────────────┐
   REST + WS         │   Interface  │
  ┌──────────────────┤  React 19    │
  │                  └──────────────┘

┌────────────────┐  read SQL   ┌──────────────────┐
│  BFF (Fastify) ├────────────►│ Postgres         │
│  /v1           │             │ (indexer-owned)  │
└───┬────────┬───┘             └────────▲─────────┘
    │        │                          │ writes only
    │        │ SSE (bearer ADMIN_TOKEN) │
    │        └──────────────┐  ┌────────┴─────────┐
    │ signed REST proxy     │  │  Indexer (Go)    │
    ▼                       ▼  └────────▲─────────┘
┌────────────────────────────────┐      │ signed WAL firehose
│  Engine (Go) — matching        ├──────┘
│  /v1/orders /v1/session-auth   │
└───────────────┬────────────────┘
                │ merkle roots, batch manifests

        ┌────────────────┐        ┌──────────────────┐
        │ Canton ledger  │◄───────┤ Oracle (index px)│
        │ Daml contracts │        └──────────────────┘
        └────────────────┘

Postgres has exactly one writer. The indexer writes; the BFF reads with a read-only Drizzle client. This is why deploy order matters: indexer first, then api. The BFF's Drizzle queries emit explicit column lists, so shipping an api that expects columns a migration has not created yet fails immediately with column "…" does not exist.

Why a BFF at all

The engine speaks a signature-oriented, market-scoped API and the indexer speaks SQL. Neither is a good browser API. The BFF exists to:

Aggregate reads. One GET /v1/users/:party/positions instead of a join the browser has to write itself.

Fan out the firehose. The engine exposes a single admin SSE stream gated by a bearer token that must never reach a browser. The BFF holds one SSE connection and multiplexes it to every WebSocket client, filtered per subscription.

Recompose domain frames. Four in-process projections turn raw WAL events into things a UI can render directly — position.update, balance.update, orderbook.snapshot, candle.update.

Pass signatures through untouched. X-User, X-Session-Id, X-Session-Sig are forwarded verbatim. The BFF cannot forge them, so a compromised BFF cannot trade.

Degraded modes

The stack is designed to fail partially rather than totally.

MissingEffect
DATABASE_URLReads return empty lists; /readyz reports it. The UI renders empty, not broken.
ENGINE_ADMIN_TOKENSSE bridge does not start. WS clients connect and receive nothing.
Engine downReads still serve from Postgres. Placement 502s.
Indexer laggingReads serve stale data; /readyz exposes bff_engine_max_seq for the gap.

A staging incident worth remembering: a duplicate party hint made the engine return 502, and Cloudflare replaced the response with its own error page — which carries no CORS headers. The browser therefore reported a CORS failure for what was really a 502. Always check the origin status before believing a CORS error.

On this page