Phase 4 of the on-chain settlement/fills/cohort-exit plan (branch autotrade-onchain-book): a SELL branch in the tracked-alerts consumer that, on a real on-chain cohort SELL, runs a per-wallet exit snapshot-diff seconds after the fill instead of waiting up to ~30 min for the cron. The money-critical decision is how it dedups against the cron so it never double-alerts or double-sells — and the answer is log the EXITED row + delete the prior snapshot row (riding the existing cursors), not firing a direct alert/sell from the chain path.
Superseded for the out-of-process executor (2026-06-22)
This note describes the
on_cohort_sellbehavior when the executor was in-process (log theEXITEDrow + delete the snapshot, then let the cron’spmv2_exit_alert/autotrade_exitscursors pick it up). After the producer/executor service split, the executor lives out-of-process over NATS, soon_cohort_sellnow also publishes anaction:Selltocohort.signals+ sends a Telegram alert (the DB bookkeeping below is retained). See cohort-signals-service-split-and-exit-signals — including theexit-discriminatorsignal_idgotcha that keeps the Sell from colliding with the entry Buy ondedup_key.
For Agents — the one-paragraph version
The chain SELL path does
detect_exitedscoped to{wallet}, and for confirmed exits it logs theEXITEDrow (repo::insert_position_event) and deletes the priorpmv2_positionsrows (repo::delete_positions) — mirroring exactly what the cron’srun_pmv2_positionsdoes. It does NOT send a direct alert and does NOT callrun_exits/auto_sell itself. Deleting the prior snapshot row means the cron’s nextdetect_exitedwon’t re-see it → exactly oneEXITEDrow exists → the existingpmv2_exit_alertcursor alerts once andrun_exitssells once. The chain path adds no new alert source and no new sell path — it just makes the same row appear seconds early. Files changed: onlyalerts_consumer.rs+ themain.rscall-site.
What was built
A SELL branch in crates/polymarket-fetch/src/pmv2/tracked/alerts_consumer.rs — the same consumer that holds the BUY-side consensus filter (tracked-alerts-consumer, which explicitly flagged its unused TrackedSignal fields as “forward-looking for Phase-4 downstream consumers” — this is that Phase 4).
On a cohort SELL detection (TrackedSignal.side == "SELL", trader in cohort with score > MIN_COHORT_SCORE = 0.0), it runs a fast per-wallet exit check that accelerates exits off the ~30-min cron.
New symbols:
| Symbol | Role |
|---|---|
eligible_sell(sig, score) | gate: side == "SELL" AND cohort member AND score > MIN_COHORT_SCORE (0.0) |
ExitDebounce | per-wallet 60s coalesce — collapses a burst of sells from one wallet into a single exit pass |
on_cohort_sell(wallet) | the per-wallet exit pass (snapshot diff → log + delete) |
Wiring into run_tracked_alerts (signature gained cfg: &AppConfig):
- a
DataApiClientviacfg.build_data_client(already setsconnect_timeout— see the proxied-client silent-hang rule) - a
tokio::Semaphore(cap 4) bounding the per-wallet fan-out - the
ExitDebounceinstance
Blast radius: exactly 2 files
alerts_consumer.rs(the branch + new symbols) andmain.rs(the call-site now passingcfg). Nothing else.
The crux — three consumers of an EXITED row, each with its OWN dedup
The exit pipeline already has three independent consumers of an EXITED row. Phase 4 had to slot in without adding a fourth dedup surface. Understanding the three existing ones is the whole decision:
| Consumer | What it does | Its dedup mechanism |
|---|---|---|
Cohort EXIT alert (operator-facing) — crate::pmv2::exits::run_check | reads recent_exits filtered detected_ts > the pmv2_exit_alert cursor; advances cursor to max alerted detected_ts | the pmv2_exit_alert timestamp cursor in pm_detection_cursors (pmv2-exit-alerts) |
auto_sell — runner::run_exits | reads autotrade_exit_events past the autotrade_exits cursor; the actual sell is gated by autotrade_sellable_for_origin (WHERE status='open') AND mark_selling | the autotrade_exits cursor + the mark_selling open→selling CAS (autotrade-repo-exit-helpers, autotrade-selling-state-foundations) |
insert_position_event | a plain INSERT — NO ON CONFLICT | none at the table level (see below) |
pmv2_position_eventshas NO unique constraint — you CANNOT dedup exits at the DB layerThe
UNIQUE(proxy_wallet, condition_id, outcome_index)constraint onpmv2_position_eventswas DROPPED in migration20260603000000_pmv2_overlap.sql(the self-wallet-overlap design — the table became an append-only lifecycle log so enter→exit→re-enter can coexist; see Migration 20260603000000_pmv2_overlap).insert_position_eventis a plain INSERT with noON CONFLICT. DuplicateEXITEDrows ARE possible at the table level. Dedup therefore lives in the consumers’ cursors + themark_sellingCAS, never in a DB constraint.
mark_sellingis the load-bearing concurrency guard
mark_selling= atomicUPDATE ... SET status='selling' WHERE id=$1 AND status='open', returningtrueonly if 1 row transitioned. It serializes all three sell triggers (cohort-exit event / missed-exit queue / 1-click Sell): only one caller wins the open→selling transition, the rest see 0 rows and abort. This is the ultimate backstop even if a duplicateEXITEDrow slips through. (autotrade-selling-state-foundations)
The safety decision (why this is safe)
The chain SELL path does detect_exited scoped to {wallet} (snapshot diff: a live data-API fetch vs the prior pmv2_positions rows). For confirmed exits it:
- logs the
EXITEDrow —repo::insert_position_event - deletes the prior
pmv2_positionsrows —repo::delete_positions
This mirrors exactly what the cron’s run_pmv2_positions does (log_exit_events then delete_positions). It does NOT send a direct alert and does NOT call run_exits/auto_sell itself.
Why no double-alert / no double-sell:
- Deleting the prior
pmv2_positionsrow means the cron’s nextdetect_exitedwon’t see that position → the cron won’t log a secondEXITEDrow → exactly oneEXITEDrow exists → the existingpmv2_exit_alertcursor fires the operator alert exactly once, andrun_exitssells exactly once. - Even if a duplicate row somehow appeared, the sell is still safe: the
mark_sellingopen→selling CAS lets only one caller win. - The chain path adds no new alert source and no new sell path — it just makes the same
EXITEDrow appear seconds after the on-chain SELL instead of up to 30 min later. The existing cursor-driven consumers then act on their normal fast cadence.
Flow — chain SELL slots a row in front of the cron; cursors do the rest
graph TD CHAIN["on-chain cohort SELL<br/>TrackedSignal side=SELL"] --> ELIG["eligible_sell<br/>cohort + score>0"] ELIG --> DEB["ExitDebounce<br/>per-wallet 60s coalesce"] DEB --> DIFF["detect_exited {wallet}<br/>live fetch vs pmv2_positions"] DIFF -->|confirmed exit| LOG["insert_position_event<br/><b>+ delete_positions</b>"] LOG --> ROW["exactly ONE EXITED row"] CRON["~30-min cron<br/>run_pmv2_positions"] -.->|prior row gone → no 2nd EXITED| ROW ROW --> CUR1["pmv2_exit_alert cursor<br/>→ operator alert ×1"] ROW --> CUR2["autotrade_exits cursor<br/>+ mark_selling CAS<br/>→ auto_sell ×1"] style LOG fill:#3d2020,stroke:#a55,color:#fff style ROW fill:#264653,stroke:#2a9d8f,color:#fff style CRON fill:#2d2d2d,stroke:#888,color:#fff
Rejected alternative — a direct alert from the chain path
Sending a direct EXIT alert from the chain path was considered and rejected. It would need its own cross-process dedup against the cron’s pmv2_exit_alert cursor, which is impossible to guarantee: the chain-path alert and the cron’s EXITED row carry different detected_ts, which defeats the timestamp cursor → it would double-alert. Logging the row IS the way to ride the existing cursor safely — it routes both the operator alert and the auto-sell through the single, already-correct dedup path.
Idempotence + cold-start
detect_exitedis intrinsically idempotent
detect_exited(positions.rs:183) compares the live snapshot against priorpmv2_positionsrows: a position already gone from the live snapshot is never re-exited, no matter how many SELL events fire. Cold-start (no priorpmv2_positionssnapshot for the wallet) produces NO exit (tested) — there is no “prior” to diff against, so nothing is falsely reported as exited. (Same “a timeout/empty is not an exit” discipline as the watcher’ssucceeded-set scoping in first-mover-overlap-watcher.)
Known / accepted limits
Intra-gap round-trips get no fast-path exit (spec, NOT a code comment)
If a cohort wallet enters AND exits within a single cron gap, the position is never snapshotted, so the fast path has no “prior” row to diff and produces no fast exit — this matches today’s cron coverage (the cron wouldn’t catch it either). The fast path only accelerates exits for already-snapshotted positions. (Per the repo’s no-comments rule, this limit lives here in the spec, not in the source.)
Gate
bash scripts/gate.sh → GATE_EXIT=0.
- Binary crate 508 tests (507 pass, 1 ignored)
- lib crate 99 pass
clippy -D warningsclean,fmtclean- 6 new unit tests:
eligible_sell×2,ExitDebouncecoalesce / per-wallet / window-boundary ×3, cold-startdetect_exitedno-exit ×1
Relationship to other notes
This complements the autotrade engine spec (pmv2-autotrade-engine-design) and the project_autotrade-engine-spec auto-memory note — it supersedes neither. It is the SELL/fast-exit counterpart to the BUY-side consensus work in tracked-alerts-consumer (same consumer file, same on-chain detection substrate). It does not change the autotrade sell mechanism (mark_selling CAS, autotrade_exits cursor) — it only changes when the EXITED row that triggers that mechanism appears.
Related
- cohort-signals-service-split-and-exit-signals — supersedes the
on_cohort_sellbehavior here: after the producer/executor split, the SELL path also publishesaction:Selltocohort.signals+ alerts (the executor is now out-of-process) - tracked-alerts-consumer — same
alerts_consumer.rs; holds the BUY consensus filter and explicitly reservedTrackedSignalfields for this Phase-4 SELL consumer - pmv2-exit-alerts — owns the
pmv2_exit_alertcursor (operator ”🔴 TRADE EXITED” alert) that this path rides - autotrade-repo-exit-helpers — owns the
autotrade_exitscursor +autotrade_exit_events/autotrade_sellable_for_originconsumed byrun_exits - autotrade-selling-state-foundations — owns the
mark_sellingopen→selling CAS, the load-bearing serializer for all sell triggers - first-mover-overlap-watcher — owns
run_pmv2_positions/detect_exited/delete_positions(the cron behavior this path mirrors) and the migration that dropped thepmv2_position_eventsUNIQUE constraint - pmv2-autotrade-engine-design — the live-execution design this complements
- proxied-client connect_timeout rule — why the injected
DataApiClientmust carryconnect_timeout(it does, viacfg.build_data_client) - polymarket-fetch — project overview