Skip to main content

Architecture overview

This document explains how the parts of sesame-bun fit together. It is derived from PLAN.md §3, which stays authoritative; the conventions that enforce it are in CLAUDE.md. The reasons behind the main choices are in the ADRs. The browser protocol is in 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.
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 and Fill backfill and cash ledger). Phase 6 adds the embedded web UI, backups and restore, and the known-device cookie (see Serving and operations). 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). 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/):

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: 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.
  • 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.
  • 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). 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): 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.
  • 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.

Live data path (Phase 1)

Account data path (Phase 2)

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

Order write path (Phase 3)

  • 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). 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).
  • 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):
  • 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.
  • 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.
  • 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:

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 (Requester API, CLOB L2 only), ADR 0015 (the Accept path) and ADR 0016 (ids and eligibility).
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_ids, 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: One source per signal (PLAN §3.7):

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: 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: Types: Position, Order, Fill, Activity, ActivityPage, PnLPoint, AccountResync and CredentialStatus, with their status enums. Phase 3 adds, in core/trading.go: 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: 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.

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).
  • 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. Details and trade-offs of the contract: ADR 0002.

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): Details and trade-offs: ADR 0003.

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).
  • 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).
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): 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. 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). 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.
  • 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).
  • 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).
  • 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). What each control does for the user is in the user guide.
  • 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.
  • No fund movement: credentials are trade-only; the backend contains no code that moves funds.