Skip to content

WS Strict Fail-Fast + REST Resync — Design (V1 of WS self-healing) ​

Реализовано в pm-engine (cutover 2026-07-22). Дизайн (3-классовая strict-parser policy, ReconnectReason, REST-resync-after-reconnect, 6-метричная таксономия, §5 coverage rule-IDs) — ЖИВОЙ и действует в pm-engine. WS-слой теперь живёт под crates/pm-engine/src/adapters/orderbook/ (market-канал) и crates/pm-engine/src/adapters/rtds/; отдельных файлов io/ws/book.rs / io/ws/user.rs / application/scanner.rs, как в удалённом pm-bot, больше нет. Все ссылки вида crates/pm-bot/src/io/ws/…:NNN, LOC-дельты, cargo … -p pm-bot и §2/§4/§6/§7-план ниже — историко-архивный gap-analysis pm-bot-эры, который МОТИВИРОВАЛ этот дизайн (стартовая точка), а НЕ актуальная карта кода. Не воспроизводимы на текущем дереве и не переиндексируются в pm-engine построчно (иначе — фабрикация якорей).

Status: ACCEPTED — 2026-05-13 (user-ratified V1+V3 architecture; see memory project_ws_self_healing_decision); IMPLEMENTED в pm-engine 2026-07-22 Implements: backlog #143Complements: BUG-11 fix (V3 — periodic reconciliation pool, merge commit 45295cf, closed in 96b0ce9) Related ADR: ADR-022 (OrderbookStore::update_from_rest_snapshot reuse for resync) Strategy.md §9 coverage: 13 rule-IDs newly closed; 3 explicitly out of scope.


1. Цель ​

Закрыть два класса silent failures в WS market channel:

  1. Parser swallow — unknown event types, malformed payloads, missing fields, и parse-error деградации в book.rs сейчас тихо превращаются в Vec::new() или continue. Любая аномалия должна быть observable (counter + structured log) либо fatal (debug build), но никогда не «исчезать».
  2. Reconnect resync gap — после reconnect WS подписки переотправляются, invalidate_orderbook_cache помечает кеш stale (scanner.rs:510-517), но активный REST resnapshot не запрашивается. Текущий поведение полагается на (a) следующий WS Book frame от Polymarket после resubscribe + initial_dump, (b) фоновый reconciliation pool (BUG-11). Это не гарантирует быстрый close gap'а для всех подписок — initial_dump может занять секунды, reconciliation pool работает в rate-budget. V1 добавляет явный REST burst для всех subscribed token_ids сразу после reconnect.

Принцип (memory feedback_ws_must_be_self_healing): WS parser bugs и missed events — это bugs нашего кода, которые надо чинить fail-fast (panic в dev, structured error + counter в prod). Это не оправдание для downstream защит.


2. Текущее состояние (gap analysis) ​

2.1 Silent skip locations в crates/pm-bot/src/io/ws/book.rs ​

Исходный файл: 1868 LOC. После аудита найдено 15 silent-skip веток, требующих перевода в observable failure mode.

