Skip to content

Acceptance Criteria — Trading Logic Critical Paths ​

ИСТОРИЧЕСКИЙ ДОКУМЕНТ. Это снимок критериев до отказа от Backtest, replay, runtime simulation и standalone capture 2026-08-22. Он не задаёт текущее поведение и не является источником команд проверки. Действующие критерии находятся в openspec/specs/ и change-артефактах.

Перенесён из docs/operations/ в docs/specs/ в SSOT-конвергенции II (2026-05-20). Re-mapping AC-IDs / Gherkin scenarios на новый docs/strategies/directional-harvester.md. Архивные стратегии (bucket-arb / maker / maker-rebate / merge-arb) удалены целиком per ADR-024 ACCEPTED 2026-05-13. Их AC-IDs пересоберутся как layers поверх extension API (ADR-025 PROPOSED).

Spec-derived Gherkin scenarios для критических торговых paths. Каждый AC имеет уникальный AC-N ID. L3 integration test ОБЯЗАН ссылаться на AC-N в комментарии (// covers AC-N) или в имени теста (fn ac_N_<slug>()). Источник — только ADR / spec / CLAUDE.md, не код.

Conventions ​

  • Background block — env knobs + market state + wallet pre-conditions. Это инвариантные предусловия (Gherkin Given верхнего уровня).
  • Scenario block — happy path, английские keywords (Given / When / Then / And).
  • Negative scenario block — adversarial / edge / failure path.
  • Verification observable — конкретный артефакт (placement_log, Decision-stream snapshot, journal row, counter, log line, FSM state), который ассертится в L3 integration test. Это контракт liveness: если артефакт не наблюдаем — тест нельзя написать, AC возвращается на доработку.
  • <!-- spec gap: ... --> — точка, где ADR/FSD не дают однозначной спецификации. L3 author должен либо запросить уточнение у software-architect, либо assert минимальный наблюдаемый инвариант.

Source map ​

ACPrimary specSecondary refs
AC-1docs/strategies/directional-harvester.md §Жизненный цикл сделки, §Anchor sourcememory:harvester_semantics, ADR-024, ADR-027, ADR-030
AC-2docs/strategies/directional-harvester.md §Migration pathADR-007 §recovery
AC-3docs/integrations/webhook.md (ADR-003)docs/strategies/directional-harvester.md §Decision rationale
AC-4docs/integrations/rtds.md (ADR-004)docs/strategies/directional-harvester.md §Жизненный цикл сделки (exit GTC)
AC-5docs/strategies/directional-harvester.md §Жизненный цикл сделки (инвариант 8 auto-redeem) + §Целевые рынкиmemory project_neg_risk_merge_path
AC-6СНЯТ 2026-08-21 вместе с бэктестомисполнителя нет; детерминизм решений держит tests/it/directional_decision_trace_golden.rs

AC-1: Directional / Harvester periodic scan + entry band + DCA + stop-loss ​

Source: docs/strategies/directional-harvester.md §Жизненный цикл сделки (инварианты 1, 3, 5, 6, 7, 8, 9), §Anchor source и entry-window gate, §Reactive closure detection, §Decision rationale (ADR-024, ADR-027, ADR-030); memory harvester_semantics; memory:directional_periodic_scan_implemented.

Background (knobs live в config.db strategies.definition per ADR-015; env используется только для infrastructure):

  • BOT_STRATEGY=directional (единственная активная per ADR-024)
  • strategies.definition.entry_price_base=best-ask + offset_cents=0
  • DIRECTIONAL_MIN_PROBABILITY=0.95
  • DIRECTIONAL_MAX_ENTRY_PRICE=0.98 (внутренний cap полосы)
  • budget_per_market=5.00 (live Harvester)
  • min_size_shares=5 целых долей (at-rest в config.db — micro-shares 5_000_000, × 10^6; оператор вводит/видит 5 через UI scaling-seam, как budget_*_micro_usd прячет μUSD за $)
  • exit_price=0.99, recompute_exit_on_fill=true
  • execution.initial_entry.policy={type:"limit_gtd",post_only:false,ttl_seconds:3600} и собственный max_taker_fee_bps (effective GTD 1 час; live wire now+60+3600); каждая другая роль задаёт свою policy явно
  • stop_loss_bid_threshold=0.80, stop_loss_cooldown_hours=5, stop_loss_debounce_secs=5 (live с 2026-05-16)
  • finish_type сконфигурирован per market category (e.g. GstLocalMidnight для weather daily через embedded GeoNames cities15000)
  • BOT_FALLBACK_SCAN_SECS set (e.g. 60)
  • Market: binary (tokens.len() == 2), per-strategy neg_risk gate совместим, accepting_orders=true, closed=false через ClosureWatcher, probability winning outcome ≥ 0.95, best_ask ∈ [0.95, 0.98], entry-window gate допускает (capability outcome-tradability-detection возвращает AllowEntry)

Scenario: Periodic scan эмитит BUY на winning outcome в полосе [0.95, 0.98] ​

  • Given MarketCache содержит ≥ 1 admissible binary market matching все фильтры выше
  • And бот работает ≥ BOT_FALLBACK_SCAN_SECS с момента последнего evaluate_periodic
  • When scanner fires periodic tick → Strategy::evaluate_periodic на DirectionalStrategy (Harvester profile)
  • And per-tick warmup buffer cheapest-first preference sorted ascending по best_ask (capability directional-entry-buffer)
  • Then ровно один Decision::PlaceOrder { side: Buy, outcome: winning_outcome, price: best_ask, post_only: false, order_type: Limit, time_in_force: Gtd, gtd_seconds: 3600 } эмитится для qualifying market
  • And winning_outcome определён probability-моделью (single-side YES OR NO, не side_mode-knob)
  • And price ∈ [0.95, 0.98] (отказ если best_ask > 0.98 или < 0.95)
  • And size ≥ min_size_shares = 5 целых долей (enforcement сравнивает micro-vs-micro: size и min_size_shares оба at-rest × 10^6, поэтому 5 целых = 5_000_000; бизнес-смысл — «≥ 5 целых долей»)
  • And size × price ≤ budget_per_market (greedy-budget-bounded)
  • And ClosureWatcher confirms is_closed(asset_id) == false
  • And entry-window verdict = AllowEntry (finish_type + seconds-bounds)

Scenario: First fill эмитит GTC SELL @ 0.99 для total accumulated position ​

  • Given entry BUY filled полностью (или partial)
  • When Strategy::on_full_fill или on_partial_fill fires для того order
  • Then ровно один Decision::PlaceOrder { side: Sell, price: 0.99, order_type: GTC, size: total_accumulated_position } эмитится
  • And если был ранее выставленный exit GTC — он пересчитывается на новый total size (recompute_exit_on_fill = true)

Scenario: DCA top-up — на каждый fill дополнительный BUY в пределах budget ​

  • Given позиция накоплена ниже budget_per_market
  • When fill arrives
  • Then дополнительный BUY по собственной execution.dca (в этом сценарии GTD, цена ∈ [0.95, 0.98]) эмитится если остаётся budget
  • And суммарная экспозиция после DCA ≤ budget_per_market
  • And exit GTC переразмещается на новый total size

Scenario: Stop-loss — bid < 0.80 непрерывно ≥ 5s → handoff + configured SELL ​

  • Given позиция открыта, exit GTC @ 0.99 в книге
  • When best_bid < 0.80 фиксируется первый sub-threshold tick → stop_loss_below_since = now
  • And continuous monitoring: на recovery (bid >= 0.80) timer сбрасывается
  • When now - stop_loss_below_since > stop_loss_debounce_secs = 5s непрерывно
  • Then после one-owner handoff эмитится Decision::PlaceOrder { side: Sell, execution: execution.stop_exit, price: current_best_bid, close_reason: "stop_loss" }]
  • And market → StopLossBlacklist на stop_loss_cooldown_hours = 5h
  • And конкретные fill/kill/rest semantics определяются выбранной execution.stop_exit, не stop-trigger FSM

