> ## 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.

# sesame-bun: Implementation Plan

# sesame-bun: Implementation Plan

A single-user web trading terminal for Polymarket (Predictions, then Combos), with a second prediction-market venue (Yeet) added later. The Go backend signs orders with a server-held Session Key; the React frontend is designed to need as few clicks as possible for live sports trading and for managing many positions.

Status: draft v1, 2026-09-30. The tech lead (the main agent) owns this document.

***

## 1. Decisions

| Area | Decision |
| - | - |
| Users | Single user |
| Signing | Server-side; a Polymarket **Session Key** signer in `.env`; the owner key stays offline |
| UI auth | Password from day one (argon2id hash, session cookie) |
| Repo | Monorepo in `sesame-bun`: `/backend`, `/frontend`, `/api`, `/docs` |
| Backend | Go; **actors + typed event bus**; stdlib `net/http` + chi; `coder/websocket`; go-ethereum for EIP-712/secp256k1 |
| API contract | OpenAPI 3.0.3 spec in `/api` (3.0 because oapi-codegen only partly supports 3.1), with `oapi-codegen` (Go) and `openapi-typescript` + `openapi-fetch` (TS); WS messages defined as JSON Schema in the same spec |
| Storage | SQLite via `modernc.org/sqlite` (pure Go, no cgo) with embedded SQL migrations |
| Frontend | Vite + React + TypeScript (strict) + Tailwind + shadcn/ui (Radix) + cmdk; TanStack Query/Virtual/Router; Zustand for live state; `lightweight-charts` |
| Theme | Dark pro by default (`theme_mode` = dark, light or system; default dark), light theme toggle; green/red semantics; **comfortable** density (\~36px rows) |
| Typography | **Nunito Sans** variable (wght 200–1000, SIL OFL), the closest free match to Avenir, which needs a paid webfont licence. Self-hosted WOFF2 in `frontend/public/fonts/nunito-sans/`, `@font-face` in `frontend/src/styles/fonts.css`. Digits are already fixed-width, so numbers align in columns. Moving to licensed Avenir later only means replacing the files and family name |
| Motion | Short, purposeful transitions for navigation and state changes; native View Transitions + CSS first; nothing animates prices or scrolls a book under the cursor (§4.1) |
| Mobile | **Full mobile**: every feature usable on a phone |
| Click semantics | Clicking a price sends a **GTC limit order at exactly the clicked price**; no confirmation; backend hard limits |
| Alerts | In-app toast + sound, desktop notifications, Telegram bot, price/score alerts |
| Sports | US majors, soccer, tennis, esports/other, all in scope |
| Run/deploy | Native dev (Taskfile: `go run` + Vite); later a single Docker image (Go binary embedding the built frontend) |
| Conventions | `CLAUDE.md` (canonical) + `AGENTS.md` (Codex pointer), adapted from dimsum-core |
| Quality gates | Lint + format on every change (golangci-lint + gofumpt, Biome, `tsc --noEmit`) |
| Tests | Required only where bugs cost money or corrupt data: venue parsers (captured-bytes fixtures), signing + amount math (golden vectors from the official TS SDK), risk limits |
| Live testing | Straight to tiny live orders (minimum size, far from the market, then cancelled) from a permitted location, after golden vectors pass |
| Git flow | One git worktree and branch per agent; PR; quality check; the tech lead merges to `main` |
| Commits | Short header + lowercase "why" bullets; no AI names or co-author trailers in commits or PRs |
| Build order | Predictions (read-only, then trading), then deploy, then Combos, then Yeet |
| Out of scope | **No Polymarket Perps. No deposits, withdrawals, bridge transfers or relayer operations (redeem/split/merge).** The app holds trade-only credentials: it places and cancels orders and reads account data. Funding and claiming happen on polymarket.com |

### Geographic restriction (hard constraint)

This dev server's IP resolves to US/VA and Polymarket geoblocks it (`/api/geoblock` → `blocked:true`). The project will **not** include proxying, IP masking or any other geoblock workaround. Read-only public endpoints are used from here for development. The backend calls the geoblock endpoint at startup and **refuses to enable trading while `blocked` is true**. Live trading (Phase 3 verification onward) runs only from a location where the user is permitted to trade.

***

## 2. Team (subagents)

Each role is one subagent working in its own git worktree. Not every role is active in every phase (see the phase tables). At most about 8 run at once.

| Role | Covers | Owns | Main responsibilities |
| - | - | - | - |
| **Tech Lead / Architect** (main agent) | 1 | `docs/PLAN.md`, `docs/adr/`, `/api` (final say) | Sequencing, contract changes, merge decisions, resolving conflicts between roles, phase sign-off |
| **BE-Venue**: Backend engineer, venue integrations | 1–2 | `backend/internal/venue/**` | Polymarket REST/WS clients, L1/L2 auth, EIP-712 order signing, sports feed; later Combos and Yeet adapters |
| **BE-Core**: Backend engineer, engine & platform | 1–2 | `backend/internal/{actor,bus,engine,store,httpapi,hub,auth,notify,app}` | Actor runtime, event bus, order manager, risk engine, portfolio, alerts, SQLite, HTTP/WS API, login, Telegram |
| **FE-Platform**: Frontend engineer, shell & data | 1 | `frontend/src/{app,lib,components/ui,features/{search,settings,auth}}` | App shell, routing, responsive layout, generated API client, WS client + live stores, command palette, hotkeys, notifications, settings, login |
| **FE-Trading**: Frontend engineer, trading views | 1–2 | `frontend/src/features/{board,market,ladder,portfolio,orders,alerts}` | Sports board, market view, price ladder, portfolio/orders tables, charts, alert UI (desktop and mobile variants) |
| **UI/UX Designer** | 1 | `docs/design/**`, `frontend/src/styles/tokens.css`, Tailwind theme | Design tokens, component specs, screen specs (desktop + mobile), interaction and hotkey specs, click-count budgets, UX review of FE PRs |
| **DevOps + Code Quality** | 1–2 | `Taskfile.yml`, lint/format configs, `lefthook.yml`, codegen scripts, `Dockerfile`, `deploy/**` | Toolchain, codegen pipeline, pre-commit hooks, lint gate on every PR, Docker image, deploy runbook, backups |
| **QA / Test Engineer** | 1 | `docs/qa/**`, test code alongside features | Acceptance checklist per phase, exploratory testing via browser automation, live-verification script and runbook, regression list |
| **Security Reviewer** | 1 | `docs/security/**` | Threat model; review of key handling, signing, risk limits, auth, logging/redaction; **must sign off before any live order** |
| **Technical Writer** | 1 | `index.md`, `docs/{adr,architecture,runbooks}/**` | Drafts ADRs (the tech lead approves them), setup guide, architecture docs, runbooks (Session Key setup, live verification, deploy), changelog |

### How parallel work stays unblocked

1. **Contract first.** The first task of each phase is the tech lead and the relevant engineers updating `/api/openapi.yaml`. After codegen, FE and BE build against the same generated types at the same time.
2. **Folder ownership.** Each role edits only its own folders (see Owns). Changes elsewhere go through the tech lead.
3. **Merge order within a phase:** contract, then backend, then frontend, then docs. Each PR is rebased onto `main` before merging.
4. **Definition of done for a PR:** lint/format/typecheck clean, builds, the UX designer has reviewed FE PRs, and the security reviewer has reviewed anything touching keys, signing, auth or risk.

