> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sesameterminal.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Architecture overview

# Architecture overview

This document explains how the parts of sesame-bun fit together. It is derived from
[PLAN.md §3](../PLAN.md#3-architecture), which stays authoritative; the conventions that
enforce it are in [CLAUDE.md](../../CLAUDE.md). The reasons behind the main choices are in
the [ADRs](../adr/index.md). The browser protocol is in
[browser-protocol.md](browser-protocol.md).

## Components

sesame-bun is one Go process and one browser app. The backend holds all state, all
secrets and all venue connections. The browser talks only to the backend, never to
Polymarket.

```text theme={null}
+----------------------------- Browser (one tab) ------------------------------+
|  React app: TanStack Query (REST data), Zustand stores (live WS data)        |
+-------------------+--------------------------------------+-------------------+
                    | REST /api/*                          | WebSocket /ws
                    |  (dev: via Vite on :5173)            |  (one per tab)
+-------------------v--------------------------------------v-------------------+
|  Go backend, 127.0.0.1:8080                                                  |
|                                                                              |
|  httpapi (generated server) -- auth (password, sessions, CSRF)               |
|       | request/reply                          hub (subscriptions,           |
|       v                                             snapshot + deltas)       |
|  +---------+ +--------+ +------+ +-----------+ +--------+      ^             |
|  | catalog | | orders |>| risk | | portfolio | | alerts |      |             |
|  +---------+ +--------+ +------+ +-----------+ +--------+      |             |
|  +----------------------+ +---------------------+              |             |
|  | books (one per token)| | games               |              |             |
|  +----------------------+ +---------------------+              |             |
|        ^  all actors publish and subscribe on typed topics     |             |
|  ======+====================== bus ============================+====         |
|        ^                                        |                            |
|  feed actors (market, sports, user)       store.Writer ---> SQLite (DATA_DIR)|
|  credential actor                                                            |
|        ^                                                                     |
|  venue adapters (implement core interfaces) -- notify (Telegram)             |
+--------+---------------------------------------------------------------------+
         | HTTPS / WSS
+--------v---------------------------------------------------------------------+
|  Polymarket: Gamma, CLOB REST, Market/User/Sports WS, Data API,              |
|  geoblock endpoint                                                           |
+------------------------------------------------------------------------------+
```

Phase 1 builds `catalog`, `books`, `games`, `hub` and the market and sports feed actors.
Phase 2 adds `engine/portfolio`, `engine/orders` (read-only: open-order state), the
Polymarket credential actor and the user feed. Phase 3 adds `engine/risk`, the order write
path in `engine/orders`, the `order_audit` log, order signing and the optional heartbeat
actor. Phase 4 adds `engine/alerts`, `notify` with the Telegram client, the fill backfill
in `engine/orders` and the cash ledger in `engine/portfolio` (see
[Alerts and notifications](#alerts-and-notifications-phase-4) and
[Fill backfill and cash ledger](#fill-backfill-and-cash-ledger-phase-4)). Phase 6 adds the
embedded web UI, backups and restore, and the known-device cookie (see
[Serving and operations](#serving-and-operations-phase-6)). Phase 8 adds the Combos adapter, the combo quote
and Accept path in `engine/orders` and `engine/risk`, and the combo positions actor in
`engine/portfolio` (see [Combos](#combos-phase-8)).

In development the Vite dev server on `http://localhost:5173` serves the frontend and
proxies `/api` and `/ws` to the backend. From Phase 6 the production build embeds the
built frontend in the Go binary, and a TLS reverse proxy sits in front of it.

Backend packages (`backend/internal/`):

| Package | Role |
| - | - |
| `app` | Wiring, config loading and validation, startup geoblock check, supervision tree, OS signals |
| `actor` | Mailbox, request/reply and supervised-run helpers |
| `bus` | Typed publish/subscribe topics |
| `core` | Venue-agnostic domain types, the venue capability interfaces and the feed and account bus topics |
| `venue/polymarket` | Gamma, CLOB, CLOB auth and the credential actor, signing (ClobAuth, L2 HMAC, orders), order placement and cancels, the heartbeat actor, WS feeds (market, sports, user), Data API |
| `venue/polymarket/combos` | Phase 8. The Combos adapter: quote requests, Accepts and status reads on the Requester API, and combo positions and activity from the Data API. Signs through `venue/polymarket/signing` |
| `engine/books` | One book per token, built from the market feed; the only source of displayed prices |
| `engine/games` | Live game state, seeded from Gamma and updated by the sports feed |
| `engine/orders` | Open orders from the user feed, reconciled over REST after a reconnect; from Phase 3 the only path that submits or cancels orders, and the writer of `order_audit` rows |
| `engine/risk` | Phase 3. The trading gates, SAFE/ARMED, the risk limits and the clock-skew check; answers every order before it is signed |
| `engine/portfolio` | Positions, cash and their valuation against the local books; exit prices |
| `engine/alerts` | Phase 4. Price, spread, score, game start and game end alerts, stored in SQLite and checked against `engine/books` and `engine/games` |
| `catalog` | Market and event metadata cache and the FTS5 search index, refreshed from Gamma in the background |
| `store` | SQLite, migrations, repositories, the single writer; from Phase 6 backups (with a MAC) and restore |
| `httpapi` | Generated strict server and its handlers |
| `hub` | Browser WebSocket: subscriptions, snapshots, deltas, coalescing |
| `auth` | Password check, sessions, CSRF |
| `geoblock` | The geoblock check actor |
| `notify` | Phase 4. The notify actor (bus events to stored notifications) and the outbound-only Telegram client; the in-app, desktop and sound channels are the browser's, reached through `hub` |
| `webui` | Phase 6. Serves the Vite bundle embedded in the binary; absent in dev, where Vite serves the UI |

## Actors and the bus

An actor is a goroutine that owns its state and reads messages from a mailbox channel.
No other code reads or writes that state. Code that needs something from an actor sends
it a message; a request carries a reply channel and a `context.Context` for the timeout.

The `thejerf/suture/v4` supervisor runs every long-lived actor and restarts a crashed one
with backoff. Feed actors reconnect by themselves, never exit on a transient error, and
publish `FeedStatus` events. The UI uses `FeedStatus` to grey out stale data.

The bus carries typed topics. Each subscriber has a bounded buffer. A subscriber that
falls behind drops messages and resyncs from a snapshot; it never blocks the publisher or
other subscribers. The feed topics are declared in `core/venue.go` and the account topics
in `core/account.go`:

| Topic | Type | Published by | Consumed by |
| - | - | - | - |
| `core.book_events` | `core.BookEvent` | Market feed actor | `engine/books` |
| `core.games` | `core.Game` | Sports feed actor | `engine/games` |
| `core.feed_status` | `core.FeedStatus` | Every feed actor | `engine/books`, `engine/games`, `hub` |
| `core.orders` | `core.Order` | User feed actor | `engine/orders` |
| `core.fills` | `core.Fill` | User feed actor | `engine/orders`, `engine/portfolio`, `store.Writer` (`fills` table) |
| `core.account_resync` | `core.AccountResync` | User feed actor, after a reconnect | `engine/orders`, `engine/portfolio` |
| `core.credential_status` | `core.CredentialStatus` | Credential actor | The user feed, `engine/risk`, `hub` (through the account summary) |
| `core.trading_state` | `core.TradingState` | `engine/risk`, on every change | `hub` (`trading` topic) |

The consumers of the account and trading topics are the intended wiring; the exact set is
defined by implementation. Phase 4 adds alert topics.

The main actors:

* **Books (`engine/books`):** one book per token. It applies the market feed's snapshots
  and level changes, tick size changes and trade prints. When the feed disconnects or
  resyncs, it marks the affected books stale until a fresh snapshot arrives. It asks the
  market feed to watch a token when the hub first needs it; when it stops watching is
  defined by implementation. See [ADR 0007](../adr/0007-prices-only-from-market-feed.md).
* **Games (`engine/games`):** the state of every game on the board. Each game is seeded
  once from its Gamma event, and again after a sports feed reconnect; otherwise only
  sports feed changes update it. It never polls Gamma for scores. See
  [ADR 0006](../adr/0006-game-state-seeded-snapshot.md).
* **Catalog (`catalog`):** refreshes event and market metadata from Gamma in the
  background into the metadata cache tables, and serves search, event listings and
  market detail from them. It holds no prices.
* **Hub (`hub`):** one per backend. It tracks each browser socket's topics, sends a
  snapshot on subscribe and then deltas with a per-topic sequence number, coalesces
  updates to about 10 per second per topic, and closes a socket whose send queue fills.
* **Credential actor (`venue/polymarket`, Phase 2):** owns the CLOB API credentials. It
  uses `POLYMARKET_CLOB_*` when set, or derives them at startup through L1 auth with the
  Session Key, and holds them in memory only
  ([ADR 0009](../adr/0009-venue-credentials-in-memory.md)). It is the single source of the
  credential state and publishes each change on `core.credential_status`: `none`,
  `pending`, `ready`, or `failed` with a fixed reason code (`no_signer`, `derive_failed`,
  `auth_rejected`, `geoblocked`, `network`, and from Phase 3 `wallet_mismatch` when the
  session-signers check finds the key is not a signer of the configured wallet). From
  Phase 3 it also reads the key's expiry from that check. Calls that need credentials
  return `core.ErrNoCredentials` while they are not available, which the API maps to 503
  `credentials_unavailable`.
* **User feed (`venue/polymarket`, Phase 2):** a feed actor on the venue's user channel,
  implementing `core.UserFeed`. It needs credentials, streams the account's order and fill
  events onto `core.orders` and `core.fills`, and publishes `FeedStatus` like the other
  feeds. The venue does not replay missed events, so after each reconnect it publishes
  `core.AccountResync` and consumers re-read state through `core.Account`.
* **Orders (`engine/orders`):** in Phase 2, the open-order state: a REST snapshot of open
  orders at startup and after every `AccountResync`, then changes from the user feed only
  (PLAN §3.7). It feeds the `orders` topic and the open-order counts. It tracks the sports
  `delayed` state, during which an order cannot be cancelled. From Phase 3 it is also the
  only path that submits orders and cancels (see [Order write path](#order-write-path-phase-3)):
  it writes the intent to `order_audit`, asks risk, writes the decision, calls the venue
  and writes the response. It reconciles `unknown` placements from the venue's open
  orders and trades instead of re-sending them, queues cancels for orders in a sports
  delay window (`cancel_pending`), runs the open-orders drift check, and applies the
  `cancel_on_shutdown` setting when the app stops.
* **Risk (`engine/risk`, Phase 3):** the single place that decides whether an order may
  go out. It owns the SAFE/ARMED state in memory (every start is SAFE) and publishes
  `core.TradingState`. On every order it checks `LIVE_TRADING`, ARMED, the geoblock
  status, credentials, clock skew against the venue's server time, foreign activity, the
  market's state, tick and minimum size, the price band against a fresh book (exits
  exempt, capped by `exit_slippage_ticks`), and the order, position, daily-notional,
  open-order, order-rate and duplicate-intent limits. Limits come from settings, capped by
  `RISK_CEILING_*` environment ceilings. It drops to SAFE on idle timeout, logout,
  geoblock, credential loss and foreign activity. It never blocks a cancel.
* **Heartbeat (`venue/polymarket`, Phase 3, optional):** implements `core.Heartbeat`.
  While `heartbeat_enabled` is on it posts a heartbeat to the venue every few seconds;
  if the venue receives none for about 10 s it cancels every open order of this Session
  Key's credentials. Off by default.
* **Portfolio (`engine/portfolio`, Phase 2):** positions from the Data API and cash from
  the CLOB balance endpoint, each refetched when a fill arrives and every 60 s; positions
  are never derived locally from fills. It values each open position against the local
  books from `engine/books`: liquidation value walking the bids, unrealized P\&L, and a
  stale flag when a book is stale or missing. Updates are coalesced to at most one per
  second per position. It feeds the `positions` and `account` topics.
* **Alerts (`engine/alerts`, Phase 4):** one actor that owns every alert. See
  [Alerts and notifications](#alerts-and-notifications-phase-4).
* **Notify (`notify`, Phase 4):** one actor that turns the bus events of the actors owning
  each signal into notifications, and the Telegram client actor.
* **store.Writer:** the only code that writes to SQLite.

Details and trade-offs: [ADR 0001](../adr/0001-actors-and-event-bus.md).

### Live data path (Phase 1)

```text theme={null}
Polymarket Market WS --> MarketFeed --core.book_events--> engine/books --> hub --> book:<token_id>
Polymarket Sports WS --> SportsFeed --core.games--------> engine/games --> hub --> game:<game_id>
Gamma (seed, once per game and after a sports reconnect) ---^
every feed actor --core.feed_status--> engine/books, engine/games, hub --> feeds, status
Gamma (background refresh) --> catalog --> metadata cache --> REST search, events, markets
```

### Account data path (Phase 2)

```text theme={null}
Session Key --L1 auth--> credential actor --core.credential_status--> user feed, account
Polymarket User WS --> UserFeed --core.orders, core.fills--> engine/orders --> hub --> orders, fills
                                 \--core.account_resync--> engine/orders (REST open orders)
                                                           engine/portfolio (refetch)
Data API positions + CLOB cash (on fill, every 60 s) --> engine/portfolio --> positions, account
engine/books (local books) --------------------------------^ valuation
Data API activity and P&L --> REST /activity, /portfolio/pnl (no credentials needed)
```

Which actor relays fills to the hub, and which one assembles the account summary, are
defined by implementation.

### Order write path (Phase 3)

```text theme={null}
browser --POST /api/orders/place {client_order_id, ...}--> httpapi (session, CSRF, Origin)
  --> engine/orders
        1. repeated client_order_id?  --yes--> return the first result, send nothing
        2. store.Writer: order_audit  intent row
        3. engine/risk: gates and limits (request/reply)
        4. store.Writer: order_audit  decision row
        5. approved only: core.Trading.Place --> venue/polymarket/signing --> CLOB POST /order
        6. store.Writer: order_audit  response row (accepted, rejected or unknown)
  <-- PlaceOrderResponse
unknown --> reconcile from CLOB open orders and trades --> further order_audit row
User WS order and trade events --> engine/orders --> hub --> orders, fills
engine/risk --core.trading_state--> hub --> trading
Data API activity not in order_audit --> alert, SAFE (foreign_activity)
```

* **One path.** Clicks, drags, hotkeys, Exit, re-post and replace all enter
  `engine/orders`, and every placement passes `engine/risk` at send time. A replace is a
  cancel, then, once the cancel is confirmed, a new placement.
* **No automatic re-send.** A timeout is `unknown` and is reconciled, never retried
  ([ADR 0011](../adr/0011-audit-first-order-path.md)). Batches of up to 15 orders are
  sent once and recorded per order.
* **Cancels** go through `engine/orders` without a risk check, so Cancel All works in
  SAFE and while trading is off. A cancel for an order in a sports delay window is queued
  and sent when the window ends.
* **Signing** happens inside the venue adapter from the risk-approved intent, never from
  the request body. `venue/polymarket/signing` builds the V2 order for the CTF or Neg
  Risk exchange, the Deposit Wallet ERC-7739 wrapper and the Session Key wrapper, and
  signs only CLOB orders and `ClobAuth` typed data
  ([ADR 0010](../adr/0010-go-ethereum-for-signing.md)).
* **Clock skew** is measured against the CLOB's server time (`core.Trading.ServerTime`) at
  startup and periodically; the interval and threshold are defined by implementation.

### Audit log

`order_audit` is the record of every order the app attempted
([ADR 0011](../adr/0011-audit-first-order-path.md)):

* Intent, decision and response are separate rows keyed by `client_order_id`. The key is
  unique, which is what makes a repeated request return the first result.
* `BEFORE UPDATE` and `BEFORE DELETE` triggers abort any change; only `store.Writer`
  inserts.
* Each row carries a hash chained to the previous row; `task audit-verify` checks the
  chain.
* Rows hold a hash of the signed payload, never a signature, L2 header or key.
* Foreign-activity detection compares the account's activity from the Data API with this
  table (security F5).

### Alerts and notifications (Phase 4)

An **alert** is a condition the user sets; a **notification** is a message the app sends.
Alerts never place or cancel orders, so they work in Safe and with trading off.

```text theme={null}
browser --POST/PUT/DELETE /api/alerts--> httpapi --> engine/alerts --store.Writer--> alerts table
engine/alerts --Books.Hold(tokens of active price and spread alerts)--> engine/books --> Market WS
books.updates, games.updates, feed status --> engine/alerts (never fires on a stale book or feed)
engine/alerts --alerts.changes--> hub --> alerts
engine/alerts --alerts.fired------------------------------------\
orders.fills, orders.results, orders.placements,                 |
orders.combo_outcomes, core.trading_state, risk.limits,          +--> notify --store.Writer--> notifications table
feed status, core.credential_status, games.updates,              |      |
auth.login_breaker ----------------------------------------------/      +--notify.notifications--> hub --> notifications
                                                                        |      (browser: in-app, desktop, sound)
                                                                        +--> Telegram actor --> Bot API sendMessage
```

* **`engine/alerts`** is one actor that owns every alert (at most 200). Kinds: `price`,
  `spread`, `score`, `game_start`, `game_end`. A price alert above watches the best bid,
  below the best ask; the threshold sits on the market's tick. It keeps the books of its
  active price and spread alerts subscribed by holding their tokens in `engine/books`, so
  those books come from the same Market WS as every other price (ADR 0007). A repeating
  alert re-arms once the value moves one tick back across its threshold, and fires at most
  once a minute. Alerts persist with their trigger state and resume after a restart without
  firing on the first snapshot unless the condition newly holds. A stale book or sports
  feed never fires an alert, and nothing fills the gap from elsewhere.
* **`notify`** is one actor. It only subscribes to the topics of the actors that own each
  signal and never polls a venue (PLAN §3.7). For each notification it writes a row through
  `store.Writer`, publishes it on `notify.notifications`, and hands it to Telegram when the
  kind's rule has Telegram on. The browser owns the in-app, desktop and sound channels: it
  applies the same rules to what arrives on the `notifications` topic. History is kept 30
  days. Kinds: `alert`, `fill`, `order_rejected`, `order_unknown`, `order_resolved`,
  `game_start_orders`, `feed_down` (after 30 s) and `feed_restored`, `foreign_activity`,
  `trading_safe`, `session_key_expiring` (24 h ahead), `credentials` and
  `credentials_restored`, `shutdown_cancel_failed` and `login_breaker` (Phase 6),
  `combo_filled` and `combo_failed` (Phase 8), and `test`. `foreign_activity` and
  `credentials` always go in-app and to Telegram; the settings cannot turn those off.
* **The Telegram client** is its own actor and is **outbound only**: `sendMessage` as plain
  text to one chat, never `getUpdates` and no webhook. The token appears only in the request
  path, never in a log line or an error. It follows no redirects.
  * **Rate limit:** at most 20 messages a minute. Ordinary messages may use three quarters
    of that; over their share they are folded into one summary message, which also counts
    any message a full queue dropped.
  * **Critical first:** critical notifications go ahead of ordinary ones, one by one.
    `foreign_activity` and `credentials` go ahead of the other critical kinds (`feed_down`,
    a failed fill, `shutdown_cancel_failed`, `login_breaker`).
  * **Retries:** one retry on 429, after `retry_after` if it is at most a minute; never after
    a timeout, since a duplicate is worse than a gap.
* **Placements.** `engine/orders` publishes the first result of each placement on
  `orders.results` and, for a placement whose outcome was unknown, the reconciled final
  result on `orders.placements`. The hub serves the latter as the `placements` topic (the
  outcomes settled in the last 10 minutes). Order notifications carry the placement's
  `client_order_id`, so the tab that clicked shows one set of toasts for it.

### Fill backfill and cash ledger (Phase 4)

Two checks keep the account figures honest across feed gaps and restarts.

* **Fill backfill (`engine/orders`).** The user feed does not replay what it missed while
  it was down or the app was stopped. At every open-orders snapshot (start, reconnect,
  `AccountResync`, a credential change) `engine/orders` also reads the venue's trades
  (`GET /data/trades`) since the newest fill it has seen, less a 2-minute overlap, and
  publishes the ones it has not seen on `core.fills`, the user feed's own topic. A fill is
  seen once per trade id, order id and status. The newest fill time and the fills seen in
  the overlap persist in `activity_watch`, so a restart backfills the time it was stopped.
  The first start ever takes its start as the mark. Snapshot and backfill are the same
  source as the feed for PLAN §3.7: one fills topic, deduplicated.
* **Cash ledger (`engine/portfolio`).** A raw pUSD transfer signed with the Session Key may
  not appear in the account activity, so every change between two cash readings (the CLOB
  balance) must be explained:

  * trade cash comes only from the fills on `core.fills` (costs and fees out, proceeds
    in), and from Phase 8 from this app's filled combo Accepts;
  * all other cash comes only from the Data API activity entries with an amount that are
    not trades (redeems, rewards, transfers, splits, merges, conversions);
  * a trade's activity row never explains cash, and a rise is never set against a drop.

  Each change waits `cashWindow` (11 minutes: the Data API's indexing lag plus one refresh)
  before it settles. What stays unexplained adds up; above \$1 it is reported as foreign
  activity, which switches trading to Safe. The last reading and the unsettled ledger
  persist in `activity_watch`, so a restart or a read gap is reconciled as one interval.

### Serving and operations (Phase 6)

* **Embedded web UI (`webui`).** The production build copies the Vite bundle into the
  binary. The same server answers `/api` and `/ws` and serves the bundle for every other
  path: hashed files under `/assets/` with a year-long immutable cache, other files
  revalidated by ETag, and client-side routes with `index.html`. Content types come from a
  fixed table, not the OS. Without a bundle (dev, where Vite serves the UI) non-API paths
  answer 404. Caddy terminates TLS in front of it; `TRUSTED_PROXY` names the proxy whose
  `X-Forwarded-For` names the client, which the per-client login limits use; without it no
  proxy header is trusted. The deploy itself is in the
  [deploy guide](../guides/deploy/index.md).
* **Backups and restore (`store`).** A backup actor writes a consistent copy with
  `VACUUM INTO` every `BACKUP_INTERVAL` (default 6 h; 0 turns it off) to
  `DATA_DIR/backups/sesame-<UTC time>.db`, keeps `BACKUP_KEEP` (default 28), and never
  blocks `store.Writer`. Each backup has a MAC file keyed from `SESSION_SECRET`.
  `sesame backup` writes one by hand. `sesame restore --from <file>` runs only while no
  server holds the data directory. It refuses a backup that fails the integrity check,
  whose schema differs from what the migrations build, or whose audit chain does not
  verify. Without `--accept-unverified-backup` it also refuses one whose MAC fails or
  whose audit chain is not a prefix of the current one. It moves the current database
  aside rather than deleting it, and clears login sessions and known-device cookies.
* **Known-device cookie (`auth`).** After a login the browser gets a `sesame_device`
  cookie (`__Host-sesame_device` with `COOKIE_SECURE`), valid 180 days and renewed at each
  login. It authenticates nothing. It lets a known browser keep its own login limit when the
  login breaker refuses unknown devices after failed logins from many addresses. The
  breaker publishes `auth.login_breaker`, which becomes a `login_breaker` notification.
  The cookie's MAC covers `settings.device_generation`; logout-all and a restore increase
  it, which revokes every known-device cookie.
* **Shutdown.** A first stop drains HTTP and applies `cancel_on_shutdown` within a 28 s
  budget, under the container's 30 s stop grace. A cancel-on-shutdown that failed is
  reported at the next start as `shutdown_cancel_failed`.

Bus topics added in Phases 4 and 6:

| Topic | Type | Published by | Consumed by |
| - | - | - | - |
| `alerts.changes` | `alerts.Change` | `engine/alerts`, on every change of an alert | `hub` (`alerts` topic) |
| `alerts.fired` | `alerts.Fired` | `engine/alerts`, once per trigger | `notify` |
| `orders.results` | `orders.Result` | `engine/orders`, the first result of each placement | `notify` |
| `orders.placements` | `orders.SettledChange` | `engine/orders`, when reconciliation settles an unknown placement | `hub` (`placements` topic), `notify` |
| `notify.notifications` | `notify.Change` | `notify`, per notification, and a reset after read marks or pruning | `hub` (`notifications` topic) |
| `auth.login_breaker` | `auth.BreakerTripped` | `auth`, at most once per login window | `notify` |

### Combos (Phase 8)

A combo is one position on 2 to 50 legs, priced only by Polymarket's market makers through
a quote request (RFQ). The user requests a quote and accepts it; the backend signs an
Exchange V3 order for the quote's amounts. There is no combo book and no requester feed.
Decisions: [ADR 0014](../adr/0014-combos-requester-api.md) (Requester API, CLOB L2 only),
[ADR 0015](../adr/0015-combo-accept-path.md) (the Accept path) and
[ADR 0016](../adr/0016-combo-ids-and-eligibility.md) (ids and eligibility).

```text theme={null}
browser --POST /api/combos/quote {client_order_id, leg_token_ids, notional}--> httpapi
  --> engine/orders (quote pipeline)
        1. catalog: leg token --> Gamma position id, market, event
        2. order_audit intent row
        3. engine/risk CheckQuote (gates and combos scope, not Armed; quote cap; legs; limits)
        4. order_audit decision row
        5. approved only: core.Combos.Quote --> Requester API POST /requests (sent once)
        6. check the answer echoes the request; order_audit response row
        7. hold the quote in memory by rfq_id (10 min) --orders.combo_rfqs--> hub --> combo_rfqs
browser --POST /api/combos/accept {client_order_id, rfq_id, quote_id}--> httpapi
  --> engine/orders (Accept pipeline)
        1. load the held quote; lock it to this Accept; reserve its amount
        2. order_audit intent row; engine/risk CheckCombo (Armed and limits); decision row
        3. refuse at or past expires_at without a venue call
        4. core.Combos.SignAccept --> venue/polymarket/signing (Exchange V3, amounts unchanged)
        5. order_audit signed row (order hash, payload hash)
        6. core.Combos.SendAccept --> Requester API POST /requests/{rfq_id}/accept (sent once)
        7. order_audit response row: executing, filled, rejected, expired or unknown
executing or unknown --> GET /requests/{rfq_id} every 500 ms until final --> reconcile row
final outcome --orders.combo_outcomes--> notify (combo_filled, combo_failed)
                                     \--> engine/portfolio (cash ledger, combo positions refetch)
Data API /v2/positions/combos (every 60 s, after a fill) --> portfolio combo positions actor
  --portfolio.combo_positions--> hub --> combo_positions; engine/portfolio (combo cost basis)
Data API /v2/activity rows with is_combo --> foreign-activity check (matched by tx_hash)
```

The actors and what they own:

* **`venue/polymarket/combos`** implements `core.Combos` (`Quote`, `SignAccept`,
  `SendAccept`, `Status`) and `core.ComboAccount` (combo positions and activity). It takes
  the L2 credentials and the scopes from the Predictions credential actor, accepts only the
  pinned Requester API host, and never repeats a quote request or an Accept.
* **`engine/orders`** owns this app's quote requests of the last 10 minutes (memory only),
  the idempotency cache of quote and Accept `client_order_id`s, the Accept reservations and
  the status reads. At start it resumes the status reads of every Accept with a signed row
  and no final row. Its `ComboAccepts` list feeds the foreign-activity check.
* **`engine/risk`** adds `CheckQuote` and `CheckCombo`, the `max_combo_notional`,
  `max_combo_exposure` and `max_combo_legs` limits with their `RISK_CEILING_COMBO_*`
  ceilings, the local cap of 12 quote requests a minute, and `combos_can_trade` and
  `combos_reasons` in `core.TradingState`.
* **`engine/portfolio`** runs a combo positions actor that reads open combo positions at
  start, shortly after a fill and every 60 s, values them at cost, and publishes
  `portfolio.combo_positions`. The account summary adds `combo_cost_basis` to exposure,
  not to liquidation value or unrealised P\&L. The cash ledger counts a filled Accept's
  cost or proceeds once.
* **`notify`** subscribes to `orders.combo_outcomes` and sends `combo_filled` or
  `combo_failed` once per Accept.

Bus topics added in Phase 8:

| Topic | Type | Published by | Consumed by |
| - | - | - | - |
| `orders.combo_rfqs` | `orders.ComboChange` | `engine/orders`, on every change of a quote request | `hub` (`combo_rfqs` topic) |
| `orders.combo_outcomes` | `orders.ComboOutcome` | `engine/orders`, once per Accept when final | `notify`, `engine/portfolio` (cash ledger, combo positions) |
| `portfolio.combo_positions` | `portfolio.ComboChange` | the combo positions actor | `hub` (`combo_positions` topic), `engine/portfolio` (summary) |

One source per signal (PLAN §3.7):

| Signal | Single source |
| - | - |
| Combo eligibility and leg position ids | Gamma `comboStatus` and `positionIds` in the metadata cache (`markets.combo_eligible`, `outcomes.position_id`). The public RFQ catalog is never read |
| Quote request state | The Requester API's create answer, its accept answer, then `GET /requests/{rfq_id}`. No feed; a quote nobody accepted expires without a venue call |
| Combo positions | Data API `/v2/positions/combos`; never derived from RFQs |
| Combo activity | Data API `/v2/activity/combos`, history only |
| Combo trades | Data API `/v2/activity` rows with `is_combo`, matched to this app's Accepts by `tx_hash` |
| Leg prices | Market WS through `engine/books`; no combined price is computed before a quote |

## Venue boundary

`backend/internal/core` defines venue-agnostic types and small interfaces. It imports
nothing from `venue/`. Phase 1 defines these in `core/market.go` and `core/venue.go`:

| Interface | Covers |
| - | - |
| `Discovery` | Events (filtered, keyset paged), event and market detail, leagues, teams, and the events linked to a game |
| `MarketData` | Price history for one token |
| `MarketFeed` | Feed actor: streams book events for watched tokens onto `core.book_events`; `Watch` and `Unwatch` change the token set without reconnecting |
| `SportsFeed` | Feed actor: publishes game changes onto `core.games` |

Types: `Event`, `Market`, `Outcome`, `Book`, `Level`, `Trade`, `PricePoint`, `League`,
`Team`, `Game`, `BookEvent`, `LevelChange` and `FeedStatus`. Money fields are
`core.Micros`.

The venue adapter's feed actors follow the same rules:

* `Run(ctx)` connects, reconnects on its own after any transient error, publishes
  `FeedStatus` on every change of state, and returns only when `ctx` ends.
* After a reconnect, the market feed publishes a `resync` book event and then a fresh
  `snapshot` for every watched token, so `engine/books` can rebuild each book from the
  same feed.
* The sports feed sends changes only; the engine seeds games through `Discovery`.
* The market feed also reports `trade`, `tick_size`, `resolved` and `unwatched` events.

Phase 2 adds, in `core/account.go`:

| Interface | Covers |
| - | - |
| `Account` | `Positions` (by status), `Activity` (paged), `PnL` (by interval), `OpenOrders` and `Cash`. The first three need only the account address; `OpenOrders` and `Cash` need API credentials and return `core.ErrNoCredentials` without them |
| `UserFeed` | Feed actor: streams the account's orders and fills onto `core.orders` and `core.fills`, and publishes `core.AccountResync` after a reconnect |

Types: `Position`, `Order`, `Fill`, `Activity`, `ActivityPage`, `PnLPoint`,
`AccountResync` and `CredentialStatus`, with their status enums.

Phase 3 adds, in `core/trading.go`:

| Interface | Covers |
| - | - |
| `Trading` | `Place` (up to 15 intents per request, results in intent order), `Cancel`, `CancelMarket`, `CancelAll`, and `ServerTime` for the clock-skew check. It signs with the configured signer and never retries a placement: a timeout returns `PlaceUnknown` |
| `Heartbeat` | Optional dead-man switch actor: while it runs, the venue cancels the account's open orders if heartbeats stop |

Types: `OrderIntent` (with `ClientOrderID`, `ReduceOnly`, and `NegRisk` and `TickSize`
copied from the market by the engine), `PlaceResult`, `PlaceStatus` (`accepted`,
`rejected`, `unknown`), `Reject` and `RejectCode`, `CancelResult` and `TradingState`.
`core.Order` gains `ClientOrderID` and `CancelPending`.

Phase 8 adds, in `core/combos.go`:

| Interface | Covers |
| - | - |
| `Combos` | `Quote` (one quote request, never repeated), `SignAccept` (signs the held quote without sending), `SendAccept` (sends once; a lost answer is `unknown`) and `Status` (reads an accepted RFQ; wraps `ErrComboNotAccepted` when none was recorded) |
| `ComboAccount` | Combo positions (open or resolved) and combo activity; needs only the account address |

Types: `ComboLeg`, `ComboQuoteRequest`, `ComboQuote`, `ComboRFQ` and `ComboRFQStatus`,
`SignedComboAccept`, `ComboAcceptResult`, `ComboPosition`, `ComboActivity`. `Outcome`
gains `PositionID`, `Market` gains `ComboEligible`, `Activity` gains `Combo`,
`CredentialStatus` gains `Scopes`, and `TradingState` gains `CombosCanTrade` and
`CombosReasons`.

Each venue adapter lives in `backend/internal/venue/<name>/` and implements the
interfaces it supports. Venue wire types stay inside the adapter package and are
converted to `core` types at its edge. IDs that cross the boundary are namespaced:
`pm:<tokenId>`, `pm:<conditionId>` for markets, `pm:<gameId>` for games,
`pmc:<combo conditionId>` and `pmc:<combo positionId>` for combos (their legs stay `pm:`
outcomes), `yeet:<id>`.
Engine code depends on the `core` interfaces only, never on `venue/polymarket`.

Polymarket signing and EIP-712 code exists once, in `venue/polymarket/signing`, and is
shared by the Predictions and Combos adapters.

Details and trade-offs: [ADR 0004](../adr/0004-venue-capability-boundary.md).

## Browser and backend protocol

`api/openapi.yaml` (OpenAPI 3.0.3) defines every REST endpoint and WebSocket message.
`task gen` generates the Go server into `backend/internal/httpapi/gen` and the TypeScript
client into `frontend/src/lib/api/gen`. Generated code is never edited by hand.

In short:

* REST carries commands and queries under `/api`. Money, prices and sizes are decimal
  strings; the backend computes in fixed-point integers (`core.Micros`).
* Mutating requests need the `sesame_session` cookie and an `X-CSRF-Token` header.
* Errors return `{code, message}` with a stable `code`.
* Public `GET /api/health` reports liveness only; `GET /api/status` needs a session
  ([ADR 0008](../adr/0008-public-health-authenticated-status.md)).
* Live data goes over one WebSocket per tab at `/ws`. The client subscribes to topics
  (`book:<token_id>`, `game:<game_id>`, `feeds`, `status`, and from Phase 2 `account`,
  `positions`, `orders`, `fills`, from Phase 3 `trading`, from Phase 4 `placements`,
  `alerts` and `notifications`, and from Phase 8 `combo_rfqs` and `combo_positions`), gets
  a snapshot, then deltas with a per-topic `seq`. A gap makes the client resubscribe the
  topic.
* Account endpoints that need venue credentials answer 503 `credentials_unavailable`
  without them.
* Phase 3 adds the trading commands (`/orders/place`, `/orders/cancel`,
  `/orders/cancel-market`, `/orders/cancel-all`, `/orders/{order_id}/replace`,
  `/positions/exit`), the switch (`GET /trading`, `POST /trading/arm`,
  `POST /trading/safe`), `GET` and `PUT /settings/risk`, and the `trading` topic. An order
  refused by risk or the venue is a 200 with `status: "rejected"`, not an HTTP error.
* Phase 4 adds `/alerts`, `/notifications` (history, read marks, a test send) and
  `/settings/notifications`. Phase 8 adds the `/combos/*` commands and the combo reads.
* From Phase 6 the same origin serves the embedded web UI for every path outside `/api`
  and `/ws`.

The full protocol is in [browser-protocol.md](browser-protocol.md). Details and
trade-offs of the contract: [ADR 0002](../adr/0002-openapi-contract-and-codegen.md).

## Storage

SQLite through `modernc.org/sqlite` (pure Go, no cgo), in the directory set by
`DATA_DIR`. Migrations are embedded in the binary and run at startup. Only
`store.Writer` writes; other code reads. SQLite stores timestamps as Unix milliseconds.

Tables (PLAN §3.5):

| Table | Contents | Since |
| - | - | - |
| `settings` | Stake presets, risk limits, theme | Phase 0 |
| `sessions` | UI login sessions | Phase 0 |
| `events`, `event_tags`, `markets`, `outcomes`, `leagues`, `teams`, `search_docs`, `search_fts` (FTS5) | Market metadata cache and the palette search index | Phase 1 |
| `watchlist` | Pinned markets | Phase 1 |
| `order_audit` | Append-only: intent, risk decision and response rows, hash-chained (see [Audit log](#audit-log)) | Phase 3 |
| `fills` | Your fills | Planned; today's fills are kept in `engine/orders` memory |
| `layouts` | Saved workspaces | Planned |
| `alerts` | Price, spread and game alerts with their trigger state and the value that last triggered them | Phase 4 |
| `notifications` | Notification history (30 days), read state, the alert or placement (`client_order_id`) it came from | Phase 4 |
| (columns) `settings.notification_rules`, `settings.sound_volume`; `activity_watch.cash`, `cash_at`, `cash_ledger`, `fill_mark`, `fill_mark_keys` | Notification rules; the last cash reading, the unsettled cash ledger and where the fill backfill continues | Phase 4 |
| (column) `settings.device_generation` | The generation the known-device cookies are signed with | Phase 6 |
| (columns) `settings.max_combo_*`, `markets.combo_eligible`, `outcomes.position_id` | Combo risk limits, combo eligibility and leg position ids (migration `0018_combos.sql`). Combo quote and Accept rows reuse `order_audit`; quote requests, combo positions and activity are not stored | Phase 8 |

Details and trade-offs: [ADR 0003](../adr/0003-sqlite-with-pure-go-driver.md).

## One source per signal

Every signal (book, game score, open orders, positions, cash balance, geoblock status and
so on) has exactly one source. The app never merges a primary source with a backup or a
faster one, and never adds a fallback. When a feed is stale the UI shows it as stale
instead of filling the gap from elsewhere. A snapshot followed by deltas from the same
feed counts as one source.

Two Phase 1 decisions apply this rule:

* Every displayed price comes from the market feed through `engine/books`. Search results
  and Gamma listings carry no prices
  ([ADR 0007](../adr/0007-prices-only-from-market-feed.md)).
* Game state is seeded from Gamma once per game and after a sports feed reconnect, then
  updated only by the sports feed. Gamma is never polled for scores
  ([ADR 0006](../adr/0006-game-state-seeded-snapshot.md)).

Phase 2 applies it to account data:

* Open orders and live fills come from the user feed. REST open orders are only its
  snapshot, at startup and after a reconnect.
* Positions come from the Data API and are never rebuilt from fills; cash comes from the
  CLOB balance endpoint. Both are refetched when a fill arrives and every 60 s.
* Position valuation is derived in `engine/portfolio` from positions and the local books,
  never from another price source.
* The credential state has one source, the venue credential actor.

Phase 4 applies it to alerts and account checks:

* Price and spread alerts read the books `engine/books` builds from the Market WS, and game
  alerts `engine/games`; a stale source never fires an alert.
* Notifications come only from the bus events of the actor that owns each signal; `notify`
  never polls a venue.
* Missed fills come back through the fill backfill on the user feed's own topic, never
  from the Data API; trade cash comes only from fills, and other cash only from non-trade
  activity entries.

Phase 8 applies it to Combos ([Combos](#combos-phase-8)): eligibility comes only from Gamma
`comboStatus`, a quote request's state only from the Requester API's answers and status
reads, combo positions only from the Data API, and the only combo price is the quote.

The table of sources is in [PLAN.md §3.7](../PLAN.md#37-one-source-per-signal). Change
that table before changing the code that produces a signal.

## Security baseline

* **Secrets:** read only from environment variables: signer key, CLOB API credentials,
  UI password hash, session secret, Telegram token. They are never logged,
  returned by the API, put in errors or committed. A redacting `slog` handler strips
  known secrets, but code does not rely on it and never passes a secret to the logger.
* **Signing key:** a trade-only Polymarket Session Key (`POLYMARKET_SESSION_KEY`, CLOB
  scope, 180-day expiry) for the Deposit Wallet, used with CLOB L2 API credentials that
  are derived at startup and held in memory only
  ([ADR 0009](../adr/0009-venue-credentials-in-memory.md)). The owner key never goes on a
  machine that runs this app; the loader refuses the old `POLYMARKET_PRIVATE_KEY` name and
  a signer equal to `POLYMARKET_OWNER_ADDRESS`. Setup:
  [Session Key runbook](../runbooks/session-key-setup.md).
* **UI login:** argon2id password hash (`UI_PASSWORD_HASH`); session cookie that is
  `HttpOnly` and `SameSite=Strict`, with `Secure` controlled by `COOKIE_SECURE`; CSRF
  token on every mutating request; rate-limited login attempts. From Phase 6 the limits are
  per client, a login breaker refuses unknown browsers after failed logins from many
  addresses, and a known-device cookie lets a browser that logged in before past it
  ([Serving and operations](#serving-and-operations-phase-6)).
* **Network:** the backend binds to `127.0.0.1` in development. TLS terminates at a
  reverse proxy in deployment. The app is same-origin and sends no CORS headers; the
  WebSocket upgrade checks the session cookie and the `Origin` header.
* **Status:** the public health check reports liveness only; version, trading state,
  geoblock and feed health need a session
  ([ADR 0008](../adr/0008-public-health-authenticated-status.md)).
* **Trading gates:** `LIVE_TRADING` (exact string `true`), SAFE/ARMED (memory only, SAFE
  at every start), geoblock status, credentials, clock skew, foreign activity and the
  risk limits, all checked in `engine/risk` on every order. Limits are capped by
  environment ceilings, and raising one needs the UI password. Every order is recorded in
  the append-only `order_audit` log before it is sent
  ([ADR 0011](../adr/0011-audit-first-order-path.md)). What each control does for the
  user is in the [user guide](../guides/user/index.md).
* **Geoblock:** the backend queries `https://polymarket.com/api/geoblock` (fixed; the
  loader refuses `GEOBLOCK_URL`) at startup and every 10 minutes, and
  retries a failed check sooner (30 s, 1 min, 2 min, then every 10 min). It disables
  trading while blocked, before a check has succeeded, and while the latest check has
  failed. The app never proxies or masks its location. See
  [ADR 0005](../adr/0005-no-geoblock-workarounds.md).
* **No fund movement:** credentials are trade-only; the backend contains no code that
  moves funds.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.