Negative scenario: best_ask вне полосы → no entry ​

  • Given market matches все остальные admissions, но best_ask = 0.99 (выше cap)
  • When evaluate_periodic runs
  • Then ZERO Decision::PlaceOrder для этого cid
  • And market остаётся в кэше для следующего тика

Negative scenario: probability < 0.95 → no entry ​

  • Given market matches best_ask ∈ [0.95, 0.98], но winning outcome probability = 0.92
  • When evaluate_periodic runs
  • Then ZERO Decision::PlaceOrder для этого cid

Negative scenario: ClosureWatcher reports closed → no entry, no DCA ​

  • Given ClosureWatcher emitted OutcomeClosed для asset_id
  • When evaluate_periodic runs или fill arrives
  • Then ZERO Decision::PlaceOrder для этого cid (entry skipped, DCA blocked)
  • And closure_event_subscriber диспатчит Decision::CancelByLifecycle для existing open orders

Negative scenario: entry-window predictive gate refuses ​

  • Given late_entry_seconds = 3600 сконфигурирован, secs_to_anchor = 60
  • When entry-window gate evaluates
  • Then verdict = RefuseTooLate
  • And ZERO Decision::PlaceOrder

Verification observable ​

  • Исторический L3 driver: ReplayEngine с synthetic MarketCache, capture fixture и SymbolicClock. Этот исполнитель и его runbook удалены 2026-08-22.
  • Assert (entry happy): placement_log для qualifying cid содержит ровно 1 BUY entry per periodic tick; price ∈ [0.95, 0.98]; order_type == Gtd; gtd_seconds == 3600.
  • Assert (cheapest-first): два одновременно admitted cids — первый placement по cid с меньшим best_ask.
  • Assert (exit on fill): после fill ровно 1 Decision::PlaceOrder { side: Sell, price: 0.99, order_type: GTC }; size equals total accumulated (включая DCA).
  • Assert (DCA recompute): после второго fill exit GTC переразмещается с увеличенным size; суммарная exposure ≤ budget_per_market.
  • Assert (stop-loss): seeded sequence best_bid = 0.79 для 5 непрерывных секунд → cancel_calls для exit_GTC + 1 FOK SELL @ current best_bid; market в StopLossBlacklist для следующих 5 часов.
  • Assert (band-out skip): market с best_ask = 0.94 или 0.99 — zero entries в placement_log.
  • Assert (closure skip): emit OutcomeClosed → последующий periodic tick не эмитит PlaceOrder для этого asset_id.

