{
  "schema_version": "1.0.0",
  "bot_id": "4.17",
  "bot_name": "MarketResolutionWatcher",
  "slug": "market_resolution_watcher",
  "layer": "Intelligence",
  "layer_key": "intel",
  "bot_class": "Signal Service",
  "authority": [
    "Observe"
  ],
  "status": "planned",
  "readiness": "Spec ready",
  "flagship": false,
  "is_reference": false,
  "public_export": false,
  "identity": {
    "layer": "Intelligence",
    "bot_class": "Signal",
    "authority": "Observe",
    "runs_before": "risk.pre_resolution_freeze",
    "runs_after": "\u2014",
    "applies_to": "Continuous",
    "default_mode": "shadow",
    "user_visible": "No",
    "developer_owner": "Intelligence pod"
  },
  "purpose": "Watches the on-chain resolution state of every active Polymarket market. As a market approaches resolution, it emits typed warnings (T-24h, T-1h, finalising, resolved) so downstream bots can stop opening new positions and start unwinding. Pure observer \u2014 never blocks; the actual blocking is done by Risk's PreResolutionFreeze guardrail downstream.",
  "why_it_matters": [
    {
      "failure": "Trading into resolution",
      "consequence": "Once a market is in its finalising window, prices can become extremely sticky and orders can sit unfilled until expiry; opening fresh exposure here is high regret."
    },
    {
      "failure": "Missing the unwind window",
      "consequence": "Without an explicit signal, strategies do not know when to switch from open-mode to unwind-mode."
    },
    {
      "failure": "Resolution-driven losses",
      "consequence": "Bots that hold a position through a resolution event with the wrong outcome lose 100% of that position; the unwind window must be respected."
    }
  ],
  "polymarket_inputs": [
    {
      "input": "Polymarket conditional resolution metadata",
      "source": "Polymarket REST + on-chain events",
      "required": true,
      "use": "Source of truth for resolution time and status."
    },
    {
      "input": "Per-market clock / now_ms",
      "source": "Runtime",
      "required": true,
      "use": "Distance to scheduled resolution."
    }
  ],
  "internal_inputs": [
    {
      "input": "Open positions per market",
      "source": "PortfolioGuard",
      "required": false,
      "use": "Decides whether to escalate the warning severity."
    }
  ],
  "raw_params": [
    "t_minus_warn_hours \u00b7 1\u2013168",
    "t_minus_freeze_hours \u00b7 0\u201324"
  ],
  "parameters": [
    {
      "name": "t_minus_warn_hours",
      "default": 24,
      "warning": "24",
      "hard": "6",
      "controls": "How far ahead of scheduled resolution to start emitting WARN signals.",
      "why_default_matters": "24 hours gives strategies a full trading day to plan an unwind.",
      "threshold_logic": [
        {
          "condition": "> 24h",
          "action": "Silent"
        },
        {
          "condition": "\u2264 24h",
          "action": "WARN signal"
        },
        {
          "condition": "\u2264 1h",
          "action": "URGENT signal"
        }
      ],
      "dev_check": "if (hoursToResolve <= p.t_minus_warn_hours) emit('WARN', hoursToResolve);",
      "user_facing": "(Internal \u2014 not shown to users.)"
    },
    {
      "name": "t_minus_freeze_hours",
      "default": 1,
      "warning": "\u2014",
      "hard": "\u2014",
      "controls": "How close to resolution before the FREEZE signal is emitted (consumed by Risk's PreResolutionFreeze guardrail).",
      "why_default_matters": "1 hour is short enough to allow last-minute unwind but long enough to avoid spurious finalising-state flapping.",
      "threshold_logic": [
        {
          "condition": "> 1h",
          "action": "WARN only"
        },
        {
          "condition": "\u2264 1h",
          "action": "FREEZE signal"
        }
      ],
      "dev_check": "if (hoursToResolve <= p.t_minus_freeze_hours) emit('FREEZE', hoursToResolve);",
      "user_facing": "(Internal \u2014 not shown to users.)"
    }
  ],
  "default_config": {
    "t_minus_warn_hours": 24,
    "t_minus_freeze_hours": 1
  },
  "flow": "Poll Polymarket resolution metadata for every active market_id at a slow timer (every minute) and watch on-chain ConditionResolved events at high priority \u2192 for each market, compute hours_to_resolve \u2192 emit signals at the configured tiers (WARN at T-24h, URGENT at T-6h, FREEZE at T-1h, RESOLVED on finalisation).",
  "decision_logic": {
    "approve": "Compute hours_to_resolve = max(0, scheduled_ts_ms - now_ms) / 3.6e6. Emit at most one signal per (market_id, tier) per polling cycle. Latch RESOLVED forever once the on-chain event arrives.",
    "reshape_required": "This bot does not reshape orders.",
    "reject": "No reject path defined for this bot \u2014 it is observe-only.",
    "warning_only": "No warn-only path defined."
  },
  "decision_output_example": {
    "kind": "resolution_warning",
    "market_id": "0xabc",
    "tier": "FREEZE",
    "hours_to_resolve": 0.42,
    "ts_ms": 1715260000000
  },
  "developer_log": "Per emission: market_id, tier, hours_to_resolve, scheduled_ts_ms, on_chain_event (if any).",
  "user_explanations": [
    {
      "situation": "When this bot acts",
      "message": "This market is close to resolving \u2014 the system stopped opening new positions on it."
    }
  ],
  "failure_modes": {
    "main_failure_mode": "Misreading resolution time from Polymarket metadata.",
    "false_positive_risk": "Polymarket metadata edits can shift scheduled_ts_ms forward; mitigation: emit the latest-known schedule rather than caching.",
    "false_negative_risk": "Missed on-chain ConditionResolved event; mitigation: belt-and-braces \u2014 both REST poll and on-chain log subscription.",
    "safe_fallback": "On metadata fetch failure, emit FREEZE for any market within 24h of its last-known schedule \u2014 fail closed."
  },
  "acceptance_tests": {
    "unit": [
      {
        "test": "At T-23h emits WARN.",
        "setup": "Synthetic fixture per template.",
        "expected": "Behaviour matches the rule described in the test name."
      },
      {
        "test": "At T-30m emits FREEZE.",
        "setup": "Synthetic fixture per template.",
        "expected": "Behaviour matches the rule described in the test name."
      },
      {
        "test": "On ConditionResolved emits RESOLVED.",
        "setup": "Synthetic fixture per template.",
        "expected": "Behaviour matches the rule described in the test name."
      }
    ],
    "integration": [
      {
        "test": "Replay a Polymarket fixture market through its 48-hour run-up to resolution; assert tiered signals are emitted in order.",
        "expected": "End-to-end behaviour matches the spec without manual intervention."
      }
    ],
    "property": [
      {
        "property": "Tier transitions are monotonic per market: SILENT \u2192 WARN \u2192 URGENT \u2192 FREEZE \u2192 RESOLVED.",
        "required": "Always true across all generated inputs."
      }
    ]
  },
  "reference_implementation": {
    "language": "pseudocode",
    "pseudocode": "for each market in active_markets():\n  meta = polymarket.metadata(market.id)\n  hours = (meta.scheduled_ts_ms - now_ms())/3_600_000\n  tier = pick_tier(hours, p)\n  emit('resolution_warning', market.id, tier, hours)\nsubscribe('ConditionResolved', lambda e: emit('resolution_warning', e.market_id, 'RESOLVED', 0))"
  },
  "wire_examples": {
    "input": {
      "market_id": "0xabc",
      "scheduled_ts_ms": 1715263600000,
      "now_ms": 1715260000000
    },
    "output": {
      "kind": "resolution_warning",
      "tier": "FREEZE",
      "hours_to_resolve": 0.42
    }
  },
  "reason_codes": [
    {
      "code": "INTEL_RESOLUTION_WARN",
      "severity": "info",
      "meaning": "Intel Resolution Warn",
      "action": "See decision output and developer log for context.",
      "user_message": "This market is close to resolving \u2014 the system stopped opening new positions on it."
    },
    {
      "code": "INTEL_RESOLUTION_URGENT",
      "severity": "info",
      "meaning": "Intel Resolution Urgent",
      "action": "See decision output and developer log for context.",
      "user_message": "This market is close to resolving \u2014 the system stopped opening new positions on it."
    },
    {
      "code": "INTEL_RESOLUTION_FREEZE",
      "severity": "info",
      "meaning": "Intel Resolution Freeze",
      "action": "See decision output and developer log for context.",
      "user_message": "This market is close to resolving \u2014 the system stopped opening new positions on it."
    },
    {
      "code": "INTEL_RESOLUTION_RESOLVED",
      "severity": "info",
      "meaning": "Intel Resolution Resolved",
      "action": "See decision output and developer log for context.",
      "user_message": "This market is close to resolving \u2014 the system stopped opening new positions on it."
    }
  ],
  "metrics": {
    "emitted": [
      {
        "name": "resolution_signals_total",
        "type": "counter",
        "unit": "event",
        "labels": [
          "bot_id"
        ],
        "meaning": "Resolution signals total."
      },
      {
        "name": "active_markets_in_freeze_window",
        "type": "counter",
        "unit": "event",
        "labels": [
          "bot_id"
        ],
        "meaning": "Active markets in freeze window."
      },
      {
        "name": "resolution_metadata_fetch_failures",
        "type": "counter",
        "unit": "event",
        "labels": [
          "bot_id"
        ],
        "meaning": "Resolution metadata fetch failures."
      }
    ],
    "alerts": [],
    "dashboards": [
      "4.17 overview dashboard"
    ]
  },
  "state": {
    "summary": "Per-market last-emitted tier (latched). Persisted to durable KV.",
    "stores": [
      {
        "name": "market_resolution_watcher_state",
        "kind": "in-memory + fast KV mirror",
        "key": "market_id",
        "value": "Per-market last-emitted tier (latched). Persisted to durable KV.",
        "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": "Single watcher process. Idempotent re-emission per tier.",
    "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": [],
    "emits_to": [
      "risk.pre_resolution_freeze"
    ]
  },
  "graph": {
    "requires": [],
    "required_before": [
      "risk.pre_resolution_freeze"
    ],
    "consumes": [
      "PolymarketMetadata",
      "ConditionResolvedEvent"
    ],
    "emits": [
      "SignalEnvelope(kind=resolution_warning)"
    ],
    "blocks": false
  },
  "mode_support": [
    "off",
    "shadow",
    "advisory",
    "enforced"
  ],
  "latency_budget_ms": {
    "p50": 200,
    "p99": 1500
  },
  "data_freshness": {
    "max_market_data_age_ms": 60000,
    "max_orderbook_age_ms": 60000,
    "max_external_feed_age_ms": 60000,
    "on_stale_data": "Emit FREEZE if any market is within 24h of last-known scheduled_ts_ms."
  },
  "ownership": {
    "owner": "Intelligence pod",
    "on_call": "intel-oncall",
    "channel": "#polytraders-intel",
    "escalation": "Head of Intelligence",
    "severity_class": "P1"
  },
  "human_override": {
    "allowed": true,
    "who": "Intel on-call",
    "log_event": "INTEL_RESOLUTION_OVERRIDE",
    "time_bound": "Until next poll",
    "scope": "Single market_id",
    "second_approval": false
  },
  "security_surfaces": {
    "summary": "Reads Polymarket REST + Ethereum logs. No write surface.",
    "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": "ConditionResolved event ABI is identical between V1 and V2."
  },
  "version": {
    "current": "0.1.0",
    "contract_version": "1.0.0",
    "last_breaking_change": "none",
    "deprecation_window_days": 30
  },
  "migration_history": [],
  "runbook": {
    "summary": "If signals stop emitting: check Polymarket REST credentials and chain RPC health.",
    "oncall_actions": [
      {
        "alert": "4.17_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": "Intelligence pod"
      }
    ],
    "manual_overrides": [
      {
        "command": "polytraders bot pause 4.17",
        "effect": "Disables the bot's enforcement layer; downstream consumers fall back to safe defaults."
      }
    ],
    "healthcheck": "GET /healthz/market_resolution_watcher \u2192 200 if last successful evaluation < 60s ago."
  },
  "promotion_gates": {
    "to_shadow": [
      {
        "gate": "Stub",
        "how_measured": "against fixture metadata.",
        "threshold": "Documented threshold met for the full window."
      }
    ],
    "to_limited_live": [
      {
        "gate": "Shadow",
        "how_measured": "14 days; signals logged.",
        "threshold": "Documented threshold met for the full window."
      },
      {
        "gate": "Advisory",
        "how_measured": "7 days.",
        "threshold": "Documented threshold met for the full window."
      }
    ],
    "to_general_live": [
      {
        "gate": "Enforced",
        "how_measured": "signals consumed by Risk's PreResolutionFreeze.",
        "threshold": "Documented threshold met for the full window."
      }
    ]
  },
  "failure_injection": [
    {
      "scenario": "Inject metadata that changes scheduled_ts_ms backwards by 6 hours and assert dow",
      "how_to_inject": "Inject metadata that changes scheduled_ts_ms backwards by 6 hours and assert downstream tier shifts.",
      "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": "Drop the REST call for 5 minutes and assert FREEZE-on-stale fallback fires",
      "how_to_inject": "Drop the REST call for 5 minutes and assert FREEZE-on-stale fallback fires.",
      "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": "Indirect",
  "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"
  }
}