Key design decisions for the new position_manager (PM) crate: a focused Polymarket execution + watch crate that the deferred autotrade → position_manager rename lands into, scoped down to execution + state + watching. This is a behavior-preserving migration out of polymarket-fetch, not a greenfield engine rehome. Source artifacts: docs/superpowers/specs/2026-06-20-position-manager-interface-design.md and docs/superpowers/plans/2026-06-20-position-manager-execution-migration.md in /Users/levander/coding/pmv2/position_manager.

Plan 1 IMPLEMENTED (2026-06-21) — see position-manager-execution-migration-implemented

The execution migration described in this note is DONE. All 11 tasks executed, final whole-branch opus review verdict READY TO MERGE (no Critical/Important). Committed on main in both repos (PM head cbd0f4c, fetch head afc785d), not pushed, autotrade mode OFF. Outcome, gate results, and open user decisions: position-manager-execution-migration-implemented.

For Agents

Reference note for future sessions. PM repo: /Users/levander/coding/pmv2/position_manager (new Cargo workspace, git init once in Task 1, NOT yet a git repo). Upstream: /Users/levander/coding/polymarket_fetch. Plan 1 (execution migration) is written; Plans 2 (self-wallet watcher) and 3 (watched-tx watcher) are sequenced but not yet written.

Post-audit revision (2026-06-20) — read before acting

This note was revised after a design audit. Two earlier claims were walked back:

  1. Migration ownership — PM does NOT run a second sqlx::migrate!. The pmv2_autotrade_orders table stays created by the single existing upstream migrator; PM owns the ledger CODE only. Physical migration relocation is deferred. The earlier “adopt-if-exists migration owned by PM” plan was a sqlx trap — see sqlx-shared-migrations-table-trap and §4.
  2. place_order scope — it now covers BOTH buy and sell. The auto-sell/exit path folds into PM so the whole position lifecycle (buy → submitted → filled/partial → open → selling → sold) lives in PM. Also folded in: a latency win (use_server_time(false)) and config table co-ownership (PM reads caps, writes breaker counters).

1. Boundary — focused execution+watch crate, NOT a full engine rehome

PM owns: place_order (build/sign/POST + record) for both buy and sell, the full position lifecycle (buy → submitted → filled/partial → open → selling → sold, including the exit/auto-sell path), the position ledger code (ledger.rs), the circuit-breaker counter writes, self-wallet reconciliation, watched-transaction detection.

place_order now spans the whole lifecycle (buy + sell)

place_order migrates BOTH today’s submit_live (buy entry) AND auto_sell (sell exit). Side::Buy drives the entry path; Side::Sell drives the exit path. The exit transitions — mark_selling / record_partial_sell / mark_sold / settle_placed_fill — fold into PM, so the entire position state machine lives in one crate. Earlier the design scoped place_order to the buy path only; that was widened in the audit.

Strategy stays upstream in polymarket_fetch: detection sources, gates (EV, conviction, price-bounds, category, time-to-resolution), sizing, first-mover gating, the NATS Pmv2Consumer, and mode selection. Upstream depends on PM and calls in-process. The mode != "live" / dust / floor-price guards stay upstream in route_exit, which builds an already-validated sell OrderRequest.

2. Dependency direction — one-way, in-process

polymarket_fetchposition_manager (one-way, no cycle). PM is a leaf crate: the polymarket-clob crate (vendor rs-clob-client-v2 SDK + V2 signing semantics) moves INTO PM. Single host process to start (today’s pmv2 consumer binary links PM and runs both watchers + strategy in-process). Event sinks are traits, so a later split into a separate watcher binary needs only a NATS-publishing sink impl — no PM internal changes.

3. Exposed API

