Skip to main content

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

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.

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

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

Phase 8 (Combos) adds columns in migration 0018_combos.sql (0016 and 0017 were taken by Phases 4 and 6) and no new table: 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.

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): Where motion is used: 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):

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

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

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