Converts position_manager from a single-signer process into ONE process that trades from N signer wallets concurrently — a “wallet axis” running parallel to the existing “source axis”. Built 2026-07-01, merge-ready on branch, NOT yet deployed (mode OFF, 179 offline tests green).

For Agents

Repo: /Users/levander/coding/pmv2/position_manager. Branch feat/multi-signer-wallet, 18 commits from a501ddd, HEAD c6bd9d8LOCAL/UNPUSHED. Design spec: docs/superpowers/specs/2026-07-01-pm-multi-signer-wallet-design.md. Plan: docs/superpowers/plans/2026-07-01-pm-multi-signer-wallet.md. SDD ledger: .superpowers/sdd/progress.md. Ships mode OFF / fail-closed; entries only route once the registry + routing tables are seeded together.

What Changed (single-signer → wallet axis)

Before, PM signed every order with one global signer. Now PM arms a fleet of wallets and each order is routed to one (or more) wallet(s). Two independent axes:

New Schema

TablePurpose
pmv2_walletsWallet registry. wallet_id PK, maker_proxy, signature_type, enc_private_key/key_nonce (BYTEA, AES-256-GCM), mode, per-wallet caps (total_exposure, daily_spend, max_open, max_copies_per_event), size_scale NUMERIC DEFAULT 1.0.
pmv2_source_walletssource_id ↔ wallet_id many-to-many routing map.
pmv2_autotrade_ordersGained wallet_id column; dedup index changed to (wallet_id, dedup_key).

Three migrations total (registry, routing, orders wallet_id + dedup index).

Core Design Decisions

  • Routing — configurable per source→wallet, many-to-many via pmv2_source_wallets. One source can fan out to multiple wallets; one wallet can serve multiple sources.
  • Isolation — enforced by per-wallet caps + ONE global breaker (not per-wallet breakers).
  • Key storage — private keys encrypted in DB (AES-256-GCM), master key from env PM_WALLET_ENC_KEY. Decrypted at arm time into signer_for(wallet_id).
  • Ops surface — per-wallet + an all selector. (Currently bridged over a wallet:-prefix ops hack until pmv2-contracts adds a real wallet wire field.)
  • Executionone durable NATS consumer per wallet (pmv2_orders_<wallet_id>). Distinct durable names = JetStream fan-out, so every wallet consumer sees every routed order.
  • Mode resolution is 3-way: effective = min(global_ceiling, wallet.mode, source.mode), fail-closed. (Old path was 2-way min(global, source).)
  • Per-wallet size_scale multiplier on the producer size_usd, applied pre-quantization. 1.0 = identical sizing to today; a smaller-bankroll wallet uses < 1.0.

Cap Breach = Advisory, Breaker = Faults Only

Semantic split

A per-wallet cap breach is ADVISORY — it skips/rejects that order but does NOT trip the breaker. The global breaker trips only on genuine faults: balance / auth / network / settlement. breaker_counts no longer includes a "cap" category. This keeps a chatty over-cap wallet from taking down the whole fleet.

Wallet-Aware Entry & Exit

  • Entry — the authoritative money gate place_entry resolves via resolve_wallet_entry_mode(wallet_id, source_id); place_buy/place_sell sign via signer_for(wallet_id).
  • Exit — each exit row loads its own wallet_id and signs with the position’s OWN wallet. A missing signer → BenignRace (skip), so PM can never sell a position from the wrong wallet.

Gotchas / Lessons (these bit us mid-build)

SAFETY GAP — wallet-blind money gate (fixed b2faddf)

The authoritative entry gate place_entry was originally wallet-BLIND: it used the 2-way resolve_entry_mode(global, source). A wallet set to dry while global+source were live would have POSTed a LIVE order. Fix: call resolve_wallet_entry_mode(wallet_id, source_id) at the money gate. Lesson: the multi-wallet mode clamp MUST be enforced at PM’s authoritative re-resolve, not just at the consumer’s early skip. The early skip is an optimization; the money gate is the guarantee.

BOOT-CRASH BLOCKER — nonce panic crashes the whole fleet (fixed c6bd9d8)

decrypt_private_key used Nonce::from_slice, which PANICS on a non-12-byte nonce. The backfill migration seeds a 1-byte placeholder key_nonce = E'\\x00' for wallet1. Booting with PM_WALLET_ENC_KEY set before the real-ciphertext UPDATE runs would panic and crash the ENTIRE fleet — arm_wallets is awaited directly in boot (not spawned in a JoinSet), so one bad wallet takes down the process. Fix: length-check the nonce → return Err → isolated per-wallet arm failure (PM runs unsigned/DRY until wallet1 is properly seeded). Lesson: GenericArray::from_slice (and Nonce::from_slice) panic on length mismatch. Always length-check bytes before constructing fixed-size crypto arrays, especially from DB-sourced placeholders.

Legacy single-env fallback is fail-closed for ENTRIES

When the registry (pmv2_wallets) is empty, pmv2_source_wallets is also empty, so every entry SkipAcks (“not routed”). In real rollout the backfill seeds both tables together, so the registry path is used. Exits still work in legacy mode (they carry their own wallet_id semantics), only entries go dark.

Exit fan-out is O(N×M) — future efficiency target

Every wallet consumer processes each exit signal and fetches the CLOB book per row before the mark_selling CAS; only the first wins (others BenignRace). Correct, but wastes N−1 book fetches per row → extra CLOB rate-limit pressure. A candidate optimization is to gate the book fetch behind ownership/CAS first.

Deploy-Gated (NOT done — off-session when going live multi-wallet)

  1. Apply the 3 migrations to prod.
  2. Seed each wallet’s encrypted key via the pm-encrypt-key bin (stdin key → BYTEA literals, run by deploy) into the seed UPDATE.
  3. Bump pmv2-contracts rev to add a real wallet wire field replacing the wallet:-prefix ops bridge.

Accept-as-Followup Cleanup

  • Orphaned dead pub API: resolve_entry_mode (mode.rs), load_source_caps/SourceCaps (ledger.rs) + dormant nullable source-cap columns on pmv2_autotrade_sources.
  • is_mapped (tests-only).
  • /status top usage line + /wallets uses section headers — operator to pick prefix-vs-header presentation.