#LinePatternCurrent behaviorRisk
1book.rs:810Err(_) => return Vec::new() в parse_book_event (RawEvent envelope)Malformed JSON envelope dropped silentlyHigh — корневой parse, скрывает все downstream проблемы
2book.rs:826other => { debug!(...); Vec::new() } в dispatcher по event_typeUnknown event_type → debug + dropCritical — Polymarket добавил tick_size_change в 2026, мы не заметили бы новый тип до отдельного капчура
3book.rs:833sonic_rs::from_str(text).ok()? в parse_book_eventBookEvent shape mismatch → NoneHigh — рассинхрон wire schema
4book.rs:839-840, 849-850l.price.parse::<Decimal>().ok()? + .size.parse::<Decimal>().ok()? per levelMalformed level → весь BookEvent в NoneHigh — один корявый уровень убивает snapshot
5book.rs:875Err(_) => return Vec::new() в parse_price_change_eventPriceChangeEvent parse-fail droppedHigh
6book.rs:881.timestamp.as_deref().and_then(|s| s.parse::<i64>().ok())Bad timestamp → None, теряем source tsMedium — REST guard в ADR-022 degraded
7book.rs:898, 902.best_bid / .best_ask parse → NoneBad NBBO field → dropMedium
8book.rs:908-916let Some(...) else { continue } для price/size/side в deltaSkip per-delta silentlyHigh — один кривой delta пропадает
9book.rs:920_ => continue для unknown side в match (BUY/SELL)Unknown side → drop entryMedium
10book.rs:956sonic_rs::from_str(text).ok()? в parse_best_bid_ask_eventBBO parse-fail → NoneMedium
11book.rs:961, 965.best_bid/.best_ask.parse::<Decimal>().ok()Bad decimal → NoneMedium
12book.rs:975-980let Ok(event) = ... else { return Vec::new() } + let Ok(price) = ... в parse_last_trade_price_eventLastTrade parse-fail → dropMedium
13book.rs:1009sonic_rs::from_str(text).ok()? в parse_new_market_eventNewMarket droppedCritical — теряем admission event'ы → market не попадает в universe
14book.rs:1082sonic_rs::from_str(text).ok()? в parse_market_resolved_eventMarketResolved droppedCritical — terminal event missed → орудия на resolved book
15book.rs:1110sonic_rs::from_str(text).ok()? в parse_tick_size_change_eventTickSizeChange droppedHigh — Codex INV-3 нарушится

Дополнительно в crates/pm-bot/src/io/ws/user.rs (649 LOC, user channel — out-of-band fills/cancels):

#LinePatternRisk
16user.rs:361sonic_rs::from_str(text).ok()? в parse_user_eventTrade/order event swallowed
17user.rs:434, 440_ => {} в match по msg_type (внутри order branch и outer event_type)Unknown order/event subtype drop

2.2 Reconnect flow — отсутствие явного REST resync ​

Текущее поведение (scanner.rs:504-572 + book.rs:303-381):

  1. connection_loop (book.rs:316-381) — supervisor: при ошибке connect_and_run инкрементит reconnect_counter, sleep backoff, повторно вызывает connect_and_run.
  2. connect_and_run (book.rs:393) при успехе сбрасывает disconnected_at = None, помечает connected = true, шлёт build_subscribe_messages(tokens, Subscribe) с initial_dump: true.
  3. В scanner.rs:510-517 на edge prev_ws_connected=false → ws_live=true вызывается invalidate_orderbook_cache(&tokens) — entries в OrderbookStore помечаются stale (timestamp → 1 ч назад) или удаляются.
  4. После этого decision-paths видят Wait { Stale } и FSM вызывает OrderManager::refresh_orderbook_rest lazily — по одной таре на FSM hop. Нет batched REST burst.
  5. Reconnect reason не классифицирован: текущий код различает только три clean-close строки (shutdown/receiver_dropped/server_close/stream_ended) vs Err. PING timeout, half-open, malformed message — все попадают в Err-bucket без типизации.
  6. WsReconnected event наверх не эмитится — scanner лишь наблюдает edge connected boolean.

После BUG-11 merge: ReconciliationPool на скорости rate-budget (1200 max / 100 reserve per 10s) поллит REST для всех subscribed → recovery происходит, но за окно до full sweep'а strategies могут принимать решения по stale levels.

V1 закрывает gap синхронно в момент reconnect detection: scanner получает WsReconnected event с list of subscribed assets, запускает burst REST fetch (sequential, sub-second wall-clock на ~50-200 ассетов при rate-limit 1500 GET /book per 10s), применяет через OrderbookStore::update_from_rest_snapshot — ADR-022 REST guard сам разрулит race с свежими WS deltas.

2.3 ReconnectReason — текущие call sites ​

В connect_and_run reconnect триггерится из (внутри process_messages):

