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.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 acontext.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 usesPOLYMARKET_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 oncore.credential_status:none,pending,ready, orfailedwith a fixed reason code (no_signer,derive_failed,auth_rejected,geoblocked,network, and from Phase 3wallet_mismatchwhen 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 returncore.ErrNoCredentialswhile they are not available, which the API maps to 503credentials_unavailable. - User feed (
venue/polymarket, Phase 2): a feed actor on the venue’s user channel, implementingcore.UserFeed. It needs credentials, streams the account’s order and fill events ontocore.ordersandcore.fills, and publishesFeedStatuslike the other feeds. The venue does not replay missed events, so after each reconnect it publishescore.AccountResyncand consumers re-read state throughcore.Account. - Orders (
engine/orders): in Phase 2, the open-order state: a REST snapshot of open orders at startup and after everyAccountResync, then changes from the user feed only (PLAN §3.7). It feeds theorderstopic and the open-order counts. It tracks the sportsdelayedstate, 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 toorder_audit, asks risk, writes the decision, calls the venue and writes the response. It reconcilesunknownplacements 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 thecancel_on_shutdownsetting 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 publishescore.TradingState. On every order it checksLIVE_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 byexit_slippage_ticks), and the order, position, daily-notional, open-order, order-rate and duplicate-intent limits. Limits come from settings, capped byRISK_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): implementscore.Heartbeat. Whileheartbeat_enabledis 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 fromengine/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 thepositionsandaccounttopics. - 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.
Live data path (Phase 1)
Account data path (Phase 2)
Order write path (Phase 3)
- One path. Clicks, drags, hotkeys, Exit, re-post and replace all enter
engine/orders, and every placement passesengine/riskat send time. A replace is a cancel, then, once the cancel is confirmed, a new placement. - No automatic re-send. A timeout is
unknownand is reconciled, never retried (ADR 0011). Batches of up to 15 orders are sent once and recorded per order. - Cancels go through
engine/orderswithout 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/signingbuilds 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 andClobAuthtyped 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 UPDATEandBEFORE DELETEtriggers abort any change; onlystore.Writerinserts.- Each row carries a hash chained to the previous row;
task audit-verifychecks 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/alertsis 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 inengine/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.notifyis 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 throughstore.Writer, publishes it onnotify.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 thenotificationstopic. History is kept 30 days. Kinds:alert,fill,order_rejected,order_unknown,order_resolved,game_start_orders,feed_down(after 30 s) andfeed_restored,foreign_activity,trading_safe,session_key_expiring(24 h ahead),credentialsandcredentials_restored,shutdown_cancel_failedandlogin_breaker(Phase 6),combo_filledandcombo_failed(Phase 8), andtest.foreign_activityandcredentialsalways go in-app and to Telegram; the settings cannot turn those off.- The Telegram client is its own actor and is outbound only:
sendMessageas plain text to one chat, nevergetUpdatesand 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_activityandcredentialsgo ahead of the other critical kinds (feed_down, a failed fill,shutdown_cancel_failed,login_breaker). - Retries: one retry on 429, after
retry_afterif it is at most a minute; never after a timeout, since a duplicate is worse than a gap.
- Placements.
engine/orderspublishes the first result of each placement onorders.resultsand, for a placement whose outcome was unknown, the reconciled final result onorders.placements. The hub serves the latter as theplacementstopic (the outcomes settled in the last 10 minutes). Order notifications carry the placement’sclient_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/ordersalso 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 oncore.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 inactivity_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.
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 inactivity_watch, so a restart or a read gap is reconciled as one interval. - trade cash comes only from the fills on
Serving and operations (Phase 6)
- Embedded web UI (
webui). The production build copies the Vite bundle into the binary. The same server answers/apiand/wsand 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 withindex.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_PROXYnames the proxy whoseX-Forwarded-Fornames 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 withVACUUM INTOeveryBACKUP_INTERVAL(default 6 h; 0 turns it off) toDATA_DIR/backups/sesame-<UTC time>.db, keepsBACKUP_KEEP(default 28), and never blocksstore.Writer. Each backup has a MAC file keyed fromSESSION_SECRET.sesame backupwrites 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-backupit 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 asesame_devicecookie (__Host-sesame_devicewithCOOKIE_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 publishesauth.login_breaker, which becomes alogin_breakernotification. The cookie’s MAC coverssettings.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_shutdownwithin a 28 s budget, under the container’s 30 s stop grace. A cancel-on-shutdown that failed is reported at the next start asshutdown_cancel_failed.
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).venue/polymarket/combosimplementscore.Combos(Quote,SignAccept,SendAccept,Status) andcore.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/ordersowns this app’s quote requests of the last 10 minutes (memory only), the idempotency cache of quote and Acceptclient_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. ItsComboAcceptslist feeds the foreign-activity check.engine/riskaddsCheckQuoteandCheckCombo, themax_combo_notional,max_combo_exposureandmax_combo_legslimits with theirRISK_CEILING_COMBO_*ceilings, the local cap of 12 quote requests a minute, andcombos_can_tradeandcombos_reasonsincore.TradingState.engine/portfolioruns a combo positions actor that reads open combo positions at start, shortly after a fill and every 60 s, values them at cost, and publishesportfolio.combo_positions. The account summary addscombo_cost_basisto exposure, not to liquidation value or unrealised P&L. The cash ledger counts a filled Accept’s cost or proceeds once.notifysubscribes toorders.combo_outcomesand sendscombo_filledorcombo_failedonce per Accept.
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, publishesFeedStatuson every change of state, and returns only whenctxends.- After a reconnect, the market feed publishes a
resyncbook event and then a freshsnapshotfor every watched token, soengine/bookscan 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,resolvedandunwatchedevents.
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_sessioncookie and anX-CSRF-Tokenheader. - Errors return
{code, message}with a stablecode. - Public
GET /api/healthreports liveness only;GET /api/statusneeds 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 2account,positions,orders,fills, from Phase 3trading, from Phase 4placements,alertsandnotifications, and from Phase 8combo_rfqsandcombo_positions), gets a snapshot, then deltas with a per-topicseq. A gap makes the client resubscribe the topic. - Account endpoints that need venue credentials answer 503
credentials_unavailablewithout 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),GETandPUT /settings/risk, and thetradingtopic. An order refused by risk or the venue is a 200 withstatus: "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
/apiand/ws.
Storage
SQLite throughmodernc.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).
- 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/portfoliofrom positions and the local books, never from another price source. - The credential state has one source, the venue credential actor.
- Price and spread alerts read the books
engine/booksbuilds from the Market WS, and game alertsengine/games; a stale source never fires an alert. - Notifications come only from the bus events of the actor that owns each signal;
notifynever 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.
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
sloghandler 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 oldPOLYMARKET_PRIVATE_KEYname and a signer equal toPOLYMARKET_OWNER_ADDRESS. Setup: Session Key runbook. - UI login: argon2id password hash (
UI_PASSWORD_HASH); session cookie that isHttpOnlyandSameSite=Strict, withSecurecontrolled byCOOKIE_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.1in 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 theOriginheader. - 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 stringtrue), SAFE/ARMED (memory only, SAFE at every start), geoblock status, credentials, clock skew, foreign activity and the risk limits, all checked inengine/riskon every order. Limits are capped by environment ceilings, and raising one needs the UI password. Every order is recorded in the append-onlyorder_auditlog 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 refusesGEOBLOCK_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.