The pmv2 monorepo is being split into independent services/repos by concern — execution (the position_manager, owned by @deploy), signal producers, and a notification service. This session carved the repo down to ONE concern: the cohort-intelligence signal producer (scoring + consensus + first-mover entry + exit detection), deleting ~13,400 lines of everything else. It then added cohort exit signals: a tracked cohort trader selling on-chain now publishes an action:Sell to NATS cohort.signals + alerts, so the downstream executor can close the copied position — closing the loop with the first-mover ENTRY lane.

For Agents — state at a glance

Branch worktree-cohort-signals, carved from main @ 43023ba.

  • Carve commit: 8cd6f58 — repo reduced to cohort scoring + consensus + first-mover + exit detection; ~13,400 lines deleted; 3 workspace crates dropped (polymarket-clob, pm-ws, lag-probe). Gate green.
  • Exit-signals commit: e762f5don_cohort_sell now publishes action:Sell to cohort.signals + Telegram, in addition to its prior DB bookkeeping. Gate green: polymarket-fetch 153 / polymarket-data 107.
  • This is the producer half of a producer→executor split over NATS. The executor is @deploy’s position_manager (position-manager-interface-design, position-manager-execution-migration-implemented). The transport (cohort.signals subject, Pmv2Consumer) is the lane established in cohort-firstmover-nats-migration + pmv2-position-manager-nats-consumer.
  • NOT merged, NOT deployed. Introduces a PROVISIONAL cross-service contract (executor must consume action:Sell idempotently) — still to be coordinated with @deploy.

The re-architecture: monorepo → services by concern

The pmv2 monorepo mixed three unrelated concerns in one repo (and the watcher process mixed them in one binary). The split assigns each concern to an owner:

ConcernOwnerWhat it does
Execution@deploy’s position_manager cratebuild/sign/POST CLOB orders, position ledger, caps + breaker, reconcile, self-wallet watch
Signal producersthis repo (cohort-signals)cohort scoring, consensus, first-mover ENTRY signals, cohort EXIT signals
Notification service(separate, future)Telegram / operator alerts

This worktree-cohort-signals branch is the first producer carve: it owns ONLY the cohort-intelligence signal producer. Producers emit TradeSignals onto NATS; the executor consumes them and owns the money-path. Neither side calls the other in-process anymore.

Producer → executor over NATS (post-split topology)

graph TD
    subgraph PROD["cohort-signals (this repo) — PRODUCER"]
        SCORE["cohort scoring<br/>Pmv2Collect"]
        CONS["cohort consensus<br/>Pmv2Consensus cron + ConsensusTracker"]
        FM["first-mover ENTRY<br/>sources::enter_signal"]
        EX["cohort EXIT<br/>on_cohort_sell → sources::exit_signal"]
    end
    FM -->|"action:Buy"| SUBJ["NATS cohort.signals<br/><i>durable pmv2</i>"]
    EX -->|"action:Sell"| SUBJ
    SUBJ --> EXEC["position_manager (@deploy) — EXECUTOR<br/>Pmv2Consumer → money-path<br/>reserve→sign→submit→fills→reconcile→exits→settle"]
    style PROD fill:#264653,stroke:#2a9d8f,color:#fff
    style SUBJ fill:#2d2d2d,stroke:#888,color:#fff
    style EXEC fill:#3d2020,stroke:#a55,color:#fff

Part 1 — the carve (8cd6f58)

The repo was reduced to only the cohort-intelligence signal producer, deleting ~13,400 lines. Gate green.

Kept (the cohort-intelligence slice)

  • Cohort scoringPmv2Collect. Kept deliberately so the slice is self-contained (a producer that can rank its own cohort, not one that depends on an external scoring service).
  • Cohort consensus — the Pmv2Consensus cron and the on-chain ConsensusTracker in pmv2-watch.
  • First-mover signal production — the ENTRY lane.
  • Cohort exit detection — the SELL detection substrate (extended in Part 2).
  • On-chain gap-backfill sweep — the 30s eth_getLogs reconciliation (watcher-onchain-gap-backfill) preserved byte-for-byte (it backfills the cohort detection feed the producer depends on).

Deleted (~13,400 lines)

  • The entire autotrade execution engine: autotrade/{engine, execute, exits, runner, reconcile, settle, preflight, sizing, pricing, orders, …}.
  • The onchain_book triggers: fill / settle / self-trade.
  • The Telegram bot.
  • Self-trade & overlap watchers (first-mover-overlap-watcher).
  • Resolution-detection (autotrade-resolution-proposed-entry-guard).
  • Trending, bet/market scoring, and the independent tracked-detector lane.

Dropped workspace crates

  • polymarket-clob (CLOB signing / execution) — notably NOT a dependency of cohort signals at all. A signal producer never signs or POSTs an order, so the CLOB crate has zero pull from this slice. (It already moved into the position_manager workspace per pmv2-polymarket-clob-crate-migration — this carve confirms the producer doesn’t need it.)
  • pm-ws
  • lag-probe (lag-probe)

The watcher was ONE service mixing two concern-classes

pmv2-watch was a single binary mixing cohort-signal lanes (consensus, first-mover, exit) with execution-trigger lanes (self-trade alerts, fill/reconcile, settlement). The carve strips the execution-trigger lanes and preserves the cohort-signal lanes + the gap-backfill sweep byte-for-byte. This is the watcher-level expression of the producer/executor split: the same binary previously straddled both sides of the boundary now being drawn.

Method — compile-driven carve, KEEP-WHOLE verified diff-clean