Sourcebook.rs lineTypePredicted ReconnectReason
activity_deadline elapsed496-498ErrHalfOpen (90s ACTIVITY_TIMEOUT — нет inbound traffic)
read error601-602ErrReadError(String)
stream None604-605Ok("stream_ended")Disconnect
server Close frame590-592Ok("server_close")Disconnect
receiver dropped585-586Ok("receiver_dropped")ShutdownLike (вышестоящий dropped Rx — не reconnect, exit path)
connect_async failure (book.rs:393)393? propagatesConnectFailure(String)
unsubscribe/subscribe send fail524-525, 536-538ErrResubscribeFailure(String)
Ping send fail (ping_loop)467-470terminates ping, не reconnect напрямую(косвенно вызовет HalfOpen через activity timeout) — PingTimeout mapping через сигнал из ping_loop в supervisor

Текущий ping_loop не сигналит supervisor: он только пишет debug. Активный сигнал «pong missed» требует доп wiring если хотим явный PingTimeout reason. Решение: V1 классифицирует через ACTIVITY_TIMEOUT branch (90s), маркируя как HalfOpen (это и есть half-open detection в нашей модели — PING'и пишутся, activity timer не сбрасывается ⇒ TCP open но трафика нет). Отдельный PingTimeout (быстрее 10s) — defer на V1.1.


3. Архитектурное решение ​

3.1 Strict fail-fast parser policy ​

Политика: никакого silent drop. Каждая ветка делится на три класса:

  • Class A — unknown event_type: невидимая новая шина. В prod → tracing::error! + ws_unknown_event_total{event_type} counter + продолжить. В debug → panic!. Это обеспечивает immediate observability в проде и fail-fast в test/CI.
  • Class B — parse error (malformed JSON, missing required field, decimal parse fail): prod → tracing::error! + ws_parse_errors_total{kind, event_type} counter + skip frame; debug → panic!. Skip frame (не панику) в prod выбран потому что Polymarket периодически шлёт legacy/edge payloads; ронять процесс на каждую кривость = self-DoS.
  • Class C — bounded enum mismatch (unknown side, unknown status): prod → tracing::warn! + ws_unknown_enum_total{enum, value} counter + skip конкретный entry; debug → panic!.
  • order.type (user-канал) — переклассифицирован в Class-C-подобный (user-ws-parser-conformance, 2026-09-02): незнакомое значение теперь даёт OrderMsgType::Unknown(raw) вместо обрыва кадра (crates/pm-sdk/src/ws/user/parse.rs), тем же приёмом, что три соседних перечисления этого канала. Именованное расхождение, НЕ созданное этой правкой: фактический dispatch_frame (crates/pm-sdk/src/ws/user/channel.rs) на пути Ok(event) вообще не читает strict_mode ни для одного из трёх уже существующих Unknown(...)-перечислений канала (TradeStatus, Outcome, TraderSide) — ни debug, ни release на них не паникуют, вопреки тому, что этот раздел утверждает про Class C выше. order.type теперь разделяет то же фактическое поведение, что и три его соседа; сам документ пока не приведён в соответствие с кодом — полная сверка вне объёма этой карточки.

Решение по policy mechanism: cfg(debug_assertions) — compile-time gate, не runtime config knob.

Обоснование:

  • runtime flag создаёт риск misconfiguration в production (включишь panic-mode по неосторожности на live trading → процесс падает на первом кривом payload).
  • cfg(debug_assertions) автоматически включён в cargo test, cargo run без --release, cargo check — все dev workflows. Автоматически выключен в cargo build --release (production VPS builds, memory feedback_release_build_for_vps).
  • нулевой runtime overhead в release.
  • совпадает с existing convention в репо: parser_panics_on_unknown_event_in_debug — естественное название теста, читаемое.

Альтернатива (env var WS_PARSER_STRICT=1) рассмотрена и отвергнута: добавляет runtime config surface без выгоды (никто не запускает release с panic-mode).

Шаблон для каждой ветки:

rust
// Class A — unknown event_type
other => {
    let preview: String = text.chars().take(200).collect();
    tracing::error!(event_type = other, preview = %preview, "ws_client: unknown event_type");
    metrics::counter!("ws_unknown_event_total", "event_type" => other.to_string()).increment(1);
    #[cfg(debug_assertions)]
    panic!("unknown WS event_type in debug build: {} preview={}", other, preview);
    #[cfg(not(debug_assertions))]
    Vec::new() // observable already — continue
}

// Class B — parse error
Err(e) => {
    tracing::error!(error = %e, len = text.len(), "ws_client: BookEvent JSON parse failed");
    metrics::counter!("ws_parse_errors_total", "kind" => "json", "event_type" => "book").increment(1);
    #[cfg(debug_assertions)]
    panic!("BookEvent parse error in debug build: {} text={}", e, text);
    #[cfg(not(debug_assertions))]
    return Vec::new();
}

3.2 Reconnect → REST resync flow ​

3.2.1 ReconnectReason enum (в book.rs) ​

rust
#[derive(Debug, Clone)]
pub enum ReconnectReason {
    /// Server sent close frame or stream ended cleanly.
    Disconnect,
    /// Activity timeout (90s) — TCP open but no inbound traffic.
    HalfOpen { silent_secs: u64 },
    /// `connect_async` failed during connection establishment.
    ConnectFailure(String),
    /// Underlying WS `read` errored (transport / TLS / framing).
    ReadError(String),
    /// Subscribe / unsubscribe send failed mid-session.
    ResubscribeFailure(String),
}

impl ReconnectReason {
    pub fn as_metric_label(&self) -> &'static str {
        match self {
            Self::Disconnect => "disconnect",
            Self::HalfOpen { .. } => "half_open",
            Self::ConnectFailure(_) => "connect_failure",
            Self::ReadError(_) => "read_error",
            Self::ResubscribeFailure(_) => "resubscribe_failure",
        }
    }
}

