The MODE-CONTROL feature is implemented: position_manager (PM) is now the single execution authority for the off / dry / live kill switch. All 12 tasks ran via subagent-driven development with per-task opus reviews on the logic tasks and a final whole-branch opus review (verdict: READY TO MERGE, no Critical/Important). Companion to the design spec 2026-06-21-position-manager-mode-control-design.md and follows the prior execution migration in position-manager-interface-design.

For Agents

PM is the kill switch. A buggy/compromised caller cannot trade when PM reads Off/disabled/breaker-tripped. The runner (polymarket_fetch) is now mode-agnostic: it builds an EntryIntent/ExitIntent, calls pm.place_order, and maps the typed ExecOutcome to a NATS ack. Mode is resolved fresh per call at the POST boundary.

What it does

PM place_order resolves the authoritative mode fresh per call (global × per-source (mode, enabled), plus the breaker) and gates at the irreversible POST boundary:

  • Off → Skipped — zero work (no reserve, no caps, no signer touch, no row).
  • Dry → reserve a dry_run row + build+sign rehearsal — no POST, no caps, no breaker bump.
  • Live → reserve a live row + caps check → place (the migrated place_buy/place_sell flow).

The reserve + caps + breaker check moved out of polymarket_fetch’s engine/runner and into PM. The runner is now mode-agnostic: it builds an EntryIntent/ExitIntent, calls pm.place_order, and maps the typed ExecOutcome to the NATS ack — it no longer resolves mode, reserves, checks caps, or checks the breaker.

Key correctness points

  • Entries gate on effective_mode(global, source) (most-restrictive: Off=0 < Dry=1 < Live=2).
  • Exits gate on the position row’s recorded mode — a live position stays sellable even when global is flipped Off. This is the E1 bug fix (a live position must remain exitable after a halt).
  • enabled=false ⇒ Off is now enforced (previously a dangling gate that the consumer path discarded).
  • A pre-POST reserve/caps DB error → ExecOutcome::TransientNak(30s) retry — nothing reserved, so retrying is safe (preserves today’s try_reserve-error → Nak).
  • Recorded-failure (row already marked failed) and benign-race (mark_submitted/mark_selling==false, no-signer) → Ack — no double-trade, no redelivery loop.
  • Fail-closed-to-Off via two paths: a config::load error → Off; a missing/unparseable source row → (Off, false).
  • cap:event literal preserved (not cap:max_copies).
  • Reserve/caps SQL is byte-identical — moved into PM, not rewritten.

ExecOutcome → ack mapping (runner-owned)

ExecOutcomeAck action
Skipped(off/breaker/disabled/cap:*)Ack
DryRehearsedAck
PlacedAck
RecordedFailure(class)Ack (must NOT redeliver)
BenignRace(reason)Ack
Transient(reason) (pre-POST DB error)Nak(30s)
Stale / past valid_until_ts (runner pre-call gate)Term

Execution: 12 tasks via subagent-driven development

All 12 tasks ran as subagents with per-task opus reviews on the logic-bearing tasks and a final whole-branch opus review.

  • Tasks 1-6 (PM authority side): relocate mode.rs (effective_mode); resolve_entry_mode fail-closed two-path + enabled⇒Off; reserve/caps/4-cap-checks moved byte-identical (Transient path preserved, ledger::Caps reused); EntryIntent/ExitIntent/OrderIntent + ExecOutcome (6 variants incl. Transient); notify strings + control flow byte-identical with old OrderRequest/Mode deleted; gate order (breaker→off clean-skip), Transient at all 4 pre-POST DB sites, dry rehearsal + no-signer-live byte-identical, cap:event literal, 12-field ReserveSpec map.
  • Tasks 7-10 (combined atomic fetch rewire): fetch is non-buildable until the runner is rewired (same shape as migration M2), so done as one commit — exec_to_consumer ack mapping, 16-field EntryIntent, all strategy gates preserved, exits gate on order_id + row.mode, build_autotrade_deps always-PM.
  • Task 11: test-assert fixes; both crates build + test green.
  • Task 12: db-tests — 7/7 mode scenarios ran vs real Postgres (off/dry/breaker/cap:event/disabled exact ExecOutcome+rows; exit-live not-skipped-on-global-off; exit-dry no-post). Signer-host limit: full live-POST/sell path is not e2e (gate logic fully tested).
  • Final review (opus, whole-branch): READY TO MERGE — no Critical/Important. Kill switch correct end-to-end: authoritative PM gate, mode-agnostic runner, exhaustive exec_to_consumer ack mapping, exits on row.mode (E1), fail-closed two paths, cap:event, dry no-breaker, enabled⇒Off, caps live-only.

State

Quick Reference

  • PM main: 351a755 (+11 commits from baseline)
  • fetch main: 4fde695
  • NOT pushed, committed on main in both repos
  • Autotrade mode is OFF — nothing armed
  • Gate: 66 PM offline + 532 fetch + 7 db-tests (vs real Postgres) pass

Open items

Open Items

  1. Commit contamination4fde695 swept in the user’s unrelated in-progress exit_signal / cohort_exit_signal_id work. This is more entangled than a simple stray file: the user has uncommitted firstmover.rs / alert.rs / alerts_consumer.rs / cohort.rs that call exit_signal. Splitting requires coordinating with that uncommitted work — pending user split/keep decision.
  2. Follow-ups: delete the dead orders.rs / EngineConfig cap-helpers (currently under #[allow(dead_code)] — PM owns caps now); add a host-injectable signer seam so the live-POST path can be db-tested end-to-end; clear residual fetch pedantic clippy in untouched code.
  3. Pre-arm: live-test that PM’s notify Arc routes to the autotrade Telegram channel before flipping live (the reviewer could not verify this statically; mode is currently OFF).