***

## 3. Architecture

### 3.1 Repo layout

```
sesame-bun/
  api/openapi.yaml            # REST + WS message schemas (source of truth)
  backend/
    cmd/sesame/main.go
    internal/
      app/                    # wiring, config, startup (geoblock check), supervision tree
      actor/                  # small actor helpers: mailbox, request/reply, supervised run
      bus/                    # typed pub/sub topics
      core/                   # venue-agnostic domain types + Venue interfaces
      venue/                  # shared HTTPError; venue/polymarket: gamma, clob, clobauth, signing, ws, data, sports
      engine/                 # books, games, orders, risk, portfolio, alerts actors
      catalog/                # metadata cache refresh and palette search
      geoblock/               # startup and periodic geoblock check (fails closed)
      testutil/fakevenue/     # in-memory venue for tests and SESAME_FAKE_VENUE=1
      store/                  # sqlite, migrations, repositories
      httpapi/                # oapi-codegen strict server + handlers
      hub/                    # browser WS: subscriptions, snapshot+delta, coalescing
      auth/                   # password, sessions, CSRF
      notify/                 # telegram, (desktop via hub)
  frontend/
    src/app/                  # router, layout, providers
    src/lib/                  # api (generated), ws client, stores, hotkeys, format
    src/components/ui/        # shadcn components
    src/components/trading/   # price button, flash, score badge, ladder and dock shared by features
    src/features/{search,board,market,watchlist,portfolio,orders,alerts,settings,auth}/
    src/styles/tokens.css
  docs/{PLAN.md,adr/,design/,qa/,security/,runbooks/,architecture/,venue/}
  tools/vectors/              # golden signing vectors from the official TS SDK
  data/                       # local SQLite (git-ignored)
  Taskfile.yml  lefthook.yml  .editorconfig  Dockerfile  .env.example
```

### 3.2 Backend: actors and event bus

* **Actor** = a goroutine that owns its state and reads a mailbox channel. No other code touches that state. Request/reply uses a message carrying a reply channel plus a `context` for timeouts.
* **Supervision:** `thejerf/suture/v4` restarts crashed actors with backoff. Feed actors reconnect on their own and publish `FeedStatus` events, which the UI uses to grey out stale data.
* **Bus:** typed topics. Venue feeds publish `core.TopicBookEvents`, `core.TopicGames` and `core.TopicFeedStatus`; engine and hub topics are defined in their packages (e.g. `hub.TopicFeeds`). Later phases add order, fill, position, risk and alert topics. Subscribers get bounded buffers. Slow consumers drop and resync from a snapshot rather than blocking anyone else.

```
polymarket.MarketFeed ─┐                 ┌─► engine/books (one manager actor, state per token) ──► bus
polymarket.UserFeed  ──┼──► bus ─────────┼─► OrderManager ◄── HTTP place/cancel (request/reply)
polymarket.SportsFeed ─┘                 ├─► RiskEngine (pre-trade checks, limits, daily notional)
polymarket.Heartbeat (dead-man, opt-in)  ├─► Portfolio (positions, liquidation value)
                                         ├─► AlertEngine ──► notify.Telegram / hub
                                         ├─► store.Writer (single SQLite writer)
                                         └─► BrowserHub ──► WS clients (coalesced ~10 Hz)
```

Actor names map to packages: OrderManager is `engine/orders`, RiskEngine is `engine/risk`, Portfolio is `engine/portfolio`, AlertEngine is `engine/alerts`, BookActor is `engine/books`, and BrowserHub is `hub`.