3.2.2 WsEvent::Reconnected variant ​

Добавить вариант в существующий enum WsEvent (book.rs:69):

rust
/// Emitted by the WS supervisor on every successful (re)connect after the
/// first one. The `subscribed_assets` snapshot is taken AT THE MOMENT of
/// resubscribe so the consumer (scanner) can drive a burst REST resync
/// over the exact set the wire is subscribed to.
Reconnected {
    subscribed_assets: Vec<String>,
    reason: ReconnectReason,
    disconnect_duration_ms: u64,
}

Эмит происходит в connect_and_run (book.rs:393-411) сразу после успешного subscribe burst и flip connected = true. Reason передаётся как параметр из connection_loop (наследуется от предыдущей итерации supervisor loop).

Изменение supervisor сигнатуры: connect_and_run начинает принимать prev_outcome: Option<&PrevAttemptOutcome> (либо приватный enum внутри supervisor), чтобы при успехе он знал reason для уже завершившейся reconnect-попытки. Альтернатива — supervisor сам шлёт WsEvent::Reconnected в tx после connect_and_run отметил connected=true. Выбор: supervisor сам шлёт, проще; нужен small refactor чтобы supervisor имел доступ к tx (он его уже принимает на вход).

3.2.3 Scanner handler ​

В scanner.rs main event loop (после строки 574 match ws_event { ... }):

