Тема
Post-Mortem: RTDS silent failure (2026-05-06)
Severity: P1 — production trading bot operating blind on toxic-flow signal for ≥4 days. No financial loss observed (dry-run only), but the same code path will run live; in production trading this would have masked an entire risk-management subsystem.
Status: Symptom fixes deployed. Process gaps remain (see §6).
1. Summary
RTDS (the always-on Polymarket trade feed that drives ADR-004 toxic-flow detection) silently dropped 100 % of inbound events for an estimated 4+ days. The bot's WebSocket layer connected, subscribed, and watchdog-reconnected every 30 s — but every event payload was dropped at the parser as "stale" because Polymarket changed the wire timestamp from milliseconds to seconds and the parser still treated the value as ms (age_ms ≈ 1.78 × 10¹² ≈ 56 yr).
The dashboard showed RTDS · SUBSCRIBED · 143 in green throughout. The operator only noticed when investigating a separate symptom (zero fills in a backtest replay) discovered rtds_events: 0 in every capture session.
2. Timeline (UTC)
| Time | Event |
|---|---|
2026-05-02 (per memory project_v2_blockers_resolved) | rtds-probe smoke verified live: timestamp_format: Milliseconds. Last known good. |
| Between 2026-05-02 and 2026-05-06 | Polymarket flipped the RTDS event-payload timestamp from ms to seconds. No external announcement observed (operator's PoV). |
| 2026-05-06 (this session) | User flagged that 9 h of dry-run produced 0 fills. Replay investigation drove three sequential discoveries: capture cid resolution, sweep CLI gap, then RTDS itself. |
| 2026-05-06 09:00 | Smoke probe re-run, immediately surfaced timestamp_format: Seconds + trades_parsed: 0. Three independent fixes deployed. |
The drift window is at minimum 4 days. Given the bot was being exercised continuously over those days, the operator's first noticeable symptom was an indirect one (replay ground-truth). Without the replay investigation, the failure could have stayed hidden indefinitely.
3. Impact
Functional impact
- ADR-004 toxic-flow detection: blind. Every external trade arrived, was parsed as Stale, and dropped. Zero
RtdsEvent::Tradereached the scanner. Zero cancel decisions could have fired from this surface —RTDS_TOXIC_FLOW_LOG_ONLY=truemasked this further by ensuring no observable side-effect even when the path did fire. - ADR-009 capture/replay: every capture session for the past 4+ days has
rtds_events: 0. Re-running historical sweeps for harvester PnL ground-truth requires either re-capturing a session post-fix OR synthesising trades from book deltas (deferred — not part of this fix). - Production dashboard: green-status badges hid the failure from the operator (separate root cause; see §4.3).
Financial impact
- None observed. Bot was in dry-run on Hetzner. Live trading path would have inherited the same blindness; ADR-004's
RTDS_TOXIC_FLOW_ACTIVE_CANCEL_ENABLED=falsedefault kept us in log-only mode regardless, but that's accidental safety, not a designed-in defence.
Replay engine impact (separate)
- The capture cid-resolution gap (commit
3dbd25d) was discovered in the same session and is unrelated to RTDS but was masking the RTDS investigation: backtests showeddecisions_emitted: 0, the natural assumption was strategy-config too tight, and we burned several hours on knob sweeps before bottom-up debugging surfaced the cid empty string in capture frames. RTDS only became visible after that.
4. Root causes (technical)
Three independent, simultaneous failures. Each alone would have been a P2; the combination + the silent observability mask escalates to P1.
4.1 Parser timestamp magnitude (data-format drift)
crates/pm-bot/src/io/rtds/parse.rs::validate_and_convert
Polymarket switched RTDS payload timestamps from ms to seconds without notice. The parser computed age_ms = now_ms - raw.timestamp assuming both in ms. With raw.timestamp in seconds, the subtraction yields ~1.78 × 10¹² ms ≈ 56 yr — every event tripped the max_event_age_secs stale guard.
The parser test t_parse_timestamp_ms (line 563) explicitly warned about this exact failure mode in its docstring:
"Q3 specifically: a wire timestamp like
1745251234567must be accepted as milliseconds. If a future refactor changed the unit to seconds, this same value would be ~55k years in the future and the stale branch would silently pass — so we assert the parsed timestamp equals the wire integer directly."
The author anticipated the magnitude trap, but the test only validated the endpoint controlled by us (parser keeps treating value as ms). It did not assert behaviour under the endpoint controlled by Polymarket (server starts sending seconds). The Q3 invariant lived as a manual checklist item (production-readiness-checklist.md), not an automated gate.
4.2 Watchdog inbound-only proof-of-life
crates/pm-bot/src/io/rtds/client.rs::process_messages
The activity_deadline only reset on inbound traffic. Polymarket's RTDS server does not echo our text-level "ping" heartbeats on quiet markets. Subscribing to 12-15 low-volume weather binaries means 30-second windows with zero inbound traffic are normal. The watchdog kicked, the bot reconnected, the cycle repeated 558+ times since the last bot restart.
This compounded with 4.1 because the operator-visible warning ("no inbound traffic for 30s") looked like a network problem, not a parser problem. Investigation effort gravitated toward network/auth/subscribe-format theories instead of looking at what the inbound traffic — when it occasionally did arrive — actually contained.
4.3 Dashboard rtdsStatus() false-green
frontend/pm-dashboard-web/src/views/MainView.vue::rtdsStatus
The badge resolved connection_state === 3 ⇒ severity: 'success', ignoring whether any data actually flowed. With watchdog cycling every 30 s, state briefly hit 3, settled, and the dashboard displayed green for the entire 30-second window before the next reconnect. Pattern: a status derived from a transient flag, not a freshness signal.
The metric counters needed to detect the failure (rtds_events_received_*, rtds_reconnect_total, rtds_events_dropped_stale) were already on the wire — added in commit f6c1f50 (2026-05-03) — but no consumer aggregated them into a truthful health signal.
5. Why our process didn't catch this
This is the part that matters. A code fix doesn't prevent the next data-format drift; the process gaps below do.
5.1 No automated invariant gate
Production-readiness checklist item:
- [ ] `SmokeReport` shows: `timestamp_format: Milliseconds` (Q3 invariant)
This item exists, but as a manual checkbox intended for the ADR-004 cutover (one-time event). There is no:
- CI cron job that runs
--smoke-rtds <busy_slug>against the live Polymarket endpoint and fails ontimestamp_format != MillisecondsORschema_parity != trueORtrades_parsed == 0. - Production health pipeline that alerts on
rtds_events_dropped_stale > 0over any rolling window. - Pre-deploy gate that runs the smoke harness as part of
docker compose up.
The smoke harness itself is correct and sufficient — verified by the fact that it took 12 seconds to identify all three root causes once finally run. Nobody had run it for 4 days.
5.2 No alert rule on RTDS health metrics
docs/operations/alerts-2026-05-03.md lists 4 stock alert rules:
emergency_stopdrawdown_thresholdposition_closedposts_err_spike
None covers RTDS health. The metric counters (rtds_events_dropped_*, rtds_reconnect_total, rtds_events_received_*) ship in every MetricsTick since 2026-05-03 but no AlertRule consumes them. The infrastructure for alerting is in place; the configuration is incomplete.
5.3 Test coverage shape
The parser test suite (11 tests) is specification-driven, not fuzz-driven. Every test feeds a known-good or known-bad fixture and asserts the parse result. None of them adversarially probe "what if the magnitude flips" or "what if a field type changes" — classes of bug that arise from external API drift.
The test author of t_parse_timestamp_ms recognised the failure mode (per the docstring) but expressed the safeguard as a unit-test fixture rather than:
- A property test (
proptest/quickcheck) over magnitude classes. - A live smoke gate enforced in CI.
5.4 Memory feedback_silent_feature_broken was already saved
The pattern was already documented in this session's memory:
"Каждое заявленное feature должно иметь тест, наблюдающий end-to-end эффект; 'compile + no panic' недостаточно. RTDS никогда не работал в проде, тесты молчали."
The principle (every ADR feature needs a liveness test) was extracted during this incident, but it indicts every other ADR feature too — WebSocket subscribers (Market/User), webhook server, periodic schedulers, env-flag gates, recovery hooks. None of those were audited yet for the same gap.
5.5 Dashboard signal authoring
Documented separately as the truthful-indicator policy in the VPS prod runbook. The pattern:
- ❌
latest_tick.counter > 0— green forever after first event. - ✅
sumDeltas('counter')overSPARKLINE_POINTS— last-minute rate.
The rtdsStatus() function used the wrong pattern. There was no code review checklist or lint that catches this — it requires either a linting rule (custom AST grep), a status-rendering convention documented in CLAUDE.md, OR a test that asserts dashboard severity under known-bad metric tape.
6. Corrective actions
6.1 Already done in this session
| Action | Commit |
|---|---|
| Parser auto-detects timestamp magnitude | 33c49cc |
| Watchdog resets on outbound ping success | 783aa05 |
Dashboard rtdsStatus() returns danger on storm + stuck | 15aa097 |
Top-level rtdsHealthAlert Message banner | 15aa097 |
| Capture cid resolution from asset_id | 3dbd25d |
Sweep CLI accepts --captures-root + 3 enum knobs | 3dbd25d |
| Runbook postmortem section | 783aa05 |
Frontend truthful-indicator audit (12 badges checked, 3 fixed: RTDS подписки, Strategy admitted, Capture · queue depth) — pure resolvers extracted to useStatusBadges.ts, pinned by indicator-truthfulness.test.ts (26 tests), AST-grep convention rule .ast-grep/truthful-indicator.yml blocks future latest_tick.<counter> > 0 regressions. Full table: INDICATORS_AUDIT.md. | (uncommitted) |
6.2 Required before next live trading session (P0)
- [ ] Add stock
AlertRule::rtds_healthmatching onMetricsTick: firesCriticalwhenrtds_events_dropped_stale + rtds_events_dropped_parse > 0ORrtds_reconnect_totaldelta over 60 s > 1 ORrtds_events_received_trade + ..._orders_matched == 0ANDrtds_subscription_active_filters > 0for ≥ 5 minutes. Plumbed into the same notification sinks asposts_err_spike. Update alerts-2026-05-03.md. - [ ] CI cron — daily 06:00 UTC GitHub Actions job that runs
cargo run -p pm-bot --release -- --smoke-rtds <busy_slug>and fails ontimestamp_format != Milliseconds,schema_parity != true, ORtrades_parsed < 1. Slug supplied from a small whitelist of evergreen high-volume markets (world-cup-winner,presidential-nominee-*). The same job runs--smoke-rtdsevery push to arelease/*branch.
6.3 Should-do within this week (P1)
- [ ] Liveness audit of every ADR-001..017 feature per memory
feedback_silent_feature_broken. Tabulate:(feature, observable boundary, has_liveness_test, gap). Promote each gap to a tracked TD-XX ticket. - [ ] Property tests for parser:
propteststrategy over timestamps in ms / seconds / future-skew / negative magnitudes, and over field shapes (missing, empty-string, wrong-type). Property:parser is total — every well-formed JSON yields Either<Event, ParseError>with no panic and no infinite loop. - [ ] Truthful-indicator audit of every dashboard status field. Replace any badge derived from
latest_tick.counter > 0patterns with freshness-window derivatives. Add a frontend lint or convention test infrontend/pm-dashboard-web/__tests__/that fails when a status function readslatest_tick.Xwithout referencingsumDeltas/valuesForfrom the same scope.
6.4 Should-do within this month (P2)
- [ ] Polymarket API contract probe — separate from
--smoke-rtds, a probe that hits every documented Polymarket REST/WS surface we depend on, captures one envelope per surface, diffs against a checked-in golden, and surfaces format drifts before they reach production. Run in CI on a daily schedule. Capture timestamp magnitude, field presence, type stability. - [ ] Runbook automation — convert manual checklist items in
production-readiness-checklist.mdto scripts underscripts/preflight/so an operator runs one command rather than walking 30 checkboxes. - [ ] Outstanding RTDS sub-issues from the verification matrix:
rtds_connection_statestuck at2post-fix (not 3). Investigate whether Polymarket dropped the SUBSCRIBE ack frame entirely or changed its shape. If the ack is gone, simplify the state machine to merge 2 + 3 into a singleSubscribed— drop the dead transition rather than wait for a frame that never comes.rtds_events_dropped_parse=4accumulated since restart. Adddebug!()of the dropped raw payload (truncated, sensitive fields redacted) when the parse error occurs. Surface a counter perParseErrordiscriminant so operators distinguish missing field vs malformed JSON vs wrong type.
7. Lessons (institutional)
Manual checklist items rot. Production-readiness Q3 invariant was correctly identified, correctly written, correctly placed in the checklist — and not automated. Four days later the manual step's existence had no protective effect. Rule: any invariant that says "verify after deploy" must also be enforced by an automated gate within 7 days of the manual one being written.
Authors anticipate failure modes; tests don't validate them. The
t_parse_timestamp_msdocstring is a perfect example: the author wrote three sentences explaining exactly the bug that eventually broke us, then wrote a unit test that did not test the failure direction. Rule: every "if this changes, silently breaks" docstring becomes a property test or a live-smoke assertion within the same PR. Reading "if X then silent break" without writing the test is a self-described code smell.External-contract drift requires live monitoring, not unit tests. Polymarket can change anything any day. Static fixtures in our test suite have zero coverage of that. Rule: every external dependency needs at least one daily live probe that captures a fresh envelope and compares to a golden, at field-type granularity.
Observability infrastructure ≠ observability coverage. We had the metric counters, the dashboard widgets, the alerts engine — all the plumbing was in place. We just hadn't filled in the alerts. Rule: when adding a new metric, the same PR adds the stock alert rule. When adding a new widget, the same PR adds the freshness-derived severity computation.
Symptom investigation cascaded into noise (5 hours on this incident). The operator's first symptom (0 fills) led to: knob sweep on
MAX_ENTRY_PRICE, capture cid resolution dive, parameter tuning iterations, sweep CLI fixes — all real but all downstream of the actual root cause. Rule: when a symptom is "no observable effect from a side-effect-producing path", the very first diagnostic to run is the input-source liveness check, before any business-logic exploration. For RTDS that's--smoke-rtds; for WS Market that's similar; for capture-replay it's checkingreplay_errorsandframes_processed > 0.
8. References
- Runbook with hotfix procedure
- ADR-004 RTDS integration
- ADR-009 Capture & Replay
- Smoke test runbook
- Alerts setup
- Production readiness checklist
- Memory entry
feedback_silent_feature_broken.md