PositionManager::connect(signer, proxy, pool, clob_cfg) -> Result<PositionManager>
async fn place_order(&self, req: OrderRequest) -> Result<PlacedOutcome>;
async fn watch_self_wallet(&self, sink: impl FillSink) -> JoinHandle<()>;
async fn watch_transactions(&self, addrs: AddressSet, sink: impl FillEventSink) -> JoinHandle<()>;
  • place_order(OrderRequest) -> PlacedOutcome — migrates BOTH today’s submit_live (buy) and auto_sell (sell). Preserves FAK order type, proxy signatureType = 1, V2 contracts. PlacedOutcome wraps today’s PlacedOrder (clob_order_id, status, making_amount, taking_amount, fee_usd, tx_hashes, trade_ids, ledger_id).
    • OrderRequest carries reserve_id, NOT a provenance block. It acts on an already-inserted reserve row (pmv2_autotrade_orders id). source_id / origin_ref / source_score / dedup_key / signal_ts are written upstream at reserve time by persistence.rs’s RESERVE_INSERT_SQL (INSERT … RETURNING id); PM operates on that row by reserve_id and needs none of it. (Earlier the design proposed an opaque provenance block — removed in the audit.)
    • Buy fields (size_usd, limit = BUY price bound) drive the entry; build_order is called with size_usd + Decimal::ZERO.
    • Sell fields (sell_shares = quantized shares / build_order’s filled_size arg, floor_price = SELL floor, expected_shares, held = current filled_size for settle proration, cost_basis_usd, slippage_cap_bps) drive the exit; build_order is called with Decimal::ZERO + sell_shares. This buy/sell split mirrors the two real SigningClient::build_order(token_id, side, size_usd, filled_size, limit_price, neg_risk) call shapes.
  • watch_self_wallet — long-running task: CLOB /user WS (L2 auth, subscribe by held condition_ids) matched to the ledger by clob_order_id, PLUS a periodic on-chain balanceOf / pUSD truth check. On divergence: chain is authoritative — correct the ledger toward chain truth and emit a reconcile event (never silent overwrite).
  • watch_transactions — long-running task, the unbuilt Phase-2 detector: Polygon OrderFilled WSS (eth_subscribe logs, topic0-filtered) over a configurable address set → raw FillEvent { trader, token_id, side, size, price, tx_hash, log_index, block_ts }FillEventSink → upstream first-mover gating.
  • Sinks are traits (FillSink for reconciled self-fills, FillEventSink for raw watched fills). Default impls are in-process channels; a NATS impl drops in later, making the separate-watcher-process split cheap.

4. Schema split — code ownership only; NO second migrator (walked back)

Table / columnsMigrator (runs DDL)Code owner (runs queries)
pmv2_autotrade_orders (whole ledger)upstream (single existing sqlx::migrate!)PM (ledger.rs)
Caps: total_exposure_cap_usd, daily_spend_cap_usd, max_open_positions, max_copies_per_eventupstreamPM reads (load_caps) — sole counter of exposure / open positions
Breaker: breaker_tripped, breaker_fail_countupstreamPM writes (bump_breaker_count, config-only id=1 FOR UPDATE lock) + reads (load_breaker)
Strategy filters (category_filter, min_conviction, entry-price bounds, edge/time/slippage/premium/fill-size) + modeupstreamupstream (mode read upstream, passed per-call into OrderRequest)

Migration ownership WALKED BACK — PM runs NO migrator (DEFERRED)

The earlier plan had PM adopt pmv2_autotrade_orders via a CREATE TABLE IF NOT EXISTS migration. That was removed. PM owns the ledger CODE (ledger.rs — status transitions, caps reads, breaker writes), NOT a second sqlx::migrate!. The table stays created by the single existing upstream migrator (it already exists in prod). PM must not call sqlx::migrate!, must not create a migrations/ dir, must not edit supabase/migrations/20260614000000_pmv2_autotrade.sql, and the sqlx migrate feature is intentionally omitted from Cargo.toml.

Why: sqlx 0.8 tracks ALL applied migrations in one shared _sqlx_migrations table keyed by version. A second migrate! set throws VersionMissing on the ~43 other upstream migrations; reusing version 20260614000000 collides as VersionMismatch; editing the already-applied file bricks boot via checksum mismatch; and CREATE TABLE IF NOT EXISTS does NOT save you (sqlx tracks by version row, not table existence). Physical migration relocation is deferred to a coordinated single-migrator change. Full breakdown: sqlx-shared-migrations-table-trap.

pmv2_autotrade_config is CO-OWNED, not read-only