AC-2: Recovery after process kill ​

Source: docs/strategies/directional-harvester.md §Migration path, §Жизненный цикл сделки (инвариант 7 shadow TTL + инвариант 8 redeem hybrid); ADR-007 §recovery (durable bot_sessions + capture_sessions).

Background:

  • pm-engine is live (or capture mode) с активной стратегией directional
  • 3 open orders POSTED на CLOB: 2 BUY GTD (entry/DCA) + 1 exit GTC @ 0.99
  • 2 filled positions держатся on-chain (resolved-pending markets)
  • Durable state в SQLite: orders_history, fills, positions_history, event_log rows (per ADR-005 INV-A atomic journal)
  • StopLossBlacklist (если был triggered) persisted

Scenario: Hard-kill restart hydrates state from durable + CLOB + on-chain ​

  • Given бот operational с state выше
  • When operator посылает kill -9 <pid> (no graceful shutdown)
  • And бот рестартует против тех же SQLite databases
  • Then Strategy::recover восстанавливает PendingEntry shadow map
    • accumulated position state + exit GTC awareness
  • And OrderClient::list_orders(status=Open) гидрирует open orders (вычисляет size = original_size − size_matched для partial fills)
  • And filled positions гидрируются from on-chain ERC-1155 balances
  • And первый evaluate_periodic или on_full_fill tick после restart НЕ эмитит duplicate Decision::PlaceOrder для cids которые уже представлены в orders_history ∪ positions_history
  • And StopLossBlacklist restored — cooldown продолжается с оригинального wall-clock timestamp