rust
WsEvent::Reconnected { subscribed_assets, reason, disconnect_duration_ms } => {
    tracing::warn!(
        ?reason,
        count = subscribed_assets.len(),
        disconnect_duration_ms,
        "WS reconnected — triggering REST burst resync"
    );
    metrics::counter!("ws_reconnect_total", "reason" => reason.as_metric_label()).increment(1);

    let start = Instant::now();
    let mut ok = 0usize;
    let mut failed = 0usize;
    for asset_id in &subscribed_assets {
        match order_manager.clob.fetch_orderbook_rest_with_metadata(asset_id).await {
            Ok(snapshot) => {
                let _ = order_manager.orderbook_store.update_from_rest_snapshot(snapshot);
                ok += 1;
            }
            Err(e) => {
                tracing::error!(error = %e, asset_id, "REST resync fetch failed");
                failed += 1;
            }
        }
    }
    let elapsed_ms = start.elapsed().as_millis() as u64;
    metrics::counter!("ws_resync_assets_total", "outcome" => "ok").increment(ok as u64);
    metrics::counter!("ws_resync_assets_total", "outcome" => "failed").increment(failed as u64);
    metrics::histogram!("ws_resync_duration_ms").record(elapsed_ms as f64);
    tracing::info!(ok, failed, elapsed_ms, "WS resync burst completed");
}

Sequential vs concurrent fetch: V1 — sequential. Обоснование:

  • CLOB rate-limit на GET /book = 1500 per 10s burst (memory reference_polymarket_rate_limits). 200 sequential calls при p50 RTT 30ms ≈ 6s — укладывается в budget.
  • Sequential защищает от rate-limit bursting; concurrent (например buffer_unordered(16)) выгоден только при >300 ассетов с высоким RTT — не наш типичный случай.
  • Concurrent опция (rt-budget-aware) — defer на V1.1 если профилирование покажет >5s elapsed на типичном reconnect.

Race с reconciliation pool: ReconciliationPool (BUG-11) уже поллит REST в background. V1 burst — это точечный sync trigger, может приземлиться одновременно с pool fetch'ем для тех же ассетов. Защита: ADR-022 REST guard (accept_rest_levels) reject'ит более старый источник timestamp'а; одинаковые (asset_id, source_timestamp_ms) идемпотентно дают одинаковый результат.

3.3 Метрики ​

MetricTypeLabelsDescription
ws_unknown_event_totalcounterevent_typeIncrement per unknown event_type на wire
ws_parse_errors_totalcounterkind (json/decimal/missing_field), event_type (book/price_change/...)Parse-failures по типу
ws_unknown_enum_totalcounterenum (side/status/...), valueOut-of-spec enum values
ws_reconnect_totalcounterreasonPer ReconnectReason; expected ~0/day в healthy ops
ws_resync_assets_totalcounteroutcome (ok/failed)REST burst fetch results
ws_resync_duration_mshistogram(none)Wall-clock REST burst sweep duration

Все 6 метрик регистрируются в том же месте, где уже регистрируются другие counters (order_manager.metrics поле — ws_reconnect_count уже там, scanner.rs:1504).


4. План реализации (5 phases) ​

Phase 1 — Strict parser audit (book.rs + user.rs) ​

Scope: 15 silent-skip веток в book.rs (lines from §2.1) + 2 в user.rs. Work:

  • Inline transform каждой ветки по шаблонам Class A/B/C.
  • Per-event_type label propagation в ws_parse_errors_total.
  • Update parse_ws_message_for_test — должна оставаться валидной для replay тестов. Tests (new in crates/pm-bot/tests/):
  • ws_parser_strict_unknown_event_panics_in_debug.rs — feed unknown event_type → expect panic in cfg(test) (debug_assertions on).
  • ws_parser_strict_malformed_book_logs_and_skips.rs — invalid JSON → returns empty vec, counter incremented (need test-hook to read counter; or assert via tracing capture).
  • ws_parser_strict_unknown_side_skips_entry.rs — side: "FROB" → skip one delta, keep others. Footprint: ~80 LOC прод-кода, ~120 LOC тестов.

Phase 2 — ReconnectReason + WsEvent::Reconnected emit ​

