Second independent validation pass over the completed pmv2 auto-trade 3c-2 (live submit) build, run through bug / DRY / redundancy / gap lenses — deliberately NOT the trade-safety lens that round-1 used. It found real issues the safety reviews missed, the worst of them capital-trapping. Final re-review of round-2 → CONFIRMED SAFE FOR STAGED ARM, all round-1 invariants intact. This note records what round-2 found; the consolidated arm runbook lives in autotrade-3c2-complete-handoff.
The lesson (most important takeaway)
A flow can be structurally trade-safe yet BROKEN at a vendored value-validation boundary the safety lens never checks. The round-1 review reasoned about lock order, reservation races, exposure caps, exit authority, reconcile truth — and signed off “SAFE FOR STAGED ARM.” But that verdict was premised on the SELL path actually working, and it did not: every real fractional-share exit failed at
rs-clob-client-v2’s lot-size guard insideAmount::shares, far below the safety machinery. A different lens (does each value survive the vendored constructor?) found in minutes what the safety lens structurally could not see. When you vendor a value-validating SDK, audit every value you hand across that boundary against the SDK’s own accept/reject rules — separately from auditing the flow.
B1 — BLOCKER (capital-trapping): the SELL path was non-functional
B1 — every real fractional-share position was unsellable
Symptom:
auto_sellreverted the order row back toopenon abuild_ordererror; the position never sold — for both auto-exit and the manual/autotrade sell. Capital trapped until on-chain resolution. Root cause: the vendoredrs-clob-client-v2Amount::shares(value)rejects any share amount whosevalue.normalize().scale() > LOT_SIZE_SCALE (=2)— Polymarket shares trade in 0.01 lots (types/mod.rs:193-200, enforced again inorder_builder.rs:25).auto_sellpassed the RAW on-chain fill straight intobuild_order → Amount::shares. The project’s locked test vector is 7.6219 shares (scale 4) → rejected →build_ordererrors →auto_sellreverts the row toopen. So every fractional-share position with scale > 2 was un-exitable. Why round-1 missed it: the prior re-review checkedassert_sell_amounts(the project’s own pure pre-POST assertion) but not the vendored lot-size guard one layer deeper inAmount::shares. The assertion passed; the constructor it fed still rejected.
Fix (commit e93dde1): quantize the sell size to the lot scale once and use that single value everywhere:
sell_shares = filled_size.trunc_with_scale(2)
sell_shares is then used consistently for build_order + quantize_sell(expected_shares) + assert_sell_amounts — so the value handed to Amount::shares always has scale ≤ 2 and the pure assertion agrees with it. Sub-0.01-share dust is held to resolution (the function returns before mark_selling, so a dust position never enters selling and never tries to POST an un-representable order).
For Agents — BUY is unaffected
The BUY path does not hit this guard.
Amount::usdcallows scale ≤ 6, and the market-order builder has no lot gate. So the lot-size landmine is SELL-only. The fix is therefore scoped to the sell quantization; no BUY change.
Quick reference — Polymarket lot/precision rules at the vendored boundary
- Shares (
Amount::shares): scale must be ≤ 2 (0.01 lot). Truncate withtrunc_with_scale(2)before building a SELL.- USDC (
Amount::usdc): scale ≤ 6.- Constants live in
rs-clob-client-v2types/mod.rs:193-200; the share guard is re-applied inorder_builder.rs:25.- The on-chain CTF fill (
balanceOf) is full-precision — do not feed it raw to any share-amount constructor.
2E — Important (false risk-limit): max_copies_per_event never enforced
max_copies_per_event was loaded from the DB, validated, and displayed in /autotrade status — but never enforced. Different cohort members entering the same event were each copied up to the GLOBAL max_open cap; there was no per-event ceiling. The operator saw a configured per-event limit that did nothing.
Fix (commit 84e3e34): a per-event_slug COUNT of live non-terminal rows inside the reserve config-lock transaction, rejecting once the count reaches the cap. 0 = unlimited (intentionally divergent from max_open where 0 blocks all entries). Full mechanics in autotrade-max-copies-per-event.
2C / I3 / 1D — Important gaps (commit d4a2a85)
Three settle / exit-cursor / reconcile gaps, all fixed in one commit:
- 2C — settle silently held a position that should have exited.
settle_open_positionsnow alarms when it closes anopenrow that still carriedexit_pending— i.e. a cohort exit signal was queued but the exit never completed, so the position rode all the way to resolution. Previously this was a silent ride-to-resolution; now the operator is told. - I3 — the exit cursor permanently DROPPED an event. The exits cursor advanced to the last-success
detected_ts, so any other event sharing that exact timestamp was skipped forever (a>cursor on a non-unique key loses ties). Fixed with a forward-onlyresume_cursor: on halt, resume atfirst_failed − 1µs, so no same-timestamp sibling is lost. - 1D — reconcile’s unbounded CTF sweep ran ahead of entries. Reconcile did a per-row CTF
balanceOfsweep with no limit on every tick, before entries — a growing position table meant an ever-longer on-chain sweep delaying (and competing with) entry processing each tick. Now capped atLIMIT 25oldest rows per tick, with a visibility notify so the operator can see the sweep is bounded/working.
DRY extractions (commit efc69bc) + a self-inflicted regression (0f06ec5)
The DRY pass extracted collecting_notify / AutotradeDeps::market() / record_fill_then_open(FillOutcome) and removed dead mark_dry_run / load_autotrade_order (see autotrade-dry-refactors-2026-06-16 for the full inventory).
The DRY extraction introduced a visibility regression — caught by the FINAL re-review
collecting_notify(commitefc69bc) broke/autotrade sell’s reply.run_selldrained its collected messages viaArc::try_unwrap, but the extracted notify closure still held a secondArcclone →try_unwrapalways failed → the drained vec was always empty → the manual/autotrade sellTelegram reply swallowed the SOLD / REJECTED / TIMEOUT result. Scope: visibility only — the sell itself executed correctly; only the operator’s reply was blank. CI stayed green because no test asserted the reply content. Fix (commit0f06ec5): drain vialock()instead ofArc::try_unwrap, plus a regression test that asserts the reply is non-empty. Meta-lesson: a behavior-preserving refactor that touches anArc/ownership-drain pattern can silently change runtime behavior the type-checker and a green CI both miss. The fix that swallowed output is exactly the kind of thing only a from-scratch re-read (not a diff review) catches.
Outcome
A final independent re-review of round-2 → CONFIRMED SAFE FOR STAGED ARM. All round-1 invariants intact. Gate green: 334 polymarket-fetch + 87 polymarket-data tests.
For Agents — round-2 commit ledger
Issue Severity Commit One-liner B1 BLOCKER (capital-trapping) e93dde1sell_shares = filled_size.trunc_with_scale(2); dust held to resolution; BUY unaffected2E Important (false risk-limit) 84e3e34per- event_slugCOUNT in the reserve config-lock tx; 0 = unlimited2C / I3 / 1D Important (gaps) d4a2a85settle alarms held exit_pending; forward-onlyresume_cursor(first_failed−1µs); reconcileLIMIT 25+ notifyDRY refactor efc69bccollecting_notify/market()/record_fill_then_open; dead-code removalregression self-inflicted, visibility 0f06ec5DRY collecting_notifyblanked the/autotrade sellreply (Arc::try_unwrapon a still-cloned Arc) →lock()-drain + regression test
Known deferrals (unchanged from round-1)
These were carried by round-1 and remain accepted after round-2:
fee_usd = 0—PostOrderResponseexposes no fee; cost understates / realized PnL overstates (fail-safe bias). Calibrate or wireorder_tradesfees.- Cap-breach reads as a benign “dedup” Skip — exposure-saturation is indistinguishable from a genuine dedup at the operator surface (visibility gap, not a correctness bug).
is_resolution_onlyunwired — still carries#[allow(dead_code)].- A timed-out-but-landed SELL books
pnl = 0(loud “verify manually” alarm) — manual-bookkeeping action item. - ~30-min REST trigger latency — the first-mover trigger is REST-polled (mean ~15 min detection lag), likely eroding the first-mover edge; a real-time on-chain
OrderFilledWSS trigger is recommended before scaling past tiny caps.
Related
- autotrade-3c2-complete-handoff — the 3c-2 consolidation + the staged-arm runbook this validation gates
- autotrade-max-copies-per-event — 2E per-event cap fix (full mechanics)
- autotrade-dry-refactors-2026-06-16 — the DRY extractions (
efc69bc) whosecollecting_notifylater regressed - autotrade-selling-state-foundations — the
auto_sellCAS +assert_sell_amountsfoundation that B1 sits on top of - autotrade-breaker-module — the §3.4 config-before-order lock order the round-2 settle/cursor fixes preserve
- autotrade-3c2-validation-carryforwards — round-1’s Task-9 carry-forwards (alarms + cursor seed)
- pmv2-autotrade-engine-design — the locked design (wallet, V1/V2 landmine, safety machinery)