Negative scenario: Idempotent trade processing after restart ​

  • Given restart sequence выше
  • And во время kill window CLOB эмитнул WsEvent::TradeMatched для одного из BUY ордеров, но локально не было ACK
  • When CLOB re-replays trade на новом WS connection
  • Then durable journal idempotency key (trade_id / fill_id) предотвращает double-application
  • And position size incremented ровно один раз
  • And downstream exit GTC recompute эмитится ровно один раз

Verification observable ​

  • L3 driver: multi-phase replay session — phase 1 captures state в SQLite, session interrupted; phase 2 invokes Strategy::recover hook over those DBs.
  • Assert (no dup): в первые 10 scanner ticks post-recovery, placement_log содержит zero new POST entries для cids уже представленных в orders_history ∪ positions_history.
  • Assert (idempotent trade): inject duplicate TradeMatched для already-processed key; journal row count для того ключа stays at 1.
  • Assert (partial fill): hydrated order имеет size = 6 (если original_size=10 и size_matched=4).
  • Assert (shadow TTL): seeded PendingEntry с created_at = now - 700s → после recovery in-memory state не содержит этого entry; CLOB order (если жив) остался без cancel_calls от shadow cleanup.
  • Assert (StopLossBlacklist): seeded blacklist entry с cooldown_until в будущем → market не получает entry decisions до истечения cooldown.

AC-3: Webhook signal triggers entry through Strategy pipeline ​

Source: docs/integrations/webhook.md (ADR-003) §§ "Decision", "Decision Drivers" D2 / D5 / D7, "Considered Options" Option A.

Background:

  • WEBHOOK_ENABLED=true
  • WEBHOOK_BIND_ADDR=127.0.0.1:31338 (loopback default)
  • WEBHOOK_SECRET=<32-byte hex> (HMAC-SHA256)
  • WEBHOOK_IP_ALLOWLIST=127.0.0.1 (optional, set for hardened scenario)
  • BOT_STRATEGY=directional with valid Background of AC-1
  • Active scanner with 8th hook on_external_signal registered (ADR-003 § "Decision")

Scenario: Valid signal fans out to Strategy hook → Decision::Buy ​

  • Given an external producer issues POST /v1/signals to 127.0.0.1:31338 with body {"signal_id":"X-001","condition_id":"<cid>","outcome":"Yes",...}
  • And the body carries a valid X-Webhook-Signature HMAC-SHA256 header keyed by WEBHOOK_SECRET
  • And the source IP is in WEBHOOK_IP_ALLOWLIST
  • When the axum handler validates HMAC and dedup-checks signal_id against the in-memory seen-set (D5 idempotency)
  • Then the signal is enqueued via mpsc::Sender<ExternalSignal> to the scanner (Option A wire)
  • And ScannerHooks::on_external_signal fires on the active strategy chain (DirectionalStrategy::on_external_signal)
  • And exactly one Decision::PlaceOrder { side: Buy, .. } is emitted with signal_source=webhook metadata for the signal-specified condition_id (D7 — single pipeline, no parallel path)
  • And the order respects ALL Phase 4 invariants (INV-1 neg-risk filter, max_entry_price, etc.) — webhook does not bypass strategy filters (D2 critical)

Negative scenario: Duplicate signal_id is no-op ​

  • Given signal X-001 was just processed (entry placed)
  • When the same producer retries with signal_id=X-001 (network retry / at-least-once delivery)
  • Then the dedup check rejects the duplicate
  • And ZERO new Decision::PlaceOrder is emitted
  • And the HTTP response is 200 (idempotent ACK), not 4xx

Negative scenario: Bad HMAC → 401 unauthorized ​

  • Given a request body with a tampered or wrong-key HMAC
  • When the handler verifies the signature
  • Then the response status is 401 Unauthorized
  • And ZERO downstream effects: no mpsc::send, no on_external_signal hook fires, no Decision emitted