The config table physically stays upstream (strategy filters + mode live there). PM reads the caps subset (load_caps) AND writes the breaker counters (bump_breaker_count). Call it co-owned — the split is ownership of meaning per column, not a table relocation. PM is the single component that can count open positions / sum exposure from the ledger.

5. Behavior-preserving — proven by golden parity

No column changes, no semantic changes to build/sign/POST/record — only relocation of which crate runs the code. No migration file is moved or edited. Proven by golden-parity tests: the same OrderRequest produces the same maker/taker/contract triple (locked buy vector maker 6_250_000 / taker 7_621_900 / std contract / limit 0.82; locked sell vector maker 10_000_000 / taker 4_000_000 / floor 0.40) AND the same ledger transition sequences (pending → submitted → filled|partial → open for buy; open → selling → closed for sell). assert_amounts / assert_sell_amounts reject the same mutations (wrong contract, amount drift, below-floor) the upstream execute.rs / exits.rs tests rejected. A wiremock mock-CLOB integration test drives place_order for success / partial / reject / timeout (and a sell) and asserts the exact ledger status sequence + recorded filled_size / avg_fill_price / cost_basis_usd.

6. Three sequenced plans

  1. Execution migrationwritten. Move polymarket-clob in → move execute/ledger/onchain → rewire submit_live to PM::place_order → move pmv2_autotrade_orders migration ownership + caps/breaker readers → declare FillSink/FillEventSink/FillEvent seams (unimplemented).
  2. Self-wallet watcher — CLOB /user WS + balanceOf reconcile.
  3. Watched-tx watcherOrderFilled WSS detector wired to upstream first-mover gating.

7. Correctness invariants to preserve (must not regress)

  • V2 hard cutover (2026-04-28): verifyingContract std 0xE111180000d2663C0091e4f400237545B87B996B / neg-risk 0xe2222d279d744050d28e00520010520000310F59; pUSD collateral 0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB; domain version "2", chain_id 137. A stale-V1 order is rejected, not slow (see 2026-06-20-fastest-polymarket-trade-submission).
  • connect_timeout rule: every reqwest/WS client sets BOTH timeout AND connect_timeout — the 75-min silent-hang lesson. The moved onchain.rs eth_call client gains connect_timeout on the move.
  • Latency win — use_server_time(false) (folded into the migration): the clob client hard-codes Config::builder().use_server_time(true) at polymarket-clob/src/lib.rs:329, which fires a serial GET /time before every order POST (~20–100 ms). Flip it to false when the crate moves into PM: stamp the HMAC auth header from local Utc::now() instead. The signed-order EIP-712 bytes are unchanged (only the auth-header timestamp moves), so the locked hash/parity tests stay green; add a one-line NTP clock-sanity note (host clock must be sane). The 3 per-trade GETs (~150–600 ms) in market_data.rs::resolve are upstream and out of PM scope; HTTP/2 + colocation are deferred pending p50/p99 measurement.
  • Execution ordering: 30s SUBMIT_TIMEOUT on build_order and place_order; mark_submitted BEFORE POST (idempotency); reserve race → skip POST; POST timeout → leave row submitted, bump breaker network, no blind retry (order may have landed); breaker-tripped → cancel without POST; error classification auth | balance | network | validation | other; default mode OFF, dry-run-in-prod first.
  • Watcher resilience: WS reconnect with exponential backoff + jitter; gap-backfill on reconnect (bounded historical sweep, see watcher-onchain-gap-backfill); fill dedup by (tx_hash, log_index).

8. Open items

  • Shared-table migration is DEFERRED. PM does not run a migrator in Plan 1 — the single existing upstream sqlx::migrate! keeps creating pmv2_autotrade_orders. A physical relocation would require a coordinated single-migrator change (sqlx 0.8’s one shared _sqlx_migrations table makes a second migrate!, a version reuse, or an edit to the applied file all fatal at boot — see sqlx-shared-migrations-table-trap). Out of scope here.
  • Process split deferred — sink traits keep the separate-watcher-binary option open without rework.
  • babylon deploy open redeploy tasks (e.g. #268) are not visible to positionmanager; decide whether deploy/redeploy responsibility moves to the PM handle.
  • No code change to the live trade path during design — migration starts only after spec + plan approval.