Scope: book.rs supervisor refactor + WsEvent enum extension. Work:

  • Add ReconnectReason enum (~30 LOC).
  • Add WsEvent::Reconnected variant (~10 LOC).
  • connection_loop tracks prev_reason: Option<ReconnectReason> across iterations.
  • connect_and_run после successful subscribe burst шлёт WsEvent::Reconnected в tx (только если prev_reason.is_some() — на самом первом connect не шлём).
  • Map call sites → reasons:
    • book.rs:393 (connect_async?) → ConnectFailure(e.to_string())
    • book.rs:497 (activity_deadline) → HalfOpen { silent_secs: 90 }
    • book.rs:602 (read error) → ReadError(e.to_string())
    • book.rs:592 (server Close) / book.rs:605 (stream ended) → Disconnect
    • book.rs:524/536 (subscribe send fail) → ResubscribeFailure(e.to_string())
    • book.rs:586 (receiver_dropped) → не reconnect, supervisor выходит (cancel propagation). Tests:
  • ws_reconnected_event_emitted_with_reason_halfopen.rs — simulate activity_timeout via test harness; assert event emitted.
  • ws_reconnected_event_subscribed_assets_matches_resubscribe.rs — set N tokens, force reconnect, assert event.subscribed_assets == N. Footprint: ~120 LOC прод-кода (включая supervisor refactor для prev_reason wiring), ~80 LOC тестов.

Phase 3 — Scanner REST resync handler ​

Scope: scanner.rs main event loop new arm. Work:

  • Match arm для WsEvent::Reconnected { subscribed_assets, reason, disconnect_duration_ms } сразу после/до существующего WsEvent::Price arm (scanner.rs:575).
  • Sequential for asset_id in &subscribed_assets { fetch_orderbook_rest_with_metadata().await }.
  • Применять через order_manager.orderbook_store.update_from_rest_snapshot(snapshot).
  • Emit 4 метрики per burst.
  • НЕ удалять существующий invalidate_orderbook_cache (scanner.rs:511) — он работает на edge prev_ws_connected=false → true через ws_client.connected bool и остаётся как defense-in-depth. Tests (integration, в crates/pm-bot/tests/):
  • ws_reconnect_triggers_rest_burst.rs — feed WsEvent::Reconnected через test channel, mock OrderClient записывает все fetch вызовы → assert == subscribed_assets list.
  • ws_reconnect_resync_handles_partial_failure.rs — mock роняет N/3 fetch'ей → assert ok counter + failed counter согласованы. Footprint: ~60 LOC прод-кода, ~120 LOC тестов.

Phase 4 — Metrics registration & dashboard wiring ​

Scope: метрики уже есть как metrics::counter!() calls — нужно убедиться что они инициализированы и видны на Local-Bot Mode panel. Work:

  • Найти existing metrics init point (поиск по metrics::describe_counter! / register_counter!).
  • Добавить describe для 6 новых.
  • Опционально: surface в BotSnapshot (pm-bot-events) если пользователь хочет видеть в дашборде. Defer до фидбэка — V1 ограничивается логами + Prometheus-scrape. Footprint: ~30 LOC.

Phase 5 — E2E verify + clippy + release ​