Negative scenario: Non-allowlisted source IP → 403 forbidden ​

  • Given WEBHOOK_IP_ALLOWLIST=10.0.0.5 and incoming connection from 127.0.0.1
  • When axum middleware checks the source IP
  • Then the response is 403 Forbidden
  • And body verification (HMAC) is short-circuited before hash computation
  • And no Decision emitted

Verification observable ​

  • L3 driver: in-process axum test client (TestServer over 127.0.0.1 ephemeral port) + ReplayEngine listening on the mpsc receiver.
  • Assert (happy): exactly 1 PlaceOrder Decision per unique signal_id AND metadata.signal_source == "webhook" AND target cid matches payload.
  • Assert (dup): re-POST same signal_id; total Decision count for that cid stays at 1 across the second request.
  • Assert (bad HMAC): response.status == 401 AND mpsc.try_recv() == Empty after a 100ms quiesce.
  • Assert (IP block): response.status == 403; HMAC verifier counter increment is 0 (proves short-circuit).

AC-4: RTDS toxic-flow active cancel ​

Source: docs/integrations/rtds.md (ADR-004) §§ Context Pain points #2 (toxic-flow detection), CLAUDE.md § "Optional integrations" RTDS gate (RTDS_TOXIC_FLOW_LOG_ONLY → RTDS_TOXIC_FLOW_ACTIVE_CANCEL_ENABLED).