* **OrderManager:**
  * Each order carries a client idempotency key.
  * Pre-trade checks call RiskEngine first.
  * Every order is written to the append-only audit log before it's sent.
  * After a user-feed reconnect, it re-reads open orders and trades over REST, because that feed doesn't replay missed events.
  * It tracks the sports-delay state (`delayed`, which can't be cancelled).
* **RiskEngine limits:**
  * `LIVE_TRADING` env flag, and the UI SAFE/ARMED switch
  * geoblock status
  * max order notional, max position notional per market (`max_position_notional`), max daily notional, max open orders
  * tick and minimum-size rounding
  * a price sanity band (the clicked price must be within N ticks of the current book)
* **Dead-man heartbeat:** an optional setting (off by default). While it's on, Polymarket cancels every open order if the backend stops.

### 3.3 The Venue boundary

`core` defines small interfaces that each adapter implements, and advertises optional capabilities:

* `Discovery`: search, events, markets, tags, sports metadata
* `MarketData`: book snapshots and streams, price history
* `Trading`: place, cancel, cancel-market, cancel-all, open orders
* `Portfolio`: positions, activity, balances
* `MarketFeed` and `SportsFeed`: feed actors publishing on the core bus topics (see `backend/internal/core/venue.go`, the source of truth for these interfaces)

Core types (Event, Market, Outcome, Book, Order, Fill, Position, Game) are venue-agnostic. IDs are namespaced (`pm:<tokenId>`, `yeet:<id>`).

### 3.4 Browser ↔ backend

* REST for commands and queries.
* **One WebSocket** per tab, with a subscription protocol: `{op:"sub", topics:["book:pm:<token_id>","game:pm:<game_id>","feeds","status"]}` (later phases add order and position topics). The server replies with a full snapshot, then sends changes. Updates are coalesced to about 10 Hz per topic. Every message has a sequence number, and a gap triggers a resync.

### 3.5 SQLite schema (initial)

| Table | Contents |
| - | - |
| `settings` | Stake presets, risk limits, theme, hotkeys |
| `sessions` | UI login sessions |
| `watchlist` | Pinned markets |
| `layouts` | Saved workspaces (planned; not created yet) |
| `order_audit` | Append-only: intent, risk decision, signed payload hash, response |
| `fills` | Your fills (planned; today's fills are held in memory) |
| `alerts` | Price, spread and game alerts with their state and last trigger time |
| `notifications` | Notification history (30 days), read state; the alert that fired is linked by `alert_id` |
| `events`, `event_tags`, `markets`, `outcomes`, `leagues`, `teams` | Market metadata cache refreshed from Gamma |
| `search_docs`, `search_fts` | Palette search index (FTS5) |

Phase 8 (Combos) adds columns in migration `0018_combos.sql` (0016 and 0017 were taken by Phases 4 and 6) and no new table:

| Table | Column | Contents |
| - | - | - |
| `settings` | `max_combo_notional` | Micros, default 5,000 pUSD (`5000000000`), `CHECK >= 0` |
| `settings` | `max_combo_exposure` | Micros, default 25,000 pUSD (`25000000000`), `CHECK >= 0` |
| `settings` | `max_combo_legs` | Integer, default 10, `CHECK BETWEEN 2 AND 50` |
| `markets` | `combo_eligible` | `0`/`1`, from Gamma `comboStatus` (`enabled` is 1; `pending`, `disabled` and unknown values are 0) |
| `outcomes` | `position_id` | The Gamma `positionIds` entry for the outcome, `''` when Gamma sends none; indexed (`outcomes_position_id`) to map combo legs back to outcomes |

Later columns: `0019_market_combo_status.sql` adds `markets.combo_status` (`enabled`, `pending` or `disabled`, the Gamma `comboStatus` with unknown values as `disabled`; it says why a market is not `combo_eligible`), and `0020_notification_orders.sql` adds `notifications.orders` (JSON, the open orders a `game_start_orders` start cancels, for Re-post; `''` for other kinds).

* The combo quote and accept audit rows reuse `order_audit` and its kinds (`intent`, `decision`, `signed`, `response`, `reconcile`). The body carries `action` (`combo_quote` or `combo_accept`), the `rfq_id` and `quote_id`, and for the accept the order hash; `notional` is the quote's `total_required` for a buy and 0 for a sell. The quote and the accept each have their own `client_order_id`, so `UNIQUE (client_order_id, kind)` holds.
* An accept whose `signed` row has no `response` or final `reconcile` row is reconciled at start by an RFQ status read, like an unknown placement.
* Recent RFQs (10 min) live in `engine/orders` memory only. Combo positions and activity are not stored.
* The foreign-activity check needs no new column: combo trades are matched to accept audit rows by `tx_hash`, and the existing `used_sizes` and `checked_ids` cover them.

### 3.6 Security baseline

* Secrets come only from env. They are never logged: a redacting logger (`slog` handler) strips them.
* The Session Key has limited scope and an expiry. The owner key never reaches this machine.
* UI login:
  * argon2id password hash
  * `HttpOnly`, `SameSite=Strict` cookie, plus a CSRF token on mutating requests
  * rate-limited login attempts
* Binds to `127.0.0.1` in dev. TLS goes in front at deploy.
* Telegram bot token and chat ID come from env. Messages never include secrets.

***

### 3.7 One source per signal

Each signal has exactly one source (see `CLAUDE.md`). A snapshot followed by deltas from the same feed counts as one source. Change this table before changing code.

| Signal | Single source | Notes |
| - | - | - |
| Order book, best bid/ask, last trade | Market WS (`initial_dump` snapshot + deltas) | Resync = resubscribe for a fresh dump, never REST `/book`. PolyBolt is not used for books. |
| Tick size / min size / neg-risk | Gamma market metadata, updated by Market WS `tick_size_change` | `engine/books` keeps the last known tick; re-sent books omit it |
| Market resolved (live) | Market WS `market_resolved` | The catalog's `closed` flag comes from Gamma and is metadata, not a live signal |
| Prices shown anywhere (board, lists, watchlist, market view) | Market WS via `engine/books` | Lists subscribe the book topics of their visible rows; search results and Gamma listings carry no prices. Gamma `outcomePrices` are never shown |
| Market & event metadata, search index | Gamma API | Refreshed in the background into the metadata cache (§3.5) |
| Game score, clock, period, status | Sports WS, seeded from Gamma | The feed sends changes only, never a snapshot (verified in Phase 0). Each game is seeded **once** from its Gamma event (`score`, `period`, `elapsed`, `live`, `ended`) when it enters the board, and again after a Sports WS reconnect; from then on only Sports WS updates it. Gamma is never polled for scores. This is a snapshot + deltas pair, not two sources |
| Open orders, order status, own fills (live) | User WS | CLOB open orders over REST only as the snapshot at startup, after a reconnect or an `AccountResync`, and CLOB trades over REST (`GET /data/trades`) only as the fill backfill since the last seen fill at the same moments, deduplicated with the feed by trade id and order id, so a fill made while the app was stopped or the feed was down arrives once through the same fills topic (P4R-01); and in a 60 s drift check that publishes `AccountResync` only on a mismatch (P2-04). An unknown placement is reconciled by its order id only: our id in the open set or a user-feed fill of it, else `GET /data/order/{hash}` for that id. That order id comes from the placement's signed audit row, written before the send. Any order id on the feed or in a snapshot that no placement maps to is foreign activity (F5), except the orders adopted as the baseline at the first-ever start |
| Account history (trades, redeems, transfers made on the venue site) | Data API v2 `/activity` | History only; live fills come from the User WS. Also read every 60 s and after fills for foreign-activity detection (F5): unexplained trades, and every outgoing transfer, split, merge and conversion, continuing at start from the persisted high-water mark, used sizes and checked entries |
| Cumulative P\&L series | Data API v2 `/user-pnl` | |
| Positions (size, avg price, status) | Data API v2 `/positions` | Refetched when a fill arrives and every 60 s; never derived locally from fills. `engine/orders` keeps a filled buy reserved against the position cap until a positions fetch shows the token's size grew by it |
| Position valuation (liquidation value, unrealized P\&L, exit price, hedge quote) | Derived in `engine/portfolio` from positions × local books | The hedge quote reads the other outcome's book, which the portfolio holds beside each held token's. `POST /positions/hedge` prices its order with the same function from the books at send time |
| Cash balance | CLOB balance-allowance (`signature_type=3`) | Refetched when a fill arrives and every 60 s. Also reconciled as a ledger for foreign-activity detection (P3F-04, P4-01): each change between readings must be explained within 11 min. Trade cash has one source, the user-feed fills (cost or proceeds, with fees from `fee_rate_bps`); Data API trade rows never explain cash (P4R-01). Combo trade cash has one source, the app's own filled Accepts (`total_required` out for a buy, `net_receive` in for a sell), keyed by the Accept so a combo fill that also arrives on the user feed or in the backfill counts once (P8-04). All other cash movement has one source, the Data API activity entries with an amount that are not trades (redeems, rewards, transfers, splits, merges, conversions). A rise with no entry is never set against a drop. A fill the user feed missed (no replay after a disconnect or a stop) comes back through the fill backfill of the own-fills row, on the same fills topic; a fill that neither delivers leaves its drop unexplained, so it is reported. The last reading and the unsettled ledger persist with the activity watch (P4-04), so a restart or read gap is reconciled with the gap's fills and non-trade activity |
| Venue credential state | `venue/polymarket` credential actor | Published on `core.TopicCredentialStatus`. Its `Scopes` come from the session-signers check (`GET /v1/user/session-signers`): `CLOB` is trading, `COMBOSRFQ` is combos, `ALL` is both. Combos are on only while the status is ready and includes combos |
| Price history | CLOB prices-history | |
| Geoblock status | `polymarket.com/api/geoblock` at startup and every 10 min | |
| Price and spread alert triggers | `engine/books` on the bus | `engine/alerts` keeps the books of active alerts subscribed through `engine/books` (the same Market WS). A stale book never fires an alert and is never filled from anywhere else |
| Score, game start and game end triggers | `engine/games` on the bus | |
| Notifications | The bus events of the actor that owns each signal (fills and rejects from `engine/orders`, trading state from `engine/risk`, feed status from the feed actors, credentials from the credential actor) | `notify` only subscribes; it never polls a venue |
| Combo eligibility and leg position ids | Gamma market metadata (`comboStatus`, `positionIds`) in the metadata cache | `markets.combo_eligible` and `outcomes.position_id`, refreshed with the rest of the market. The public RFQ catalog (`combos-rfq-api.polymarket.com/v1/rfq/combo-markets`) is never read. A leg maps to an outcome by position id only, never by the Data API's `leg_condition_id`. A leg of a closed market missing from the cache is looked up in Gamma by `position_ids` with `closed=true` |
| Quote request state (quote, Accept, execution) | Combos Requester API answers: the create answer, the accept answer, then `GET /requests/{rfq_id}` | There is no requester feed. `engine/orders` polls the status of an accepted RFQ every 500 ms for the first minute, then every 5 s, with failed reads backing off up to 1 min, until it is final, and reads it once per unknown Accept; it never re-sends an Accept. A quote nobody accepted is dropped when it expires, without a venue call. The quote timer is `expires_in_ms`, computed by the server at send time A combo fill on the CLOB user channel, if the venue sends one (§16 A15), is not used for RFQ state |
| Combo positions | Data API v2 `/positions/combos` | Refetched every 60 s and after an RFQ reaches filled; never derived from RFQs. Valued at cost (no book); the row's `leg_current_price` is never shown. `engine/orders` keeps a filled combo buy reserved against `max_combo_exposure` until a fetch shows the position grew by it |
| Combo activity (split, merge, conversion, compress, wrap, unwrap, redeem) | Data API v2 `/activity/combos` | History only |
| Combo trades | Data API v2 `/activity` rows with `is_combo` (part of account history above) | The foreign-activity check explains a combo trade only by this app's combo accept with the same `tx_hash`; any other one is foreign activity |
| Leg prices shown (combo slip, combo positions) | Market WS via `engine/books` | The UI subscribes each leg's `book:<token_id>` topic. No combined or indicative combo price is computed or shown before a quote; the quote's price is the only combo price |

## 4. UX specification (summary; full specs in `docs/design/`)

* **Global shell, desktop:**
  * Top bar: Ctrl+K palette, venue badge, cash/exposure/open orders, SAFE/ARMED switch, **Cancel All** (hotkey)
  * Left rail: Live, Watchlist, Portfolio, Orders, Alerts
  * Bottom dock: positions, orders and fills
* **Global shell, mobile:**
  * Bottom tab bar: Live, Watchlist, Portfolio, Orders, Search
  * A sticky Cancel All button, and the SAFE/ARMED switch in the header
  * Ladders open as full-height bottom sheets
* **Command palette:**
  * Searches markets, teams, your positions and commands
  * Prefixes: `@` teams/events, `#` tags, `>` commands
  * Enter opens, Shift+Enter pins, Alt+Enter opens in a new panel
  * Ranking: pinned, then held, then live, then volume
* **Live sports board:**
  * One row or card per game, grouped by league: score, clock, period, possession, bid/ask per outcome (click to trade at the preset stake), position and P\&L, a pending-orders dot
  * Up to 4 ladders on desktop
  * A warning before game start that resting orders will be cleared, with one-click re-post after the start
  * A "delayed (can't cancel)" countdown chip
* **Price ladder:**
  * Depth, last trade, your orders on their price levels
  * A "P\&L if closed here" column
  * Click to place, drag to reprice, right-click (long-press on mobile) to cancel
  * Header buttons: Exit, Hedge and Cancel all
  * The ladder doesn't re-centre while the pointer is over it
* **Portfolio:**
  * Position rows with liquidation value (walking the book, not the midpoint), unrealized P\&L, resting-order count, and live score
  * Row actions: Exit, Exit @ join ask, Add, ×Orders, expand to show the ladder
  * The orders table supports inline tick steppers and bulk cancel/reprice
  * Grouping by event, league, expiry or venue
* **Stale data:** when a feed lags past a threshold, prices grey out and clicking them is disabled.
* **Hotkeys:** only active when ARMED, and they act only on the focused panel (heavy focus ring). The Cancel All hotkey always works.

### 4.1 Motion and navigation

Aim: navigation feels instant and continuous. Motion explains a change; it never delays one.

**Making navigation fast (more important than animating it):**

* Routes preload on hover or focus (TanStack Router `preload: "intent"`), so data and code are ready before the click.
* Previous data stays on screen while the next loads (`placeholderData: keepPreviousData`), so nothing flashes blank. Skeletons show only on a cold load.
* Route chunks split per feature and are prefetched when the app is idle.
* Scroll position is restored per route. The watchlist, board and portfolio keep their state when you switch tabs.

**Motion tokens** (`tokens.css`; all components use these):

| Token | Value | Used for |
| - | - | - |
| `--dur-instant` | 0 ms | Price and size changes, order state on the ladder |
| `--dur-fast` | 120 ms | Hover, press, focus rings, toggles, tooltips |
| `--dur-base` | 180 ms | Palette open/close, dropdowns, toasts, tab switches, route cross-fade |
| `--dur-slow` | 240 ms | Bottom sheets, side panels, expanding a portfolio row. Nothing is longer |
| `--ease-out` | `cubic-bezier(0.2, 0, 0, 1)` | Entering |
| `--ease-in` | `cubic-bezier(0.4, 0, 1, 1)` | Leaving (exit animations run at about 0.75× the enter duration) |

**Where motion is used:**

| Element | Motion |
| - | - |
| Route change | View Transitions API cross-fade (`--dur-base`); the shell (top bar, rail, dock) stays still |
| Command palette | Fade + scale from 98% (`--dur-base`); results render immediately without animation |
| Bottom sheets and side panels (mobile ladder, market detail) | Slide in, draggable to dismiss with velocity (shadcn Drawer / vaul) |
| Expanding a portfolio row into its ladder | Height and opacity (`--dur-slow`) |
| Toasts (fills, rejects) | Slide + fade (sonner); stacked, can be swiped away on mobile |
| Price changes | **No animation of the value.** A short background flash, green for an uptick and red for a downtick, fading over 400 ms. It's opacity only, so layout never moves |
| Your order changing state (placed / delayed / filled / cancelled) | Chip colour cross-fade (`--dur-fast`); the delay countdown ring runs linearly |
| Scrolling | `scroll-behavior: smooth` for in-page jumps (e.g. "jump to league", "back to top"). Lists use native momentum scrolling |

**Rules that override the tables above:**

* **Ladders and books never auto-scroll or re-centre with animation** while the pointer or a finger is over them. A price must never move under the cursor.
* Values are never tweened or counted up (prices, sizes, P\&L, balances). They update instantly.
* Only `transform` and `opacity` are animated; no layout properties on live rows. Rows with streaming data never use layout animations.
* `prefers-reduced-motion` turns all durations to 0 except the price flash, which becomes a static colour marker.
* Clicking an action never waits for an animation. Order commands are sent on pointer-down/click, and animations run alongside.
* Motion library: none by default. CSS transitions, the View Transitions API, vaul and sonner cover the table above. Adding a library (e.g. `motion`) needs a tech lead decision.

### 4.2 Design decisions (Phase 0)

The tech lead accepted the designer's proposals in `docs/design/index.md`:

* The price flash is 400 ms (`--dur-flash`), the one exception to the 240 ms cap. Portfolio rows expand by changing height in one frame and fading the contents in; height is never animated.
* Trading hotkeys need ARMED; navigation hotkeys work in SAFE; Cancel All is global.
* The backend supplies money figures shown in the UI (exit price, liquidation value, hedge sizes). The browser previews share estimates and the ladder's per-level P\&L-if-closed with exact decimals; these are display-only (decided in Phase 2: per-level values on every tick would be heavy to stream).
* A cancel sent while an order is in the sports delay window is queued by the backend and sent when the window ends; the contract carries the delay end time.
* Exit sells the whole position at the lowest bid level that fully fills it. Position-reducing orders are exempt from the price band but capped by a max-slippage setting (Phase 3 contract).
* Ladder rows are 28 px with a mouse and 44 px on touch; up to 4 docked ladders persist across Live and Watchlist.
* Selling with no position is disabled. A repeat click on the same price within 350 ms reuses the first idempotency key.
* Palette ranking applies pinned > held > live > volume inside text-match tiers.
* Contract gaps (notification settings, layout state, feed age, cancel-only venue state) land in the phase contracts that need them.

**Click budgets** (the QA acceptance checks use these):

| Task | Budget |
| - | - |
| Buy from the board or watchlist | 1 click |
| Open any market | Ctrl+K + typing + Enter |
| Exit a position | 1 click |
| Cancel all orders on a market | 1 click |
| Move an order | 1 drag, or 1 click per tick |
| Kill everything | 1 click or 1 hotkey |

***

## 5. Phases

Each phase lists the parallel work per role, what gets delivered, and the **exit gate** (acceptance criteria the QA engineer checks and the tech lead signs off).

### Phase 0: Foundations

**Goal:** An empty but fully wired monorepo in which every role can work in parallel.

| Role | Work |
| - | - |
| Tech Lead | Approve ADRs 0001–0005 (drafted by the writer: actors+bus, OpenAPI contract, SQLite, venue boundary, geoblock policy); OpenAPI skeleton (health, auth, settings); `CLAUDE.md`/`AGENTS.md` kept current |
| BE-Venue | `tools/vectors/`: a Node script using the official Polymarket TS SDK that writes golden vectors (order structs, EIP-712 hashes, signatures, HMAC headers, amount rounding) with a throwaway test key |
| DevOps+Quality | `Taskfile.yml` (`tools`, `setup`, `gen`, `dev`, `lint`, `fmt`, `test`, `build`, `hash-password`); golangci-lint + gofumpt; Biome + `tsc` strict; lefthook pre-commit; codegen pipeline (oapi-codegen, openapi-typescript); `.env.example` updates (Session Key, UI password hash, Telegram, risk limits) |
| BE-Core | `cmd/sesame`, config loader with validation, `slog` + redaction, actor helpers, bus, suture supervision tree, SQLite + migrations, health endpoint, **startup geoblock check**, UI password login/sessions/CSRF |
| FE-Platform | Vite + React + TS + Tailwind + shadcn setup, wire in `styles/fonts.css` and preload the latin normal WOFF2 in `index.html`, router with intent preloading + View Transitions + scroll restoration, responsive shell (desktop rail + mobile tab bar), theme toggle, generated API client, login page |
| UI/UX Designer | Design tokens (dark and light, green/red, comfortable density, tabular numbers, motion tokens from §4.1), Nunito Sans type scale, component inventory, motion spec per component, desktop and mobile wireframes for every Phase 1–3 screen, hotkey map |
| Security | Threat model v1; secrets and logging policy |
| Tech Writer | README (setup and run on Windows), architecture overview |

**Exit gate:**

* `task dev` starts backend and frontend.
* Login works.
* The theme toggles.
* `task lint` passes on a clean tree.
* Startup logs the geoblock status and shows trading as disabled while blocked.

### Phase 1: Predictions, read-only market data

**Goal:** Find any market in seconds and watch live prices and scores.

| Role | Work |
| - | - |
| BE-Venue | Gamma client (events/markets keyset paging, search, tags, series, sports metadata, teams, market types); CLOB public price history (books, prices and ticks come only from the Market WS and Gamma per §3.7); Market WS actor (book, price\_change, last\_trade\_price, tick\_size\_change, best\_bid\_ask, market\_resolved; PING heartbeat; add/remove tokens without reconnecting); Sports WS actor; linking games to events/markets via `gameId` and companion events |
| BE-Core | BookActor per token (applies deltas, checks sequence, resyncs); market cache + FTS5 search index with background refresh; BrowserHub subscriptions and coalescing; REST: search, event/market detail, price history, sports board snapshot |
| FE-Platform | WS client (reconnect, sequence gaps, resync), live stores, command palette with prefixes and ranking, watchlist (pin/unpin), stale-data greying, feed status indicator |
| FE-Trading | Sports board (desktop grid + mobile cards, league grouping, scores), market view (outcomes, book, chart), read-only ladder component |
| UI/UX | High-fidelity specs for the board, ladder, market view and palette; UX review of FE PRs; mobile interaction specs (bottom sheets, long-press) |
| QA | Acceptance checklist; browser-automation checks: palette reaches any market by typing, and the board updates live |
| Tech Writer | API/WS protocol doc; ADR for the sports data model |

**Exit gate:**

* Searching an event name, team or tag finds it in under 300 ms.
* A live game's score and book update without a reload.
* A dropped feed greys out prices and recovers.
* Works on a phone-width viewport.

**Phase 1 decisions (tech lead, at kickoff):**

* Contract: `api/openapi.yaml` Phase 1 paths and WS schemas; Go domain types and venue interfaces in `backend/internal/core/{market,venue}.go`. Engine code depends on `core` interfaces only, never on `venue/polymarket`.
* Public `/health` returns `status` only; version, geoblock, trading state and feed health move to authenticated `/status` (security F20).
* The geoblock actor retries a failed check with backoff (30 s, 1 min, 2 min, then every 10 min) instead of waiting 10 min; it still fails closed.
* The frontend may mirror `theme_mode` in localStorage purely to paint the first frame; settings stay the source and overwrite it on load.
* Security controls due this phase: F3a/F3c (no redirects, validated venue URLs), F11 (Host allowlist, Origin checks, no CORS, WS Origin check), F12 (security headers), F13 (error body caps), F14/F24 (session hardening), F18 (lockfiles, `govulncheck`, `gitleaks`), F19 (no `dangerouslySetInnerHTML`, `lib/url`), F23 (input limits).

### Phase 2: Account and portfolio, read-only

**Goal:** See all positions, orders and activity, live.

| Role | Work |
| - | - |
| BE-Venue | CLOB L1 (create/derive API key via EIP-712 `ClobAuth`) and L2 HMAC signing, **verified against the live docs and the open-source clients**; User WS actor (orders, trades; reconciles over REST after reconnect); Data API v2 (positions by status, value, PnL series, activity, trades); balance/allowance for the deposit wallet; open orders |
| BE-Core | Portfolio actor (positions, liquidation value from live books, unrealized P\&L, exposure); REST and WS topics for positions, orders, fills, balances; fills table |
| FE-Trading | Portfolio view (grouping, liquidation value, live score column, expandable ladder), orders table (read-only), activity/history, PnL chart; mobile variants |
| FE-Platform | Top-bar cash/exposure/open-orders counters; bottom dock; desktop notification permission flow |
| Security | Review of L1/L2 auth code, where credentials are stored, and log redaction |
| QA | Compare numbers with polymarket.com: positions, balances and PnL match |

**Exit gate:**

* Every open position and order matches polymarket.com.
* Liquidation value updates live.
* After killing and restoring the network, the orders list is correct again with no manual refresh.

**Phase 2 decisions (tech lead, at kickoff):**

* Contract: `api/openapi.yaml` account paths and schemas (`AccountSummary`, `Position`, `Order`, `Fill`, `Activity`, `PnlSeries`, `SignedDecimal`) and WS topics `account`, `positions`, `orders`, `fills`; Go types and interfaces in `backend/internal/core/account.go` (`core.Account`, `core.UserFeed`, topics).
* Credentials (security F1): `POLYMARKET_SESSION_KEY` (trade-only Session Key signer, hex) and `POLYMARKET_WALLET_ADDRESS` (the Deposit Wallet). CLOB API credentials come from `POLYMARKET_CLOB_API_KEY`/`_SECRET`/`_PASSPHRASE` when set; otherwise they are derived at startup through L1 auth and kept in memory only, never stored. Optional `POLYMARKET_OWNER_ADDRESS`: the loader refuses to start when the signer address equals it. The old `POLYMARKET_PRIVATE_KEY` name is refused with a message to rename it.
* Data API reads need only the wallet address and work without credentials; open orders, cash and the User WS need credentials and report `credentials_unavailable` (503) without them.
* Valuation (liquidation value walking the bids, unrealized P\&L) is computed in `engine/portfolio` from positions and local books, coalesced to at most one update per second per position.
* The tech lead's own verification uses the fake venue and the golden vectors; it never uses the user's real key from the geoblocked dev host (security F8). The user verifies with real credentials from a permitted location.
* Phase 1 UX leftovers for FE-Trading: event pages list open markets first and do not subscribe closed markets' books; board league chips show only leagues with games in the window.

### Phase 3: Trading (Predictions)

**Goal:** One-click trading with hard safety limits.

| Role | Work |
| - | - |
| BE-Venue | EIP-712 V2 order signing (CTF Exchange and Neg Risk exchange domains; the Deposit Wallet ERC-7739 `TypedDataSign` wrapper, byte encoding taken from `clob-client-v2`/`rs-clob-client-v2`); amount math (6-decimal fixed point, tick and minimum-size rounding); place (single and batch up to 15), cancel, cancel-market, cancel-all; handling for 425 (engine restart), post-only mode, 503 (cancel-only), 429 (rate limit) and `delayed`; heartbeat actor; contract addresses from live docs pinned in config |
| BE-Core | OrderManager (idempotency, audit log, state machine incl. `delayed`), RiskEngine (all limits in §3.2, SAFE/ARMED, geoblock), REST endpoints for commands, risk settings API, Hedge/Exit logic, re-post of pre-game orders after start |
| FE-Trading | Ladder trading (click, drag to reprice, right-click/long-press to cancel), board click-to-trade, portfolio row actions (Exit, Exit @ join ask, Add, ×Orders), inline tick steppers, bulk actions, delay countdown chip, stake presets |
| FE-Platform | SAFE/ARMED switch, global Cancel All, hotkey system (focus-scoped, armed-only), fill toasts and sounds, error surfacing (reject reasons) |
| UI/UX | Trading interaction specs; click-budget verification; one-click safety cues (stake shown at the cursor, ARMED banner) |
| Security | **Gate:** review of signing, Session Key setup, risk engine and audit log. Sign-off required before live |
| QA | Live verification runbook (from a permitted location): minimum-size GTC far from the market, then cancel; cancel-market; cancel-all; one small fill with exit; risk-limit rejections; double-click idempotency |
| Tech Writer | Session Key setup runbook; trading user guide; live verification runbook |

**Exit gate:**

* Security sign-off.
* Every live verification step passes with the audit log matching Polymarket's records.
* All click budgets in §4 are met.
* Orders over the limit are rejected by the backend even if the UI is bypassed.

**Phase 3 decisions (tech lead, at kickoff):**

* Contract: `POST /orders/place`, `/orders/cancel`, `/orders/cancel-market`, `/orders/cancel-all`, `/orders/{id}/replace`, `/positions/exit`, `GET /trading`, `POST /trading/arm` and `/trading/safe`, `GET|PUT /settings/risk`; WS topic `trading`; Go types in `backend/internal/core/trading.go` (`OrderIntent`, `PlaceResult`, `RejectCode`, `core.Trading`, `core.Heartbeat`, `TradingState`).
* Order path: the browser mints a UUIDv7 `client_order_id` per intent. `engine/orders` writes the intent to the audit log, asks `engine/risk`, writes the decision, calls the venue, and writes the response. A repeated `client_order_id` returns the first result. A timeout is `unknown` and is reconciled from open orders and trades, never re-sent.
* Risk (F6, F7, F10, F16): limits from settings, capped by env ceilings (`RISK_CEILING_*`); raising a limit needs the UI password; order rate and duplicate-intent window; price band against a fresh book (reduce-only exits exempt, capped by `exit_slippage_ticks`); ARMED lives in memory, every start is SAFE, and it drops to SAFE on idle timeout, logout, geoblock, credential loss or foreign activity; clock skew against the venue's server time.
* Audit log (F15): intent, decision and response as separate rows keyed by `client_order_id`; UPDATE and DELETE blocked by triggers; each row carries a hash chained to the previous one; `task audit-verify` checks the chain. Never stores signatures or L2 headers.
* Cancel All works in SAFE and when trading is otherwise off; the risk engine never blocks a cancel. A cancel during a sports delay window is queued and sent when the window ends.
* Exit: `walk` sells the whole position at the lowest bid within `exit_slippage_ticks` of the best bid; `join` rests a post-only sell at the best ask. Positions carry `exit_price` and `exit_complete` from `engine/portfolio`.
* Hedge (two-outcome markets): `POST /positions/hedge` takes only the client\_order\_id and the held token; `engine/orders` buys the other outcome's size difference at the walk price on its asks (within `price_band_ticks` of the best ask) through the usual audit and risk path. Positions carry the same quote as `hedge` (or `hedge_unavailable`) from `engine/portfolio`.
* Signing: V2 order EIP-712 for both exchanges, signature type 3 with the ERC-7739 wrapper, and the session-key wrapper per docs/venue/polymarket.md §10. Golden vectors must match; the session-key wrapper has none, so it is built from the type-3 vectors plus the documented encoding and marked UNVERIFIED until live verification. go-ethereum stays (P2-10), recorded in ADR 0010.
* Security P2 items due now: P2-02(c) and P2-03 (session-signers check at credential start, `wallet_mismatch`), P2-04 (open-orders drift check, foreign activity from the Data API, F5), F16 skew.
* Live verification cannot run from the geoblocked dev host. The phase ends with everything built and tested against the fake venue and golden vectors, the security gate reviewed, and `docs/runbooks/live-verification.md` ready for the user to run from a permitted location.
* UI polish from the Phase 2 screenshots and the user's layout feedback (`CLAUDE.md` "Layout consistency"): one control height per row (mobile header: FAKE DATA badge, OFF switch and Cancel All), Positions/Activity tabs without the extra nested strip, search placeholder wrapping at 1440, "12,000.00 sh" wrapping in the portfolio size column, the mobile portfolio summary running off the edge, and average prices rounded for display.

### Phase 4: Alerts and notifications

| Role | Work |
| - | - |
| BE-Core | AlertEngine (price crosses, spread, score events, game start, fills, risk-limit hits), Telegram notifier (bot token/chat ID from env, rate-limited), alerts CRUD API |
| FE-Trading | Alert creation from any price/score (one click from the ladder or board), alerts list |
| FE-Platform | Desktop notifications (Notification API; service worker for background tabs), sound settings |
| QA / Writer | Acceptance checks; Telegram setup runbook |

**Exit gate:**

* A price alert set from the ladder fires on desktop and on Telegram within 2 s of the trigger.
* Fills and game-start book wipes send notifications.

**Phase 4 decisions (tech lead, at kickoff):**

* Contract: `GET|POST /alerts`, `PUT|DELETE /alerts/{alert_id}`, `GET /notifications` (cursor paging), `POST /notifications/read`, `POST /notifications/test`, `GET|PUT /settings/notifications`; WS topics `alerts` and `notifications`. Error code `telegram_error` (502); an unconfigured Telegram shows as `telegram_configured: false` in the notification settings, not as an error.
* Alert kinds: `price`, `spread`, `score`, `game_start`, `game_end`. A price alert above fires when the best bid reaches the threshold (it can be sold there), below when the best ask reaches it (it can be bought there); the threshold sits on the market's tick. A repeating alert re-arms after the value moves one tick back across. At most 200 alerts. Alerts persist in SQLite and survive a restart; on start they resume from the live books without firing on the first snapshot unless the condition newly holds.
* `engine/alerts` is one actor. It asks `engine/books` to keep the books of active price and spread alerts subscribed, and reads games from `engine/games`. It never fires on a stale book or game feed (§3.7).
* `notify` is one actor that turns bus events into `Notification`s. It writes each one to the `notifications` table through `store.Writer`, publishes it on the `notifications` topic, and fans out per the user's rules: in-app toast, sound and desktop in the browser; Telegram from the backend. Kinds: `alert`, `fill`, `order_rejected`, `order_unknown`, `game_start_orders`, `feed_down` (after 30 s), `foreign_activity`, `trading_safe`, `session_key_expiring` (24 h ahead), `credentials`. History is kept 30 days.
* Telegram is outbound only. It uses `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` from env (both or neither; a half-set pair fails startup), `sendMessage` as plain text with no `parse_mode`, and the token only in the request path, never logged or put in an error (the redactor learns it). It allows 20 messages a minute and coalesces the rest into one summary message. It retries once only on 429, honouring `retry_after`, and never after a timeout (a duplicate is worse than a gap). It never receives updates and has no webhook.
* Desktop notifications use the Notification API, shown through a minimal same-origin service worker (`/sw.js`) so they appear while the tab is in the background and focus the app on click. The worker has no fetch handler, no caching and no push subscription. Nothing is delivered when no tab is open; Telegram covers that.
* Sounds are synthesised with Web Audio (no audio files), one short tone per severity, at `sound_volume`, and play only after the first user gesture.
* Alert creation takes one click from the ladder (a bell per level on hover, prefilled at that price and the side-appropriate direction) and from the board (game alerts in the row menu). The alerts screen lists, pauses, resumes and deletes them. No emoji anywhere (`CLAUDE.md` "Icons and text").
* Telegram can be checked from this host once the user adds a bot token and chat ID to `.env`. Until then it is tested against a local HTTP server.

### Phase 5: Removed

Funding (deposits, withdrawals, bridge) and relayer operations (redeem, split, merge) are out of scope: the app only holds trade-only credentials (§1). Phase numbers after this are unchanged. Resolved positions are claimed on polymarket.com; in-app redeem could be added later only if the user provides credentials that allow it.

### Phase 6: Hardening and deployment

| Role | Work |
| - | - |
| DevOps | Multi-stage Dockerfile (Vite build embedded in the Go binary), TLS reverse proxy (Caddy), SQLite backups, deploy runbook for a VPS in a permitted region |
| Security | Deployment review (TLS, cookies, login rate limits, exposed ports) |
| FE-Platform / FE-Trading | Mobile polish pass, performance (virtualised tables, render budget with 50+ live games) |
| QA | Full regression across desktop and mobile |

**Exit gate:**

* One-command deploy.
* The app is reachable over HTTPS behind login.
* The backup restore has been tested.

### Phase 7: Removed

Polymarket Perps (perpetual futures) is out of scope at the user's request (2026-09-30). Phase numbers after this are unchanged.

### Phase 8: Combos

Combo markets, the RFQ quote request and accept flow (decline is local), combo positions and activity, and the Combos exchange signing domain (Exchange V3, timestamps in seconds). UI: a leg builder from the board ("add leg" on any price), a quote timer, and combo positions.

**Phase 8 decisions (tech lead, at kickoff; research in `docs/venue/polymarket.md` §16):**

* Contract: `POST /combos/quote`, `POST /combos/accept`, `GET /combos/rfqs` (this app's RFQs from the last 10 min), `GET /portfolio/combos`, `GET /activity/combos`; WS topics `combo_rfqs` and `combo_positions` (snapshot then deltas, as the other topics). Additions: `Market.combo_eligible`, `Activity.combo`, `CredentialStatus.scopes`, `AccountSummary.combo_cost_basis`, `TradingState.combos_can_trade` and `combos_reasons`, the risk limits below, notification kinds `combo_filled` and `combo_failed`, the Error code `combos_unavailable` (503) and the combo reject codes. Go types and the `Combos` and `ComboAccount` capabilities in `backend/internal/core/combos.go`, plus `Outcome.PositionID`, `Market.ComboEligible`, `Activity.Combo`, `CredentialStatus.Scopes` and `TradingState.CombosCanTrade`/`CombosReasons`.
* Venue API: the Combos **Requester API** (`https://combos-rfq-gateway-requester-api.polymarket.com/v1/requester/rfq`: `POST /requests`, `POST /requests/{rfq_id}/accept`, `GET /requests/{rfq_id}`) with CLOB L2 auth only. No Builder API credentials: they would also authorise relayer calls, which the app must not hold (§1). The order's `builder` field is zero. The official SDK implements only the Builder Gateway, so the adapter calls the Requester API directly.
* Namespace `pmc:` for combo markets (`pmc:<combo conditionId>`) and combo positions (`pmc:<combo positionId>`). Legs stay `pm:` outcomes.
* Adapter at `backend/internal/venue/polymarket/combos/` (the standard file names; no `ws.go`, as there is no requester feed). Signing stays in `venue/polymarket/signing`: its allowlist gains the pair (`"3"`, Exchange V3 `0xe3333700cA9d93003F00f0F71f8515005F6c00Aa`), V3 timestamps are in seconds, and the golden vectors in `testdata/combos/signing_vectors.json` must match byte for byte. The session-key wrapper around the accept signature (§10) has no vector and stays UNVERIFIED until the live run.
* Legs are keyed by Gamma `positionIds` only (never CLOB token ids or condition ids). The browser sends leg token ids; the server maps each to its position id through the catalog. Eligibility comes from Gamma `comboStatus` (`enabled` only); the public RFQ catalog is not a source (§3.7). A market with an outcome whose position id cannot be resolved has `combo_eligible` false, and a quote request naming it is refused with `combo_ineligible`.
* Quote request (glossary: Combo, Leg, Quote, Accept, Decline, Expired, No quote): works in Safe. `engine/risk` `CheckQuote` refuses it with `trading_disabled` (LIVE\_TRADING off), `geoblocked`, `no_credentials` (credentials not ready), `combos_unavailable` (no combos scope), `max_quotes_per_minute` (a local cap of 12 per rolling minute, below the venue's 15 per wallet; `retry_after_ms` says when the next is allowed), `max_combo_legs`, `combo_ineligible`, `market_closed`, `combo_contradictory_legs` (two outcomes of one market or one neg-risk event), and for a buy `max_combo_notional` and `max_combo_exposure` against the stake. It does not require Armed. The slip's stake is the session's active stake, shared with one-click orders; there is no separate combo stake. A quote request is never repeated: a lost answer is `no_quote` with `combo_no_response`, and there is nothing to Accept. The server checks that the venue's answer echoes the request (legs, side, size) before it shows the quote (`combo_quote_mismatch`).
* Accept needs Armed. The browser sends only `client_order_id`, `rfq_id` and `quote_id`; the server signs the amounts of the quote it holds (`maker_amount_e6` and `taker_amount_e6` copied unchanged). `engine/risk` checks every gate of the quote request except the quote cap, then `max_orders_per_minute` (an Accept counts as an order), and for a buy `max_combo_notional`, `max_combo_exposure` and `max_daily_notional` against `total_required_e6` (fee included), and for a sell that `total_required_e6` shares are held (`no_position`). Risk never uses `makerAmount`, since the fee sits outside the signed order. A quote is sent for Accept at most once (`combo_already_accepted`).
* Accept outcome: an unknown Accept (timeout, lost answer, 5xx, 503, 409 `INVALID_RFQ_STATE`) is reconciled by a status read of that `rfq_id` and never re-sent, overruling the venue docs' "safely retry" (CLAUDE.md). A status 409 means the Accept was not recorded: `venue_not_found`. After an Accept the status is polled every 500 ms until `CONFIRMED` or `FILLED` (filled, with `tx_hash`) or `FAILED`, `CANCELED`, `EXPIRED`, `REJECTED` or an unknown status (not filled). `combo_filled` or `combo_failed` (titles `Combo filled`, `Combo rejected`, `Combo failed`) is notified once per Accept, with `Notification.client_order_id` (Phase 4 contract) set to the Accept's `client_order_id`.
* Decline is local only: there is no venue call. The UI dismisses the quote and the server forgets it when it expires. Cancel all also declines a live quote in the app (frontend only; it cannot stop an Accept in flight). Timer: `ComboQuote.expires_in_ms` is computed by the server when it sends each response or WS frame, and the browser counts down from it starting when the message arrives, never from its own clock against `expires_at`. `expires_at` (the venue's deadline, corrected by the measured venue clock skew; the docs give 5 s and 10 s windows, the answer rules) stays for display and audit. The server refuses an Accept at or after `expires_at` without a venue call; the UI disables Accept under 1 s left.
* No indicative product price: the slip shows each leg's live price from its book and no combined price until a quote arrives.
* Combos need a signer with the `COMBOSRFQ` or `ALL` scope, alongside `CLOB`: a key scoped `COMBOSRFQ` alone fails the CLOB credential check, so the recommended key is `["CLOB","COMBOSRFQ"]` (P8-11). The app's current key is `CLOB` only; until the credential status reports the combos scope, quote requests and Accepts are refused with `combos_unavailable` and `combos_reasons` lists it. The combo endpoints answer 503 `combos_unavailable` only when no Combos venue is configured. Reading combo positions and activity needs only the wallet address.
* Combo positions: `/v2/positions/combos` every 60 s and after a fill, valued at cost. Statuses `open`, `won`, `lost`, `partial` plus `redeemable`; the venue's `REDEEMABLE` maps to won and `RESOLVED_PARTIAL` to partial, both to be confirmed in the live run. `AccountSummary.combo_cost_basis` sums the open ones and counts in exposure (liquidation value plus combo cost), but not in liquidation value or unrealised P\&L. Exit is a sell quote for the position's size; payouts are claimed on polymarket.com.
* Foreign activity: `/v2/activity` rows with `is_combo` are matched to this app's accepts by `tx_hash`. Before Phase 8, such a row would be flagged foreign; the check must learn it before combos go live.
* Exchange V3 approvals (pUSD `approve` for buys, PositionManager `setApprovalForAll` for sells) are set by the user on polymarket.com; a missing one comes back as `venue_balance`.
* Risk limits, defaults (the user confirms them at the end of the phase, with gate 5): `max_combo_notional` 5,000 pUSD, `max_combo_exposure` 25,000 pUSD (at least `max_combo_notional`), `max_combo_legs` 10, each capped by an env ceiling (`RISK_CEILING_COMBO_NOTIONAL`, `RISK_CEILING_COMBO_EXPOSURE`, `RISK_CEILING_COMBO_LEGS`; proposed defaults 25,000, 100,000 and 50) and raised only with the UI password, as the other limits. Until the `0016` columns are wired, `GET /settings/risk` reports the defaults as both limits and ceilings.
* Migrations start at `0016` (Phase 4 took 0014 and 0015); the Phase 8 columns are in §3.5.
* Everything is built and tested against the fake venue; the live checks in §16 "Fixtures still needed" run from a permitted location with a key scoped `["CLOB","COMBOSRFQ"]` or `["ALL"]`.

### Phase 9: Yeet

Implement the `core` capabilities for Yeet's API once its docs arrive. The Venue boundary and namespaced IDs mean the UI gains venue filters but no structural changes.

***

## 6. Running the team

Each phase runs roughly like this:

1. **Kickoff:** the tech lead writes the phase's contract diff and task briefs, one per role.
2. **Parallel build:** the roles work in separate worktrees at the same time. Each opens a PR per piece of work.
3. **Review:** the DevOps+Quality agent runs lint/format/typecheck. The UX designer reviews FE PRs, and the security reviewer reviews sensitive PRs.
4. **Merge:** the tech lead merges in contract → backend → frontend → docs order.
5. **Exit gate:** QA runs the acceptance checklist, the tech lead reports to the user, and the next phase starts.

Commits and PRs follow the `CLAUDE.md` format (no AI names or co-author trailers). Each PR is registered with this thread.

***

## 7. Risks and open items

| Item | Mitigation |
| - | - |
| Scraped docs are missing contract addresses, the HMAC recipe, the Deposit Wallet signature encoding and the rate-limit tables | BE-Venue checks against docs.polymarket.com and the open-source v2 clients; each finding goes in an ADR |
| Dev server is geoblocked | Read-only work here; live trading verification only from a permitted location; no workarounds (§1) |
| Signing or amount-rounding bugs | Golden vectors from the official TS SDK must match byte for byte before any live order |
| One-click risk | Backend-enforced limits, SAFE/ARMED, price sanity band, idempotency keys, audit log |
| Sports WS sends no snapshot on connect | Decided: seed once from Gamma per game and after each reconnect, then Sports WS only (§3.7) |
| Full mobile plus ladders | Designer specs a bottom-sheet ladder and long-press gestures in Phase 0; mobile is part of every phase's exit gate |
| Session Key support for Predictions CLOB | Check in Phase 2; if it isn't supported, use an encrypted keystore unlocked by a passphrase at startup (agreed with the user) |


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