Scope: pre-merge gates. Work:

  • cargo test -p pm-bot --test 'ws_*' зелёный.
  • cargo clippy -p pm-bot --all-targets -- -D warnings clean (без новых allow).
  • cargo build -p pm-bot --release ok (на dev workstation — для VPS пользователь сам через scripts/deploy-vps.sh).
  • Verify: на VPS после deploy, в логах grep ws_reconnect_total показывает ≥1 событие за 1ч (естественные disconnect'ы в проде).
  • VERIFIED markers расставить на: connection_loop (book.rs:303), parse_ws_message_impl (book.rs:774), scanner.rs handler. Footprint: docs + verification, нет нового кода.

5. Coverage rule-IDs (strategy.md §9) ​

Полностью покрывает V1 ​

Rule-IDПокрытиеКомментарий
R-WS-RESYNC-AFTER-RECONNECT (§9.1 line 529)✓Phase 3 — burst REST fetch для всех subscribed
R-WS-RESNAPSHOT-AFTER-RECONNECT (§9.2 line 548)✓то же, через update_from_rest_snapshot
R-WS-PARSE-ERROR-RESTART-POLICY (§9.2 line 542)✓Phase 1 — debug panic / prod log+counter, не silent
R-WS-UNKNOWN-MSG-RAW-OR-DEADLETTER (§9.2 line 543)✓Phase 1 — error log с raw preview + counter (раw → log как dead-letter analog)
R-WS-NO-SILENT-MALFORMED-SKIP (§9.3 line 565)✓Phase 1 — устраняет все 15 silent-skip веток
R-WS-NO-EVENTS-AFTER-RECONNECT-WITHOUT-RESYNC (§9.3 line 562)✓Phase 3 — burst REST до того как scanner начнёт принимать решения по post-reconnect WS deltas (synchronous handler)
R-WS-RECONNECT-CASE-DISCONNECT (§9.4 line 575)✓ReconnectReason::Disconnect
R-WS-RECONNECT-CASE-HALF-OPEN (§9.4 line 576)✓ReconnectReason::HalfOpen via activity_timeout
R-WS-RECONNECT-CASE-PING-TIMEOUT (§9.4 line 577)✓ (через HalfOpen)PingTimeout distinct labeling — defer V1.1; pragma — half-open == «pings flow но трафика нет», что наш текущий activity timer и измеряет
R-WS-RECONNECT-CASE-MALFORMED (§9.4 line 578)✓Phase 1 ловит malformed на парсере; не reconnect, но evidence chain закрыт
R-WS-RECONNECT-EVIDENCE-LOGGED (§9.4 line 590)✓tracing::warn! per reconnect
R-WS-RECONNECT-EVIDENCE-METRIC (§9.4 line 591)✓ws_reconnect_total{reason}
R-WS-RECONNECT-EVIDENCE-POST-RESNAPSHOT (§9.4 line 593)✓Burst REST fetch + apply

Частично покрывает (ослабленная форма) ​

Rule-IDStatusЧто не сделано
R-WS-RECONNECT-EVIDENCE-DEGRADED-STATE (§9.4 line 592)partialws_client.connected: AtomicBool уже есть; «recovering» state distinct from «degraded» — не введён. Defer.
R-WS-GAP-DETECT (§9.1 line 527)partialPhase 1 fail-fast детектит parser drift; gap-by-seq-number — нет (см. exclusions)

Explicitly OUT OF SCOPE ​

Rule-IDПричина
R-WS-GAP-MARKER-DB (§9.2 line 549)DB schema work — отдельный backlog, нужна таблица ws_gap_markers для backtest replay exclusion
R-WS-STREAM-SESSION-ID (§9.2 line 550)Polymarket не присылает session_id на wire (memory reference_ws_troubleshooting) — sticky-routing невозможен
R-WS-MONOTONIC-INGEST-SEQ (§9.2 line 551)Отвергнут в project_ws_self_healing_decision (Вариант 2 reject) — steady-rate drift не детектится
R-WS-DEDUP-KEY (§9.2 line 555)Hash-content dedup отсутствует; REST resync polagается на ADR-022 REST guard; полноценный dedup — отдельный fix
R-WS-RAW-PAYLOAD-HASH (§9.2 line 554)Capture/replay path (ADR-009) уже пишет raw; in-memory hashing — defer
R-WS-RECONNECT-CASE-NET-PAUSE (§9.4 line 579)Effectively covered by ACTIVITY_TIMEOUT (90s) → HalfOpen, но distinct net-pause labeling defer
R-WS-RECONNECT-CASE-DB-UNAVAIL (§9.4 line 580)Не WS-layer concern; orthogonal к этому fix
R-WS-RECONNECT-CASE-BURST (§9.4 line 581)Initial dump batching уже работает (SUBSCRIBE_BATCH_SIZE=500); burst-after-reconnect не вызывает crash — current code handles
R-WS-RECONNECT-CASE-SUB-REJECTED (§9.4 line 582)Subscription rejection wire-protocol Polymarket'а не публичен — defer до captured rejection sample
R-WS-RECONNECT-CASE-PARTIAL-SUB (§9.4 line 583)Same — нет evidence какие frames Polymarket шлёт при partial reject
R-WS-RECONNECT-CASE-TOKEN-REMOVED (§9.4 line 584)Покрывается WsEvent::MarketResolved handling (другой кодовый путь, не WS reconnect)
R-WS-RECONNECT-EVIDENCE-GAP-MARKER (§9.4 line 594)Связан с R-WS-GAP-MARKER-DB — defer вместе
R-WS-RECONNECT-EVIDENCE-BACKTEST-EXCLUDABLE (§9.4 line 595)Same

Итого V1: 13 полных + 2 частичных. После V1 в §9 остаются 13 open rule-IDs для будущих fix'ов (gap-marker DB, dedup, monotonic seq — каждый отдельный design).


6. Footprint ​

LayerEstimate
crates/pm-bot/src/io/ws/book.rs (parser strict)~80 LOC delta
crates/pm-bot/src/io/ws/user.rs (parser strict)~25 LOC delta
crates/pm-bot/src/io/ws/book.rs (ReconnectReason + WsEvent::Reconnected emit)~120 LOC delta
crates/pm-bot/src/application/scanner.rs (resync handler)~60 LOC delta
Metrics descriptor wiring~30 LOC
Tests (unit + integration)~320 LOC (5 unit + 2 integration)
Docs (this design + components-status update)already covered
Total prod code~315 LOC across 3 files
Total test code~320 LOC in 5-7 new test files

7. Acceptance criteria ​

  • AC-1 Все 17 silent-skip locations устранены — verified re-grep по паттернам _ => (), .ok()?, Err(_) в crates/pm-bot/src/io/ws/{book,user}.rs (за исключением intentional Class B cfg(not(debug_assertions)) skip).
  • AC-2 WsEvent::Reconnected emit'ится в 5 reconnect path'ах (Disconnect / HalfOpen / ConnectFailure / ReadError / ResubscribeFailure), unit-test per reason.
  • AC-3 Scanner делает sequential REST fetch для всех subscribed_assets после WsEvent::Reconnected, integration-test покрывает (a) full success path (b) partial failure path.
  • AC-4 6 метрик зарегистрированы и наблюдаемы (Prometheus scrape или structured log).
  • AC-5 cargo clippy -p pm-bot --all-targets -- -D warnings зелёный.
  • AC-6 cargo build -p pm-bot --release зелёный.
  • AC-7 VERIFIED маркеры расставлены на book.rs::connection_loop, book.rs::parse_ws_message_impl, scanner.rs::<resync handler>.
  • AC-8 docs/tests/components-status.md §9 обновлён: 13 полных rule-IDs → проверено; 2 partial → отмечены; 13 explicit OUT-OF-SCOPE → ссылка на этот design.
  • AC-9 Backlog #143 → DONE-143.

8. References ​

  • Backlog: docs/tests/backlog/DONE-143-ws-strict-fail-fast-resync.md
  • BUG-11 sibling fix (V3): commit 45295cf (merge) + 96b0ce9 (closure)
  • ADR-022 (REST guard for resync — unified-orderbook-merge)
  • Strategy.md §9 (WS Market channel): docs/tests/strategy.md lines 515-596
  • Production WS layer:
    • crates/pm-bot/src/io/ws/book.rs (1868 LOC) — supervisor + parser
    • crates/pm-bot/src/io/ws/user.rs (649 LOC) — user channel parser
    • crates/pm-bot/src/application/scanner.rs:485-573 — current edge-trigger Gap-B handler
  • Memory anchors:
    • project_ws_self_healing_decision — V1+V3 architecture ratification
    • feedback_ws_must_be_self_healing — fail-fast principle
    • reference_ws_troubleshooting — Polymarket WS wire invariants