Replaced the untyped Result<Option<i64>> return of Persistence::try_reserve with a ReserveOutcome enum so cap-breach and dedup are distinguishable variants rather than both collapsing to Ok(None).

Problem

Persistence::try_reserve previously returned Result<Option<i64>>:

  • Ok(Some(id)) — reservation succeeded
  • Ok(None) — either ON CONFLICT dedup OR any cap-breach (exposure / daily / max-open / per-event)

Both cap-breach and real dedup collapsed to the same IngestOutcome::Skipped("dedup") in ingest_entry. During Phase-2 staging this was unsafe: a double-trade caused by a bug would look identical to benign noise in metrics/logs.

Additionally, the pre-lock dry-run estimate path used a different vocabulary (“exposure cap”, “daily cap”, “max open”) versus the atomic reserve path, making cross-layer tracing unnecessarily hard.

Solution

1. ReserveOutcome enum (engine.rs)

Reserved(i64) | CapExposure | CapDaily | CapMaxOpen | CapEvent | Dedup

Introduced directly in engine.rs alongside the trait. Each variant is one and only one reason a reservation did not produce an id.

2. Trait method signature change

Persistence::try_reserve now returns Result<ReserveOutcome> instead of Result<Option<i64>>.

3. ingest_entry match-arms each variant

Each ReserveOutcome variant maps to a distinct skip reason string:

VariantIngestOutcome::Skipped reason
Reserved(id)proceeds normally
CapExposure"cap:exposure"
CapDaily"cap:daily"
CapMaxOpen"cap:max_open"
CapEvent"cap:event"
Dedup"dedup"

4. Pre-lock cap strings unified

The dry-run estimate path now uses the same cap:* namespace:

  • "exposure cap""cap:exposure"
  • "daily cap""cap:daily"
  • "max open""cap:max_open"

This makes log and metric queries uniform across both paths.

5. PgPersistence::try_reserve (persistence.rs)

The combined cap if was split into three separate rollback returns:

  1. exposure check → CapExposure
  2. daily spend check → CapDaily
  3. max-open check → CapMaxOpen

Per-event cap → CapEvent. The final INSERT … ON CONFLICT path uses fetch_optional mapped as:

.map_or(ReserveOutcome::Dedup, |row| ReserveOutcome::Reserved(row.id))

6. MockStore updated

  • Field type changed from Option<i64> to ReserveOutcome
  • New with_outcome(outcome, rows, spend) constructor for direct variant injection in tests
  • new(Option<i64>, ...) kept as a compatibility shim (uses map_or to convert)

Tests added / updated

  • dedup_returns_skipped — updated to use MockStore::with_outcome(Dedup, ...)
  • try_reserve_cap_breach_distinct_from_dedup — new; asserts CapEventIngestOutcome::Skipped("cap:event"), verifying the variants can no longer be confused
  • dispatched_skipped_preserves_reason in runner.rs — extended with a cap:event assertion alongside the existing dedup case

Files changed

  • crates/polymarket-fetch/src/pmv2/autotrade/engine.rs
  • crates/polymarket-fetch/src/pmv2/autotrade/persistence.rs
  • crates/polymarket-fetch/src/pmv2/autotrade/runner.rs

Gate

GATE_EXIT=0

For Agents

Branch: autotrade-phase2-firstmover (2026-06-19). The canonical cap-rejection vocabulary is now cap:exposure, cap:daily, cap:max_open, cap:event. A Skipped("dedup") now unambiguously means an ON CONFLICT hit, never a cap-breach. MockStore::with_outcome(ReserveOutcome, rows, spend) is the correct constructor for unit tests that need to inject a specific reservation result.

Compat shim

MockStore::new(Option<i64>, rows, spend) still works (NoneDedup, Some(id)Reserved(id)) for tests that predate this change, but new tests should use with_outcome for explicitness.