Background:

  • RTDS WS connection up to wss://ws-live-data.polymarket.com
  • RTDS_TOXIC_FLOW_ENABLED=true
  • RTDS_TOXIC_FLOW_LOG_ONLY=false (active mode)
  • RTDS_TOXIC_FLOW_ACTIVE_CANCEL_ENABLED=true (final cutover gate per CLAUDE.md)
  • BOT_STRATEGY=directional with active exit GTC quote resting on at least 1 market (per AC-1 exit pathway)
  • Toxic-flow window thresholds (operator-defined, per ADR-004 Pain point #2): N trades within W seconds, all on the same side, total size ≥ T

Scenario: One-sided pressure burst triggers cancel of resting quotes ​

  • Given an exit GTC SELL is RESTING on cid C for outcome YES
  • And the rolling RTDS window for cid C is empty
  • When RTDS emits N consecutive activity/trades events for cid C within window W, all side: BUY and aggregate size ≥ T (one-sided pressure)
  • Then the toxic-flow detector classifies the window as toxic
  • And exactly one Decision::CancelOrder (or Decision::CancelAll(condition_id=C)) is emitted for the resting order on cid C
  • And the Decision's metadata.reason contains "toxic_flow" for operator audit

Negative scenario: LOG_ONLY=true → log entry, no cancel ​

  • Given Background mutated with RTDS_TOXIC_FLOW_LOG_ONLY=true (and either active-cancel flag false or irrelevant)
  • And the same toxic burst sequence as above
  • When the detector classifies the window as toxic
  • Then ZERO Decision::CancelOrder is emitted for any cid
  • And a structured log line at level warn/info is produced with event="toxic_flow_detected", cid=C, window_size=N, total_size (CLAUDE.md gate semantics)

Negative scenario: ACTIVE_CANCEL_ENABLED=false short-circuits ​

  • Given Background mutated with RTDS_TOXIC_FLOW_ACTIVE_CANCEL_ENABLED=false
  • When detector classifies the window as toxic
  • Then ZERO Cancel emitted (final gate not opened) regardless of LOG_ONLY value

Negative scenario: own-fills do NOT trigger toxic classification ​

  • Given RTDS event has proxyWallet == OUR_WALLET_ADDRESS
  • When the event would otherwise contribute to the toxic window
  • Then the event is filtered out before window aggregation (ADR-004 § Context — own-fills come from CLOB /ws/user, not RTDS)
  • And the window does NOT advance toward classification

Verification observable ​

  • L3 driver: ReplayEngine fed with synthetic RTDS frames + SimOrderClient with one resting exit GTC pre-seeded.
  • Assert (happy): inject 10 RTDS BUY events on cid C within 5s, total size 50000; assert exactly 1 Cancel-class Decision for an order on cid C with reason ~= "toxic_flow" within 1 scanner tick of the 10th event.
  • Assert (LOG_ONLY): same input, flag flipped; assert decision-stream Cancel count for cid C is 0; assert journal/log contains 1 entry matching event="toxic_flow_detected".
  • Assert (gate): with active-cancel flag false; same input; same zero-Cancel assertion as LOG_ONLY.
  • Assert (own-wallet filter): inject toxic-shaped burst where every event has proxyWallet == OUR_WALLET; assert detector window stays empty AND zero Cancel.

AC-5: Directional / Harvester auto-redeem on market resolution (hybrid WS + backstop) ​

Source: docs/strategies/directional-harvester.md §Жизненный цикл сделки (инвариант 8 — auto-redeem hybrid), §Целевые рынки (standard CTF + neg-risk-wrapped), §Decision rationale (ADR-016 — TimezoneCache для auto-redeem TTL only); memory project_neg_risk_merge_path (NegRiskAdapter is canonical contract для neg-risk; standard CTF MUST NOT быть использован на neg-risk).

Background:

  • BOT_STRATEGY=directional
  • One filled позиция (e.g. 50 shares winning outcome filled @ 0.97 avg)
  • Market type: либо standard binary CTF, либо neg-risk-wrapped binary
  • Wallet has setApprovalForAll(NEG_RISK_ADAPTER_ADDRESS, true) (для neg-risk)
  • REDEEM_BACKSTOP_SECS = 600

Scenario: Resolution через WS-event → auto-redeem через корректный adapter ​

  • Given 50-share filled позиция выше
  • When CLOB эмитит WsEvent::MarketResolved { condition_id, winning_outcome }
  • Then Strategy::on_market_resolved fires на DirectionalStrategy
  • And ровно один Decision::Redeem { condition_id, neg_risk: <market_flag> } эмитится (primary path per инвариант 8)
  • And dispatcher маршрутизирует Redeem к корректному on-chain path:
    • neg_risk=false → CTF.redeemPositions(USDC, 0, cid, [1,2])
    • neg_risk=true → NegRiskAdapter.redeemPositions(bytes32, uint256)
  • And позиция удаляется из self.positions (идемпотентно для backstop)
  • And на receipt confirmation, wallet pUSD balance увеличивается на approximately 50 × $1.00 = $50.00 (modulo gas)

Scenario: Resolution missed WS → 10-min backstop эмитит Redeem ​

  • Given rynok резолвится, но WS-event пропал (network drop / restart window) — MarketCache::active_markets() больше не содержит cid
  • And позиция has filled_at старше 600s
  • When evaluate_periodic runs (backstop branch)
  • Then ровно один Decision::Redeem эмитится для того cid (с корректным neg_risk флагом из persisted state)
  • And позиция удаляется из self.positions
  • And идемпотентность с WS-path: если WS приходит позже, on_market_resolved видит уже отсутствующий cid → no double Redeem

Negative scenario: Standard CTF redeem на neg-risk market forbidden ​

  • Given позиция на neg-risk-wrapped binary
  • When L3 test моки redeem dispatcher и assert какой контракт method вызывается
  • Then call MUST идти на NegRiskAdapter.redeemPositions, NOT на CTF.redeemPositions (memory project_neg_risk_merge_path — neg-risk collateral = wcol, standard CTF revert'нёт)
  • And L3 test FAILS если standard CTF method вызван для neg_risk=true cid

Negative scenario: Exit GTC @ 0.99 заполняется до resolution → no Redeem ​

  • Given позиция exit'нула через GTC SELL @ 0.99 до on-chain resolve
  • And self.positions уже не содержит cid (closed после exit fill)
  • When WsEvent::MarketResolved arrives
  • Then ZERO Decision::Redeem эмитится — нечего redeem'ить
  • And backstop тоже molchit (cid отсутствует в self.positions)

Verification observable ​

  • L3 driver: ReplayEngine + on-chain mock (MockProvider capturing contract address + method selector tuples).
  • Assert (WS-path neg-risk): synthetic MarketResolved для neg-risk cid → ровно 1 Redeem Decision; mock provider записал ровно 1 вызов NegRiskAdapter.redeemPositions(...) AND zero вызовов CTF.redeemPositions(...) для того cid.
  • Assert (WS-path standard CTF): synthetic MarketResolved для standard binary → ровно 1 Redeem Decision; mock записал CTF.redeemPositions(...) AND zero NegRiskAdapter вызовов.
  • Assert (backstop): seeded resolved позиция без WS-event, filled_at = now - 700s → periodic tick эмитит ровно 1 Redeem; повторный tick не эмитит (idempotent).
  • Assert (forbid standard CTF on neg-risk): negative invariant — mock_provider.call_count("CTF.redeemPositions", neg_risk_cid) == 0 через все сессии.
  • Assert (no redeem after exit): seeded exit-GTC fill до resolution → на MarketResolved event zero Redeem Decisions.

AC-6: Replay determinism contract — СНЯТ 2026-08-21 ​

Что произошло. Критерий проверялся тремя паритетными работами CI над офлайн-реплеем записи. Офлайн-бэктест снят решением оператора (обоснование — openspec/changes/composable-portfolio-strategy-domain/design.md §D13: симулятор филлов систематически наливает охотнее биржи, а запись сохраняет один вход торгового цикла из шести). Вместе с ним сняты три шага детерминизма из scripts/gate.sh и .github/workflows/parity.yml, и у AC-6 не осталось исполнителя.

Что держит детерминизм теперь. Утверждение «поток решений стратегии воспроизводим и не зависит от инструментации» проверяет crates/pm-engine/tests/it/directional_decision_trace_golden.rs (C2 golden): он пинит DecisionKind и непустой input_digest и требует равенства прогонов «с эмиссией отчёта» и «без неё». Это тот же класс утверждения без реплея. Виртуальное время (SymbolicClock) сохранено в детерминированных тестовых двойниках; удалённый capture-runbook.md больше не является его контрактом.

Что НЕ покрыто после снятия и признано осознанно. Бит-паритет «живая сессия ↔ переигранная запись» больше не проверяется ничем: проверка перенесена на живые сессии и телеметрию.


Cross-cutting traceability ​

Every active AC above must have at least ONE L3 integration test in the pm-engine test suite that:

  1. Carries // covers AC-N in its source-level comment, OR has a test name starting with ac_<N>_ (e.g. fn ac_1_directional_periodic_scan()).
  2. Runs the strategy's actual production hook chain (no in-test re-implementation of the pipeline).
  3. Asserts at least one observable from the AC's "Verification observable" block.
  4. Has both a positive AND at least one negative-scenario test (1:1 with the Negative scenario blocks above).

L3 tests для активных AC (AC-1, AC-2, AC-3, AC-4, AC-5) запускаются как часть cargo test -p pm-engine. AC-6 снят 2026-08-21 вместе с бэктестом — исполнителя у него больше нет. Архивные стратегии (bucket-arb / maker / maker-rebate / merge-arb) — ARCHIVED-BY-024, соответствующие L3 tests должны быть помечены #[ignore = "ARCHIVED-BY-024"] до восстановления как layers (ADR-025 PROPOSED).

Maintenance protocol ​

  • When an ADR or FSD listed in the Source map changes, the corresponding AC MUST be reviewed in the same PR. A spec change without an AC review is rejected at gate.
  • New strategies require a new AC slot here BEFORE their L3 test lands (AC-first, then test, then strategy gate sign-off — quality-gates skill).
  • <!-- spec gap: ... --> markers must either be resolved (ADR amendment + AC update) or escalated via Observatory consensus.create to software-architect within 1 sprint of the test landing.