Phase 2 refactor that hoists the first-mover NATS publish logic out of positions.rs and into a shared firstmover.rs function, so that the cron and any future watcher both use a single canonical emit path — identical id generation, gating, and retry semantics by construction.

Branch: autotrade-phase2-firstmover (uncommitted). Gate: GATE_EXIT=0.

For Agents

Files changed: autotrade/nats.rs, autotrade/firstmover.rs, pmv2/positions.rs, autotrade/sources.rs. Sits on top of cohort-firstmover-nats-migration (which migrated the cohort entry to NATS and deleted runner::run_entry). That note’s PUBLISH_RETRIES=3, PUBLISH_RETRY_BACKOFF=200ms, and COHORT_SUBJECT constants were previously private literals inside positions.rs; they are now pub consts in nats.rs. The call site process_entered_entry in positions.rs is untouchedrun_autotrade_entry still has the same signature; only its body changed from inline publish logic to a single delegation call.

What changed

1. Publish consts hoisted to nats.rs

Three literals formerly defined inline at positions.rs:411-413 are now pub consts with a single canonical home:

PUBLISH_RETRIES: u32 = 3
PUBLISH_RETRY_BACKOFF: Duration = 200ms
COHORT_SUBJECT: &str = "cohort.signals"

Both positions.rs and the new firstmover.rs import them from nats. Any future producer automatically inherits the same retry policy and subject name.

2. PublishOutcome enum + build_and_publish_first_mover_entry in firstmover.rs

PublishOutcome is a typed return value replacing the former unit return:

PublishOutcome::Published      — signal was serialized and the JetStream PubAck was received
PublishOutcome::SkippedTier    — trader tier != Core (or tier field absent)
PublishOutcome::SkippedBreaker — breaker is tripped; no publish attempted
PublishOutcome::PublisherNone  — no NATS publisher configured (dormant lane)

build_and_publish_first_mover_entry(atc, trader, position, conviction, publisher, notify) is the shared async entry point. It:

  1. Short-circuits on PublisherNone when the optional publisher arg is None.
  2. Checks atc.breaker_tripped (returns SkippedBreaker).
  3. Checks trader.tier == Some(Core) (returns SkippedTier).
  4. Calls sources::enter_signal to build the TradeSignal.
  5. Serializes via nats::serialize_cohort and publishes via nats::publish_cohort with the bounded retry (PUBLISH_RETRIES / PUBLISH_RETRY_BACKOFF).
  6. On retry exhaustion: logs a WARN and fires the notify closure with a “first-mover entry LOST” alarm — preserving the operator-visibility behaviour from the post-cutover hardening (39c01dd in cohort-firstmover-nats-migration).

3. run_autotrade_entry in positions.rs — 1-call delegation

The former inline body (breaker check → tier check → serialize → publish loop → retry exhaustion warn) is replaced by:

firstmover::build_and_publish_first_mover_entry(atc, trader, position, conviction, publisher, notify).await

process_entered_entry (the only call site) is untouched.

4. Tests added to firstmover.rs (3 async tokio tests)

TestWhat it pins
producer_skips_non_corebuild_and_publish_first_mover_entry returns SkippedTier for Tier::Proven and Tier::Emerging traders
producer_skips_when_breaker_trippedreturns SkippedBreaker when atc.breaker_tripped=true, regardless of tier
producer_skips_when_publisher_nonereturns PublisherNone when publisher arg is None, without inspecting tier or breaker

5. Regression pin in sources.rs

cron_run_autotrade_entry_emits_unchanged_signal_id pins the exact signal_id string that run_autotrade_entry emits for a known fixture:

"first_mover_core|0xtrader|0xcond|1|enter|0xcond"

This guards against any future change to cohort_signal_id or enter_signal accidentally shifting the dedup key used by the DB ON CONFLICT(dedup_key) DO NOTHING backstop — which would cause two live orders for the same logical entry.

Behavioral equivalence note

The original cron combined breaker and tier into a single guard:

if atc.breaker_tripped || t.tier != Some(Core) { return; }

The new build_and_publish_first_mover_entry checks them sequentially (breaker first, then tier). These are semantically equivalent: || short-circuits and checks breaker first anyway. The only observable difference is the typed PublishOutcome return instead of unit. Retry semantics are faithfully replicated (3 retries, 200ms backoff, warn + notify on drop).

Dedup identity still load-bearing

The signal_id produced by sources::enter_signal (via cohort_signal_id) must remain byte-identical to the legacy orders::dedup_key(..,"enter"..) so that the ON CONFLICT(dedup_key) DB backstop fires correctly between any two producers that observe the same position. The regression pin in sources.rs (cron_run_autotrade_entry_emits_unchanged_signal_id) is the guard for this invariant. Do not change cohort_signal_id or enter_signal’s field ordering without updating the pin.

Why this matters for Phase 2

Phase 2 will add a watcher-triggered publish path: when the on-chain OrderFilled detector sees a cohort entry, the watcher should publish to cohort.signals immediately, cutting the ~30-min REST-poll lag. Both the cron and the watcher must produce identical signal_id values for the same logical position — otherwise the DB dedup doesn’t fire and a double live order is placed.

By having both paths call build_and_publish_first_mover_entry, id generation, gating, and retry are identical by construction rather than by convention.