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 succeededOk(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:
| Variant | IngestOutcome::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:
- exposure check →
CapExposure - daily spend check →
CapDaily - 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>toReserveOutcome - New
with_outcome(outcome, rows, spend)constructor for direct variant injection in tests new(Option<i64>, ...)kept as a compatibility shim (usesmap_orto convert)
Tests added / updated
dedup_returns_skipped— updated to useMockStore::with_outcome(Dedup, ...)try_reserve_cap_breach_distinct_from_dedup— new; assertsCapEvent→IngestOutcome::Skipped("cap:event"), verifying the variants can no longer be confuseddispatched_skipped_preserves_reasoninrunner.rs— extended with acap:eventassertion alongside the existingdedupcase
Files changed
crates/polymarket-fetch/src/pmv2/autotrade/engine.rscrates/polymarket-fetch/src/pmv2/autotrade/persistence.rscrates/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 nowcap:exposure,cap:daily,cap:max_open,cap:event. ASkipped("dedup")now unambiguously means anON CONFLICThit, 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 (None→Dedup,Some(id)→Reserved(id)) for tests that predate this change, but new tests should usewith_outcomefor explicitness.