{
  "schema_version": "1.0.0",
  "bot_id": "1.15",
  "bot_name": "SettlementExposureGuard",
  "slug": "settlementexposureguard",
  "layer": "Risk",
  "layer_key": "risk",
  "bot_class": "Guardrail",
  "authority": [
    "Veto",
    "Reshape"
  ],
  "status": "planned",
  "readiness": "Planned",
  "flagship": false,
  "is_reference": false,
  "public_export": false,
  "identity": {
    "layer": "Risk",
    "bot_class": "Guardrail",
    "authority": "Veto, Reshape",
    "runs_before": "ExecutionPlan emit",
    "runs_after": "Strategy OrderIntent",
    "applies_to": "Every OrderIntent \u2014 caps simultaneous settlement resolution risk by limiting how much equity is locked in markets that could resolve in the same UMA oracle window",
    "default_mode": "planned",
    "user_visible": "summary-only",
    "developer_owner": "Polytraders core \u2014 Risk pod"
  },
  "purpose": "SettlementExposureGuard tracks how much pUSD is committed in markets that share the same UMA resolution window and blocks or downsizes new orders that would push the concurrent settlement exposure above the configured ceiling. It prevents the user from having more equity at risk in a single 2-hour UMA settlement window than they can afford to lose if all markets in that window resolve adversely.",
  "why_it_matters": [
    {
      "failure": "Multiple markets resolving in same UMA window",
      "consequence": "Markets resolving in the same 2-hour UMA window create a concentration of settlement risk; an adverse outcome across all of them results in simultaneous losses the user did not anticipate.",
      "worked_example": {
        "setup": "Strategy holds 9,000 pUSD on YES at 0.78 in market 0x12e (resolves in 14h). It now wants to add 4,000 pUSD on YES at the same market.",
        "without_bot": "PortfolioGuard checks current notional only and approves the add. At resolution, if YES settles, the position pays out; if NO, the loss is the full 9,000 + 4,000 \u00d7 (1 \u2212 0.78) = 11,880 pUSD. The team learns at resolution that single-market settlement exposure exceeded the monthly budget.",
        "with_bot": "SettlementExposureGuard projects the worst-case post-resolution loss for the proposed position, finds it crosses the per-market hard cap of 10,000 pUSD, and votes RESHAPE_REQUIRED to clamp the new add to 2,500 pUSD."
      }
    },
    {
      "failure": "No cap on concurrent settlement exposure",
      "consequence": "Without a settlement window cap, strategies can inadvertently concentrate the majority of the portfolio in a single 2-hour settlement batch."
    }
  ],
  "polymarket_inputs": [
    {
      "input": "Market resolution end_date and UMA challenge window metadata",
      "source": "gamma",
      "required": true,
      "use": "Group open positions by their UMA resolution window (2-hour buckets) to compute concurrent settlement exposure."
    },
    {
      "input": "Open position notional by market",
      "source": "clob_public",
      "required": true,
      "use": "Sum the pUSD notional for all positions in each UMA window bucket."
    }
  ],
  "internal_inputs": [
    {
      "input": "Settlement window exposure config (max concurrent pUSD)",
      "source": "internal",
      "required": true,
      "use": "Load the per-window exposure ceiling."
    },
    {
      "input": "KillSwitch active flag",
      "source": "KillSwitch",
      "required": true,
      "use": "If active, reject immediately."
    }
  ],
  "raw_params": [
    "max_concurrent_settlement_usd \u00b7 int",
    "uma_window_hours \u00b7 float",
    "warn_pct \u00b7 float"
  ],
  "parameters": [
    {
      "name": "max_concurrent_settlement_usd",
      "default": 3000,
      "warning": 2400,
      "hard": 3000,
      "controls": "Maximum pUSD allowed to resolve in a single UMA settlement window (default 2 hours).",
      "why_default_matters": "3000 pUSD per window ensures that a worst-case adverse resolution of all markets in the window cannot exceed a manageable loss fraction of a typical 10 000 pUSD portfolio.",
      "threshold_logic": [
        {
          "condition": "window_exposure + intent.size_usd <= 2400",
          "action": "APPROVE"
        },
        {
          "condition": "2400 < total <= 3000",
          "action": "WARN \u2014 SETTLEMENT_EXPOSURE_APPROACHING"
        },
        {
          "condition": "total > 3000",
          "action": "RESHAPE or HARD_REJECT \u2014 SETTLEMENT_EXPOSURE_EXCEEDED"
        }
      ],
      "dev_check": "if (windowExposure + intent.size_usd > params.max_concurrent_settlement_usd) return reshape_or_reject('SETTLEMENT_EXPOSURE_EXCEEDED');",
      "user_facing": "Your exposure in this settlement window has reached the limit."
    },
    {
      "name": "uma_window_hours",
      "default": 2.0,
      "warning": null,
      "hard": null,
      "controls": "Duration in hours of the UMA settlement window used for grouping positions. Matches the UMA optimistic oracle 2-hour challenge period.",
      "why_default_matters": "2 hours is the canonical UMA challenge window; grouping by this interval correctly identifies markets that will compete for the same resolution event slot.",
      "threshold_logic": [
        {
          "condition": "always",
          "action": "Group positions in 2-hour UMA buckets"
        }
      ],
      "dev_check": "const bucketKey = Math.floor(market.end_date_ms / (params.uma_window_hours * 3600000));",
      "user_facing": ""
    },
    {
      "name": "warn_pct",
      "default": 0.8,
      "warning": null,
      "hard": null,
      "controls": "Fraction of max_concurrent_settlement_usd at which a WARN annotation is emitted without blocking.",
      "why_default_matters": "80% utilisation gives early warning before the hard ceiling is reached.",
      "threshold_logic": [
        {
          "condition": "window_exposure / max >= warn_pct",
          "action": "Attach WARN annotation"
        },
        {
          "condition": "window_exposure / max < warn_pct",
          "action": "No annotation"
        }
      ],
      "dev_check": "if (windowExposure / params.max_concurrent_settlement_usd > params.warn_pct) annotations.push(WARN);",
      "user_facing": ""
    }
  ],
  "default_config": {
    "bot_id": "risk.settlement_exposure_guard",
    "version": "0.1.0",
    "mode": "hard_guard",
    "defaults": {
      "max_concurrent_settlement_usd": 3000,
      "uma_window_hours": 2.0,
      "warn_pct": 0.8
    },
    "locked": {
      "max_concurrent_settlement_usd": {
        "min": 100
      },
      "uma_window_hours": {
        "min": 2.0
      }
    }
  },
  "implementation_flow": [
    "Receive OrderIntent with market_id, size_usd.",
    "Check KillSwitch; if active, HARD_REJECT(KILL_SWITCH_ACTIVE).",
    "Fetch target market end_date from Gamma to determine its UMA settlement window bucket.",
    "Fetch all open positions; group by UMA window bucket (floor(end_date_ms / window_ms)).",
    "Compute window_exposure = sum of notional for all positions in the same bucket as the target market.",
    "If window_exposure + intent.size_usd > max_concurrent_settlement_usd: compute safe_size.",
    "If safe_size > 0: RESHAPE(max_size_usd=safe_size). Else HARD_REJECT(SETTLEMENT_EXPOSURE_EXCEEDED).",
    "If window_exposure / max > warn_pct: attach WARN annotation.",
    "APPROVE with window_exposure and bucket_key attached."
  ],
  "decision_logic": {
    "approve": "Adding the proposed order to the UMA window bucket does not exceed max_concurrent_settlement_usd.",
    "reshape_required": "The bucket would exceed the ceiling with the full order but a reduced size fits.",
    "reject": "The bucket is fully utilised even at minimum size, or data is unavailable."
  },
  "decision_output_schema": "RiskVote",
  "decision_output_example": {
    "guard_id": "risk.settlement_exposure_guard",
    "decision": "RESHAPE_REQUIRED",
    "severity": "WARN",
    "reason_code": "SETTLEMENT_EXPOSURE_EXCEEDED",
    "message": "UMA window bucket has 2800 pUSD exposure; adding 400 pUSD exceeds 3000 ceiling. Resized to 200 pUSD.",
    "constraints": {
      "max_size_usd": 200
    },
    "inputs_used": [
      "gamma.market.end_date",
      "clob_public.positions",
      "internal.config"
    ],
    "checked_at": "2026-05-10T14:00:00Z"
  },
  "developer_log": {
    "bot_id": "risk.settlement_exposure_guard",
    "decision": "RESHAPE_REQUIRED",
    "reason_code": "SETTLEMENT_EXPOSURE_EXCEEDED",
    "inputs_used": [
      "gamma.market.end_date",
      "clob_public.positions"
    ],
    "metrics": {
      "bucket_key": "1746799200",
      "window_exposure_usd": 2800,
      "intent_size_usd": 400,
      "ceiling_usd": 3000,
      "safe_size_usd": 200
    },
    "checked_at": "2026-05-10T14:00:00Z"
  },
  "user_explanations": [
    {
      "situation": "Order downsized \u2014 settlement window cap",
      "message": "Your order was reduced because your exposure in this settlement window has reached its limit. Adding more would concentrate too much risk in a single resolution event."
    },
    {
      "situation": "Order blocked \u2014 window fully utilised",
      "message": "You have reached the maximum exposure in this settlement window. Please wait for some of those markets to resolve before placing new orders."
    }
  ],
  "failure_modes": {
    "main_failure_mode": "Approving an order because the position cache is stale and does not include recently placed orders in the same UMA window, underestimating window exposure.",
    "false_positive_risk": "Blocking an order because a position in the same window has already been closed but the cache has not been refreshed.",
    "false_negative_risk": "Two concurrent intents approved simultaneously before either updates the position cache, both contributing to the same window bucket.",
    "safe_fallback": "If Gamma market metadata or position data is unavailable, HARD_REJECT with SETTLEMENT_EXPOSURE_DATA_UNAVAILABLE. Never approve on missing data.",
    "required_dependencies": [
      "Gamma API (market end_date)",
      "CLOB position data",
      "Settlement exposure config",
      "KillSwitch active flag"
    ]
  },
  "acceptance_tests": {
    "unit": [
      {
        "test": "Approve when window exposure within warning threshold",
        "setup": "window_exposure=2000, intent.size=300, ceiling=3000",
        "expected": "APPROVE"
      },
      {
        "test": "Reshape when window would exceed ceiling",
        "setup": "window_exposure=2800, intent.size=400, ceiling=3000",
        "expected": "RESHAPE_REQUIRED(max_size_usd=200)"
      },
      {
        "test": "Reject when window fully utilised",
        "setup": "window_exposure=3000, intent.size=10, ceiling=3000",
        "expected": "HARD_REJECT(SETTLEMENT_EXPOSURE_EXCEEDED)"
      },
      {
        "test": "Warn when window exposure above warn_pct",
        "setup": "window_exposure=2500, max=3000, warn_pct=0.8",
        "expected": "APPROVE with WARN annotation"
      }
    ],
    "integration": [
      {
        "test": "Multiple positions in same UMA bucket correctly summed",
        "expected": "Window exposure reflects all open positions with matching bucket_key"
      },
      {
        "test": "KillSwitch bypasses settlement check",
        "expected": "HARD_REJECT(KILL_SWITCH_ACTIVE) without reading Gamma or positions"
      }
    ],
    "property": [
      {
        "property": "Total window exposure never exceeds max_concurrent_settlement_usd after APPROVE",
        "required": "Always true"
      },
      {
        "property": "Reshape size is strictly <= original intent size_usd",
        "required": "Always true"
      }
    ]
  },
  "checklist_overrides": {},
  "legacy_goal": "Cap simultaneous resolution risk \u2014 how much equity is locked in markets that could settle in the same UMA window.",
  "legacy_pm_signals": [
    "Open exposure aggregated by resolution date / window",
    "UMA queue density and dispute frequency by source",
    "Neg-risk-event simultaneous-settlement clusters"
  ],
  "legacy_external_feeds": [],
  "reporting_groups": [
    "risk_compliance"
  ],
  "reason_codes": [
    {
      "code": "KILL_SWITCH_ACTIVE",
      "severity": "HARD_REJECT",
      "meaning": "Global kill switch active.",
      "action": "Immediate HARD_REJECT.",
      "user_message": "Trading is paused. Please try again later."
    },
    {
      "code": "SETTLEMENT_EXPOSURE_EXCEEDED",
      "severity": "RESHAPE",
      "meaning": "Adding the intent to the UMA window bucket would exceed the ceiling.",
      "action": "RESHAPE if safe_size > 0; else HARD_REJECT.",
      "user_message": "Your exposure in this settlement window has reached the limit."
    },
    {
      "code": "SETTLEMENT_EXPOSURE_APPROACHING",
      "severity": "WARN",
      "meaning": "Window exposure is above warn_pct of the ceiling.",
      "action": "Attach WARN annotation; APPROVE.",
      "user_message": ""
    },
    {
      "code": "SETTLEMENT_EXPOSURE_DATA_UNAVAILABLE",
      "severity": "HARD_REJECT",
      "meaning": "Gamma market metadata or position data unavailable.",
      "action": "HARD_REJECT (fail-closed).",
      "user_message": "We could not verify settlement window data. Please try again."
    }
  ],
  "metrics": {
    "emitted": [
      {
        "name": "polytraders_risk_settlementexposureguard_decisions_total",
        "type": "counter",
        "unit": "count",
        "labels": [
          "decision",
          "reason_code"
        ],
        "meaning": "Total decisions by type."
      },
      {
        "name": "polytraders_risk_settlementexposureguard_window_exposure_usd",
        "type": "gauge",
        "unit": "usd",
        "labels": [
          "bucket_key"
        ],
        "meaning": "Current pUSD exposure per UMA settlement window bucket."
      },
      {
        "name": "polytraders_risk_settlementexposureguard_eval_latency_ms",
        "type": "histogram",
        "unit": "milliseconds",
        "labels": [],
        "meaning": "Latency from intent to RiskVote emit."
      }
    ],
    "alerts": [
      {
        "name": "SettlementExposureGuardWindowNearCap",
        "condition": "polytraders_risk_settlementexposureguard_window_exposure_usd / 3000 > 0.9",
        "severity": "P2",
        "runbook": "#runbook-settlement-near-cap"
      },
      {
        "name": "SettlementExposureGuardDataUnavailable",
        "condition": "rate(polytraders_risk_settlementexposureguard_decisions_total{reason_code='SETTLEMENT_EXPOSURE_DATA_UNAVAILABLE'}[5m]) > 0",
        "severity": "P1",
        "runbook": "#runbook-settlement-data"
      }
    ]
  },
  "state": {
    "store": "in-memory + redis",
    "shape": "Per-bucket exposure map keyed by (user_id, bucket_key); Gamma end_date cache per market_id.",
    "ttl": "Position cache: 15s; Gamma end_date cache: 300s.",
    "recovery": "Position cache rebuilt from CLOB on cold start. Gamma cache populated on first market lookup.",
    "size_estimate": "~500 B per bucket; ~200 B per Gamma market entry"
  },
  "concurrency": {
    "execution_model": "single-threaded event loop",
    "max_in_flight": 200,
    "idempotency_key": "intent_id",
    "timeout_ms": 100,
    "backpressure": "drop newest",
    "locking": "per-bucket optimistic lock to prevent concurrent over-allocation in same window"
  },
  "dependencies": {
    "depends_on": [
      {
        "bot_id": "risk.kill_switch",
        "why": "Global brake checked first.",
        "contract": "HARD_REJECT(KILL_SWITCH_ACTIVE) short-circuits all evaluation."
      }
    ],
    "emits_to": [
      {
        "bot_id": "exec.smart_router",
        "why": "APPROVE or RESHAPE passes to SmartRouter.",
        "contract": "RESHAPE constraints respected by SmartRouter."
      }
    ],
    "sibling": [],
    "external": [
      {
        "service": "Gamma API (market end_date)",
        "endpoint": "https://gamma-api.polymarket.com",
        "sla": "99.9% / 300ms p99",
        "failure_mode": "HARD_REJECT(SETTLEMENT_EXPOSURE_DATA_UNAVAILABLE) if Gamma unavailable."
      },
      {
        "service": "CLOB API (positions)",
        "endpoint": "https://clob.polymarket.com",
        "sla": "99.95% / 200ms p99",
        "failure_mode": "HARD_REJECT(SETTLEMENT_EXPOSURE_DATA_UNAVAILABLE) if position data unavailable."
      }
    ]
  },
  "security_surfaces": {
    "signs_orders": false,
    "private_key_access": "none",
    "abuse_vectors": [
      "Submitting concurrent intents for the same UMA window bucket to bypass per-intent idempotency",
      "Selecting markets with staggered end_dates that fall just outside the window bucket to accumulate higher effective exposure"
    ],
    "mitigations": [
      "Per-bucket optimistic lock ensures at most one intent is processed at a time per bucket",
      "Market end_date is fetched from Gamma at evaluation time, not trusted from the intent payload"
    ]
  },
  "polymarket_v2_compat": {
    "clob_version": "v2",
    "collateral": "pUSD",
    "eip712_domain_version": "2",
    "builder_code_aware": false,
    "negrisk_aware": true,
    "multichain_ready": false,
    "sdk_used": "py-clob-client-v2",
    "settlement_contract": "CTFExchangeV2",
    "notes": "Settlement window grouping uses UMA optimistic oracle 2-hour challenge period per V2 protocol. NegRisk markets are included in window calculations using their Gamma end_date."
  },
  "version": {
    "spec": "2.0.0",
    "implementation": "0.1.0",
    "schema": "2",
    "released": null,
    "planned_release": "Q4-2026"
  },
  "migration_history": [
    {
      "date": "2026-04-28",
      "from": "n/a",
      "to": "v2-spec",
      "reason": "Spec drafted post-CLOB-V2 cutover; bot not yet implemented",
      "action_taken": "Designed against V2 schema (pUSD, builder codes, V2 EIP-712 domain)"
    }
  ],
  "reference_implementation": {
    "pseudocode": "FUNCTION evaluateSettlementExposure(intent):\n  ks = FETCH internal.killswitch.status\n  IF ks.active: EMIT RiskVote(HARD_REJECT, KILL_SWITCH_ACTIVE); RETURN\n\n  market = FETCH gamma.getMarket(intent.market_id)\n  IF market IS NULL:\n    EMIT RiskVote(HARD_REJECT, SETTLEMENT_EXPOSURE_DATA_UNAVAILABLE); RETURN\n\n  windowMs = params.uma_window_hours * 3600000\n  bucketKey = floor(market.end_date_ms / windowMs)\n\n  positions = FETCH clob_public.positions(intent.user_id)\n  IF positions IS NULL:\n    EMIT RiskVote(HARD_REJECT, SETTLEMENT_EXPOSURE_DATA_UNAVAILABLE); RETURN\n\n  windowExposure = 0\n  FOR pos IN positions:\n    posMarket = FETCH gamma.getMarket(pos.market_id)\n    IF floor(posMarket.end_date_ms / windowMs) == bucketKey:\n      windowExposure += pos.notional_usd\n\n  IF windowExposure + intent.size_usd > params.max_concurrent_settlement_usd:\n    safeSize = params.max_concurrent_settlement_usd - windowExposure\n    IF safeSize > 0:\n      EMIT RiskVote(RESHAPE_REQUIRED, SETTLEMENT_EXPOSURE_EXCEEDED,\n                    constraints={max_size_usd: safeSize}); RETURN\n    EMIT RiskVote(HARD_REJECT, SETTLEMENT_EXPOSURE_EXCEEDED); RETURN\n\n  utilisation = windowExposure / params.max_concurrent_settlement_usd\n  IF utilisation > params.warn_pct:\n    annotations.append(WARN(SETTLEMENT_EXPOSURE_APPROACHING))\n\n  EMIT RiskVote(APPROVE, window_exposure=windowExposure, bucket_key=bucketKey)",
    "sdk_calls": [
      "gamma.getMarket(market_id)",
      "clob_public.positions(user_id)",
      "internal.killswitch.status()"
    ],
    "complexity": "O(N) where N = open positions"
  },
  "wire_examples": {
    "input": [
      {
        "label": "OrderIntent \u2014 window cap reached",
        "source": "internal",
        "payload": {
          "intent_id": "int_a7b8c9d0e1f20007",
          "market_id": "0x5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f",
          "size_usd": 400,
          "generated_at_ms": 1746800000000
        }
      }
    ],
    "output": [
      {
        "label": "RiskVote \u2014 RESHAPE_REQUIRED",
        "payload": {
          "guard_id": "risk.settlement_exposure_guard",
          "decision": "RESHAPE_REQUIRED",
          "severity": "WARN",
          "reason_code": "SETTLEMENT_EXPOSURE_EXCEEDED",
          "message": "Window bucket exposure 2800 pUSD + 400 exceeds 3000. Resized to 200.",
          "constraints": {
            "max_size_usd": 200
          },
          "checked_at": "2026-05-10T14:00:00Z"
        }
      }
    ]
  },
  "failure_injection": [
    {
      "scenario": "GAMMA_UNAVAILABLE",
      "how_to_inject": "Return 503 from Gamma API",
      "expected_behaviour": "HARD_REJECT(SETTLEMENT_EXPOSURE_DATA_UNAVAILABLE)",
      "recovery": "Returns to normal after Gamma API is reachable."
    },
    {
      "scenario": "WINDOW_CAP_REACHED",
      "how_to_inject": "Load positions to fill bucket_key to 3000 pUSD, submit any intent for the same bucket",
      "expected_behaviour": "HARD_REJECT(SETTLEMENT_EXPOSURE_EXCEEDED)",
      "recovery": "Returns to APPROVE after positions in the bucket reduce below ceiling."
    },
    {
      "scenario": "CONCURRENT_INTENTS",
      "how_to_inject": "Submit two intents simultaneously for the same bucket_key",
      "expected_behaviour": "One APPROVE and one RESHAPE_REQUIRED due to per-bucket optimistic lock",
      "recovery": "Immediate \u2014 second intent is evaluated after first updates the bucket exposure."
    }
  ],
  "runbook": {
    "summary": "SettlementExposureGuard incidents typically involve a strategy concentrating orders in a single UMA window. Verify the bucket distribution in Grafana before adjusting the ceiling.",
    "oncall_actions": [
      {
        "alert": "SettlementExposureGuardWindowNearCap",
        "first_step": "Check window_exposure_usd gauge; identify which bucket is near cap. Confirm whether this is a genuine concentration or a stale position cache.",
        "escalation": "Risk pod lead if genuine concentration confirmed.",
        "diagnosis": "",
        "mitigation": ""
      },
      {
        "alert": "SettlementExposureGuardDataUnavailable",
        "first_step": "Check Gamma API and CLOB positions endpoint; confirm connectivity.",
        "escalation": "Infra on-call if either service is down > 2 minutes.",
        "diagnosis": "",
        "mitigation": ""
      }
    ],
    "manual_overrides": [
      {
        "command": "polytraders risk refresh-positions --user-id <id>",
        "effect": "After a known CLOB sync delay causing stale position data."
      }
    ],
    "healthcheck": "GET /internal/health/settlementexposureguard \u2192 green: Gamma cache populated, position cache age < 15s, no buckets within 20% of ceiling; red: Gamma or CLOB unavailable, position cache age > 60s"
  },
  "promotion_gates": {
    "to_shadow": [
      {
        "gate": "Unit tests pass for reshape and reject window scenarios",
        "how_measured": "CI test run",
        "threshold": "100% pass"
      }
    ],
    "to_limited_live": [
      {
        "gate": "Window bucket grouping verified against live Gamma end_date values over 48h",
        "how_measured": "Manual spot-check of bucket_key assignment",
        "threshold": "100% correct bucketing"
      }
    ],
    "to_general_live": [
      {
        "gate": "Zero DATA_UNAVAILABLE rejections during normal hours over 7 days",
        "how_measured": "SettlementExposureGuardDataUnavailable alert history",
        "threshold": "0 firings"
      }
    ]
  },
  "reporting": {
    "emits_kinds": [
      "RiskVote"
    ],
    "topics": [
      "polytraders.reports.risk"
    ],
    "partition_by": "trace_id",
    "cadence": "every-event",
    "retention_class": "2y",
    "sampling_rule": "emit-every",
    "bus_failure_action": "fail-closed",
    "user_visible": "summary-only",
    "consumes_kinds": [
      "ObservationReport"
    ]
  },
  "capital_impact": "Direct",
  "mode_support": [
    "quarantine"
  ],
  "v3_status": {
    "phase": 4,
    "phase_name": "Core risk",
    "docs": {
      "done": 27,
      "total": 27,
      "state": "done"
    },
    "impl": {
      "done": 0,
      "total": 15,
      "state": "pending"
    },
    "runtime": {
      "done": 0,
      "total": 8,
      "state": "pending"
    },
    "overall": "pending"
  }
}