{
  "schema_version": "1.0.0",
  "bot_id": "1.18",
  "bot_name": "StaleBookGuard",
  "slug": "stale_book_guard",
  "layer": "Risk",
  "layer_key": "risk",
  "bot_class": "Guardrail",
  "authority": [
    "Reject"
  ],
  "status": "planned",
  "readiness": "Spec ready",
  "flagship": false,
  "is_reference": false,
  "public_export": false,
  "identity": {
    "layer": "Risk",
    "bot_class": "Guardrail",
    "authority": "Reject",
    "runs_before": "exec.smart_router",
    "runs_after": "intel.orderflowanalyzer",
    "applies_to": "Per OrderIntent",
    "default_mode": "shadow",
    "user_visible": "Yes",
    "developer_owner": "Risk pod"
  },
  "purpose": "Rejects any OrderIntent priced against an order book older than the configured staleness threshold. The book may look healthy, but if its last update is too old, prices have almost certainly moved. StaleBookGuard fails closed: if it cannot prove the book is fresh, it rejects.",
  "why_it_matters": [
    {
      "failure": "Trading on stale prices",
      "consequence": "An OrderIntent priced against a 20-second-old book during a fast-moving event is essentially a market order; it will fill at whatever price the new book is.",
      "worked_example": {
        "setup": "Market 0x44a has had no order-book update for 7 seconds. The last mid was 0.41. A strategy submits a buy at 0.41 sized for the visible 6,200 pUSD of asks.",
        "without_bot": "The book is actually 0.34/0.36 \u2014 the WebSocket feed dropped and the strategy is reading a frozen snapshot. The order fills against a fresh 0.36 ask queue at 0.41 and the strategy overpays the spread on every clip.",
        "with_bot": "StaleBookGuard sees `last_book_update_age > staleness_threshold_s=3` and votes REJECT with reason `STALE_BOOK`. The order is blocked. When the WebSocket reconnects, age drops to 0.4s and the next OrderIntent goes through."
      }
    },
    {
      "failure": "Hidden feed lag",
      "consequence": "WebSocket latency can spike without disconnecting; a healthy-looking connection can still be delivering 30-second-old data."
    },
    {
      "failure": "No central freshness check",
      "consequence": "Without one canonical staleness rule, every strategy invents its own \u2014 some too lenient, some forgotten entirely."
    }
  ],
  "polymarket_inputs": [
    {
      "input": "OrderBookSnapshot.ts_ms per market_id",
      "source": "intel.orderflowanalyzer",
      "required": true,
      "use": "The single source of truth for book freshness."
    }
  ],
  "internal_inputs": [
    {
      "input": "OrderIntent.market_id",
      "source": "Strategy bot",
      "required": true,
      "use": "Identifies which book to check."
    },
    {
      "input": "Wall-clock now_ms",
      "source": "Runtime",
      "required": true,
      "use": "Compute age of the book at decision time."
    }
  ],
  "raw_params": [
    "max_book_age_ms \u00b7 100\u201360000",
    "warn_book_age_ms \u00b7 100\u201360000"
  ],
  "parameters": [
    {
      "name": "max_book_age_ms",
      "default": 2000,
      "warning": 1000,
      "hard": 2000,
      "controls": "Maximum allowed book age (ms) at which an OrderIntent may proceed.",
      "why_default_matters": "2 seconds is the longest the most active markets can drift before mid-price has demonstrably moved.",
      "threshold_logic": [
        {
          "condition": "\u2264 1000ms",
          "action": "PASS"
        },
        {
          "condition": "1000\u20132000ms",
          "action": "WARN"
        },
        {
          "condition": "> 2000ms",
          "action": "REJECT"
        }
      ],
      "dev_check": "if (now - book.tsMs > p.max_book_age_ms) return reject('RISK_BOOK_STALE');",
      "user_facing": "We did not place this order because the latest market data was too old to trust."
    },
    {
      "name": "warn_book_age_ms",
      "default": 1000,
      "warning": "\u2014",
      "hard": "\u2014",
      "controls": "Soft warn threshold \u2014 logged but not blocking, used for observability.",
      "why_default_matters": "Lets ops see slowly degrading feed health before it becomes a hard reject.",
      "threshold_logic": [
        {
          "condition": "\u2264 1000ms",
          "action": "Silent"
        },
        {
          "condition": "> 1000ms",
          "action": "WARN log only"
        }
      ],
      "dev_check": "if (now - book.tsMs > p.warn_book_age_ms) log.warn('BOOK_AGE_HIGH');",
      "user_facing": "(Internal \u2014 not shown to users.)"
    }
  ],
  "default_config": {
    "max_book_age_ms": 2000,
    "warn_book_age_ms": 1000
  },
  "flow": "Receive OrderIntent \u2192 look up latest OrderBookSnapshot for OrderIntent.market_id \u2192 compute age = now_ms - snap.ts_ms \u2192 if age > max_book_age_ms emit RiskVote(REJECT, RISK_BOOK_STALE) else PASS. Always emit a metric for the measured age.",
  "decision_logic": {
    "approve": "Look up most recent book snapshot for market_id. Compute age in ms. Emit measured age as metric for every decision.",
    "reshape_required": "This bot does not reshape orders.",
    "reject": "Reject if age > max_book_age_ms.",
    "warning_only": "No warn-only path defined."
  },
  "decision_output_example": {
    "vote": "REJECT",
    "reason_code": "RISK_BOOK_STALE",
    "measured_age_ms": 3104,
    "explain": "Book age 3104ms > 2000ms threshold."
  },
  "developer_log": "Per decision: intent_id, market_id, book.ts_ms, decision_ts_ms, measured_age_ms, vote, reason_code.",
  "user_explanations": [
    {
      "situation": "When this bot acts",
      "message": "We did not place this order because the latest market data was too old to trust."
    }
  ],
  "failure_modes": {
    "main_failure_mode": "Rejecting orders when the book is fine but the freshness clock has drifted.",
    "false_positive_risk": "Clock skew between feed publisher and bot host; mitigation: bots use NTP-synced clocks and the data-flow layer stamps an ingest_ts_ms used as a fallback.",
    "false_negative_risk": "A frozen book with a recent ts_ms (publisher bug) passes the check; mitigation: combine with MarketHaltDetector's TRADE_SILENCE rule.",
    "safe_fallback": "If no snapshot exists at all, REJECT \u2014 never assume freshness."
  },
  "acceptance_tests": {
    "unit": [
      {
        "test": "Pass when age = max_book_age_ms - 1.",
        "setup": "Synthetic fixture per template.",
        "expected": "Behaviour matches the rule described in the test name."
      },
      {
        "test": "Reject when age = max_book_age_ms + 1.",
        "setup": "Synthetic fixture per template.",
        "expected": "Behaviour matches the rule described in the test name."
      },
      {
        "test": "Reject when no snapshot is present.",
        "setup": "Synthetic fixture per template.",
        "expected": "Behaviour matches the rule described in the test name."
      }
    ],
    "integration": [
      {
        "test": "End-to-end: synthetic CLOB stream paused for 4 seconds \u2192 all OrderIntents in the gap rejected.",
        "expected": "End-to-end behaviour matches the spec without manual intervention."
      }
    ],
    "property": [
      {
        "property": "For any (book.ts_ms, now_ms) pair, vote is determined purely by their difference and the threshold.",
        "required": "Always true across all generated inputs."
      }
    ]
  },
  "reference_implementation": {
    "language": "pseudocode",
    "pseudocode": "snap = latest_snapshot(intent.market_id)\nif snap is None: return reject('RISK_BOOK_STALE')\nage = now_ms() - snap.ts_ms\nif age > p.max_book_age_ms: return reject('RISK_BOOK_STALE', measured_age_ms=age)\nif age > p.warn_book_age_ms: log_warn('BOOK_AGE_HIGH', age=age)\nreturn pass_()"
  },
  "wire_examples": {
    "input": {
      "intent_id": "intent_002",
      "market_id": "0xabc"
    },
    "output": {
      "vote": "REJECT",
      "reason_code": "RISK_BOOK_STALE",
      "measured_age_ms": 3104
    }
  },
  "reason_codes": [
    {
      "code": "RISK_BOOK_STALE",
      "severity": "P1",
      "meaning": "Risk Book Stale",
      "action": "See decision output and developer log for context.",
      "user_message": "We did not place this order because the latest market data was too old to trust."
    },
    {
      "code": "RISK_BOOK_STALE_WARN",
      "severity": "P1",
      "meaning": "Risk Book Stale Warn",
      "action": "See decision output and developer log for context.",
      "user_message": "We did not place this order because the latest market data was too old to trust."
    }
  ],
  "metrics": {
    "emitted": [
      {
        "name": "book_age_ms_histogram",
        "type": "counter",
        "unit": "event",
        "labels": [
          "bot_id"
        ],
        "meaning": "Book age ms histogram."
      },
      {
        "name": "rejects_with_book_stale",
        "type": "counter",
        "unit": "event",
        "labels": [
          "market_id",
          "reason_code"
        ],
        "meaning": "Rejects with book stale."
      },
      {
        "name": "warns_with_book_age_high",
        "type": "counter",
        "unit": "event",
        "labels": [
          "bot_id"
        ],
        "meaning": "Warns with book age high."
      }
    ],
    "alerts": [],
    "dashboards": [
      "1.18 overview dashboard"
    ]
  },
  "state": {
    "summary": "Stateless. Reads latest snapshot from the data-flow layer's in-memory cache.",
    "stores": [
      {
        "name": "stale_book_guard_state",
        "kind": "in-memory + fast KV mirror",
        "key": "bot_id",
        "value": "Stateless. Reads latest snapshot from the data-flow layer's in-memory cache.",
        "ttl": "24h",
        "durability": "crash-safe via KV mirror"
      }
    ],
    "recovery": "Cold-start hydrates from fast KV; missing keys default to safe fallback.",
    "on_restart": "All in-flight decisions are re-evaluated; no bot decision is trusted across restart without re-emit."
  },
  "concurrency": {
    "execution_model": "Pure function of (snapshot, now_ms). Trivially safe under concurrent calls.",
    "max_in_flight": 32,
    "idempotency_key": "order_intent_id",
    "replay_safe": true,
    "deduplication": "By idempotency_key within a 60s window.",
    "ordering_guarantees": "Per-market_id FIFO; cross-market unordered.",
    "timeout_ms": 250,
    "backpressure": "Bounded queue; oldest-dropped with metric increment when full.",
    "locking": "Per-market_id mutex; no global locks."
  },
  "dependencies": {
    "depends_on": [
      "intel.orderflowanalyzer"
    ],
    "emits_to": [
      "exec.smart_router"
    ]
  },
  "graph": {
    "requires": [
      "intel.orderflowanalyzer"
    ],
    "required_before": [
      "exec.smart_router"
    ],
    "consumes": [
      "OrderIntent",
      "OrderBookSnapshot"
    ],
    "emits": [
      "RiskVote"
    ],
    "blocks": true
  },
  "mode_support": [
    "off",
    "shadow",
    "advisory",
    "enforced",
    "quarantine"
  ],
  "latency_budget_ms": {
    "p50": 1,
    "p99": 5
  },
  "data_freshness": {
    "max_market_data_age_ms": 2000,
    "max_orderbook_age_ms": 2000,
    "on_stale_data": "REJECT \u2014 that is precisely what this bot does."
  },
  "ownership": {
    "owner": "Risk pod",
    "on_call": "risk-oncall",
    "channel": "#polytraders-risk",
    "escalation": "Head of Risk",
    "severity_class": "P2"
  },
  "human_override": {
    "allowed": false,
    "who": "\u2014",
    "log_event": "\u2014",
    "time_bound": "\u2014",
    "scope": "\u2014",
    "second_approval": false
  },
  "security_surfaces": {
    "summary": "No external surface. Reads internal book cache only.",
    "signing": "None \u2014 bot does not sign or submit.",
    "secrets": [],
    "contract_calls": [],
    "abuse_vectors": [],
    "mitigations": [
      "Rate-limit per source",
      "Audit-log every override",
      "Require role-based authz on admin paths"
    ]
  },
  "polymarket_v2_compat": {
    "clob_version": "V2",
    "collateral": "pUSD",
    "eip712_domain_version": "2",
    "builder_code_aware": true,
    "negrisk_aware": true,
    "multichain_ready": true,
    "sdk_used": "Polymarket CLOB V2 SDK",
    "settlement_contract": "CTFExchangeV2",
    "notes": "OrderBookSnapshot.ts_ms field is a CLOB V2 standard property."
  },
  "version": {
    "current": "0.1.0",
    "contract_version": "1.0.0",
    "last_breaking_change": "none",
    "deprecation_window_days": 30
  },
  "migration_history": [],
  "runbook": {
    "summary": "If reject rate spikes: first check feed health (ingest latency p99). If feed is healthy and rejects persist, suspect publisher clock drift.",
    "oncall_actions": [
      {
        "alert": "1.18_anomaly",
        "first_step": "Open the bot's reporting page and confirm the alert is real (not a metric hiccup).",
        "diagnosis": "Inspect developer log entries for the affected market_id over the last 30 minutes.",
        "mitigation": "Force-clear via Admin UI if the rule is clearly stale; otherwise leave engaged and notify owner.",
        "escalation": "Risk pod"
      }
    ],
    "manual_overrides": [
      {
        "command": "polytraders bot pause 1.18",
        "effect": "Disables the bot's enforcement layer; downstream consumers fall back to safe defaults."
      }
    ],
    "healthcheck": "GET /healthz/stale_book_guard \u2192 200 if last successful evaluation < 60s ago."
  },
  "promotion_gates": {
    "to_shadow": [
      {
        "gate": "Stub",
        "how_measured": "100% deterministic on fixtures.",
        "threshold": "Documented threshold met for the full window."
      }
    ],
    "to_limited_live": [
      {
        "gate": "Shadow",
        "how_measured": "7 days; rejection rate < 0.5% on healthy feeds.",
        "threshold": "Documented threshold met for the full window."
      },
      {
        "gate": "Enforced",
        "how_measured": "Risk Lead sign-off.",
        "threshold": "Documented threshold met for the full window."
      }
    ],
    "to_general_live": [
      {
        "gate": "Owner sign-off",
        "how_measured": "Bot owner reviews 14 days of advisory data.",
        "threshold": "No P1 incidents attributable to this bot."
      }
    ]
  },
  "failure_injection": [
    {
      "scenario": "Pause the synthetic feed for 4 seconds and assert all in-flight intents are reje",
      "how_to_inject": "Pause the synthetic feed for 4 seconds and assert all in-flight intents are rejected.",
      "expected_behavior": "Bot detects within its latency budget and emits the corresponding reason code.",
      "recovery": "Remove the injected fault; bot returns to healthy state within one debounce window."
    },
    {
      "scenario": "Inject a snapshot with future ts_ms and assert decision is still PASS (negative ",
      "how_to_inject": "Inject a snapshot with future ts_ms and assert decision is still PASS (negative age).",
      "expected_behavior": "Bot detects within its latency budget and emits the corresponding reason code.",
      "recovery": "Remove the injected fault; bot returns to healthy state within one debounce window."
    }
  ],
  "capital_impact": "Direct",
  "v3_status": {
    "phase": 2,
    "phase_name": "Data normalisation",
    "docs": {
      "done": 27,
      "total": 27,
      "state": "done"
    },
    "impl": {
      "done": 0,
      "total": 15,
      "state": "pending"
    },
    "runtime": {
      "done": 0,
      "total": 8,
      "state": "pending"
    },
    "overall": "pending"
  }
}