The deletion was compiler-driven: delete the migrated callers, then follow the compiler errors to the next thing that can go. Files in the KEEP set were verified diff-clean vs base (43023ba) so the preserved cohort/consensus/first-mover/exit/backfill code is provably unchanged — the carve is pure subtraction, not a rewrite. This is the same “preserve byte-for-byte, prove it” discipline the NATS migration used for the money-path.

Part 2 — cohort exit signals (e762f5d)

New requirement: “watch trade exits the same way as first-mover entries.” The first-mover ENTRY lane already publishes action:Buy to cohort.signals (cohort-firstmover-nats-migration). The EXIT side did not — it was DB-only.

Before → after

Before (the pmv2-phase4-chain-cohort-sell-fast-exit behavior): when a tracked cohort trader SOLD / exited a position on-chain, the handler on_cohort_sell did DB bookkeeping only — logged an EXITED pmv2_position_events row and deleted the tracked pmv2_positions rows. It neither alerted nor published. The intent there was to ride the existing exit cursors (the cron’s pmv2_exit_alert + autotrade_exits cursors would pick up the early-inserted row). That model assumed the executor lived in-process and read those cursors.

After (this work): the executor now lives out-of-process (the position_manager over NATS), so the EXIT must be published as a signal, exactly like the ENTRY. on_cohort_sell now:

  1. Publishes an action:Sell TradeSignal to NATS cohort.signals.
  2. Sends a Telegram alert.
  3. Then does its prior bookkeeping (log EXITED + delete pmv2_positions).

sources::exit_signal(PositionRow) builds the Sell signal — Execution::Context (same arm as the ENTRY), reference_price = position.cur_price.

Load-bearing — the exit signal_id MUST carry an exit discriminator distinct from the entry signal_id

The consumer dedups via ON CONFLICT(dedup_key) DO NOTHING keyed on signal_id. The ENTRY for (condition, outcome) already published a Buy with signal_id == orders::dedup_key(.., "enter", ..). If the EXIT for the same (condition, outcome) reused that key, the consumer’s ON CONFLICT would silently swallow the Sell as a duplicate of the entry Buy — the position would never be closed = capital trap.

The fix: the exit signal_id carries an exit discriminator (distinct from "enter") so the Buy and the Sell for the same market are two distinct dedup keys → both persist → both execute. Test-locked: exit_signal_id_differs_from_enter_signal_for_same_market.

This is the exit-side analogue of the dedup-identity work in cohort-firstmover-nats-migration (where the ENTRY signal_id was made byte-identical to the legacy orders::dedup_key(.., "enter", ..)): the dedup key is THE correctness surface for the at-least-once NATS lane, and entry vs exit must occupy different keys for the same market.

Correctness details

Publish BEFORE delete — a publish failure can't silently lose the signal

Ordering is publish-then-delete. If publish were attempted after deleting the pmv2_positions row, a publish failure would leave the position gone from the snapshot (so it can never be re-detected) AND the Sell never on the wire → a permanent silent miss of the exit. Publishing first means a publish failure leaves the position row intact, so the next pass can retry. (Same “a dropped publish is a permanent miss → make it loud / don’t lose it” lesson as the ENTRY publisher in cohort-firstmover-nats-migration.)

DRY — entry + exit share publish_cohort_with_retry

The ENTRY and EXIT publishers now share a single publish_cohort_with_retry helper (retry + backoff, with a drop-alert on give-up so an exhausted publish is never silent). One publish path, one retry policy, one give-up alarm for both directions.

The score-based eligible_sell + ExitDebounce gate is unchanged

The existing exit gate from pmv2-phase4-chain-cohort-sell-fast-exiteligible_sell(sig, score) (cohort member AND score > MIN_COHORT_SCORE) + the per-wallet 60s ExitDebounce coalesce — was kept as-is. It was NOT narrowed to Core-tier (the ENTRY lane gates on Core tier; the EXIT lane intentionally does not — you want to exit any cohort sell you’re mirroring, not only Core-tier ones).

The provisional cross-service contract

PROVISIONAL contract — @deploy's executor must consume action:Sell idempotently (NOT yet coordinated)

This work introduces a new cross-service contract that is not yet coordinated:

  • @deploy’s position_manager must consume action:Sell on cohort.signals (today it consumes action:Buy entries + weather directives).
  • It must treat repeated Sells as idempotent — the at-least-once lane + redelivery means the same Sell signal_id can arrive more than once; closing an already-closed position must be a no-op, never a double-close or an error.

Until @deploy wires the Sell consumer, the EXIT signals publish into a consumer that doesn’t act on them. Treat “executor consumes action:Sell idempotently” as a hard prerequisite to arming the exit lane live. (This is the SELL-side analogue of the ENTRY-side pre-arm gate noted in cohort-firstmover-nats-migration.)

Gate

scripts/gate.sh green at both commits:

  • Carve 8cd6f58: gate green.
  • Exit-signals e762f5d: polymarket-fetch 153 tests / polymarket-data 107 tests, including the exit_signal_id_differs_from_enter_signal_for_same_market lock.

(The test count drop vs the pre-carve ~563 / 107 reflects the ~13,400 lines — and their tests — deleted in Part 1.)

Status & open items

Built + gate-green on worktree-cohort-signals; NOT merged, NOT deployed

  1. Coordinate the provisional contract — @deploy’s position_manager consumes action:Sell on cohort.signals, idempotently.
  2. Deploy — both the carved producer and the executor changes are off main.
  3. The cohort-publisher NKey + cohort.signals stream subject work from cohort-firstmover-nats-migration’s T5 still applies (the EXIT publishes to the same subject as the ENTRY — no new subject needed).