{
  "schema_version": "1.0.0",
  "bot_id": "5.4",
  "bot_name": "SessionKeyManager",
  "slug": "sessionkeymanager",
  "layer": "Security",
  "layer_key": "sec",
  "bot_class": "Guardrail",
  "authority": [
    "Reject",
    "Pause"
  ],
  "status": "planned",
  "readiness": "Spec started",
  "flagship": true,
  "is_reference": false,
  "public_export": false,
  "identity": {
    "layer": "Security",
    "bot_class": "Guardrail",
    "authority": "Reject, Pause",
    "runs_before": "Any strategy signing action",
    "runs_after": "User authorisation grant",
    "applies_to": "All active session keys per user and strategy",
    "default_mode": "shadow_only",
    "user_visible": "Advanced details only",
    "developer_owner": "Polytraders core"
  },
  "purpose": "Issue, scope, and expire short-lived session keys so strategies can sign without re-prompting on every order.",
  "why_it_matters": [
    {
      "failure": "Session key never expires",
      "consequence": "A compromised key allows unlimited signing indefinitely without re-authorisation."
    },
    {
      "failure": "Key scope not strategy-bound",
      "consequence": "A key issued for one strategy could sign orders for another, violating least-privilege."
    },
    {
      "failure": "No emergency revocation path",
      "consequence": "A stolen key cannot be neutralised quickly, extending the attack window."
    }
  ],
  "polymarket_inputs": [
    {
      "input": "ClobAuth session token for V2 API",
      "source": "clob_auth",
      "required": true,
      "use": "Bind the session key to the V2 ClobAuth domain for order signing."
    }
  ],
  "internal_inputs": [
    {
      "input": "User-granted session scope (strategy, methods, max size)",
      "source": "Admin UI",
      "required": true,
      "use": "Define the scope of each issued session key."
    },
    {
      "input": "KillSwitch active flag",
      "source": "KillSwitch",
      "required": true,
      "use": "Revoke all session keys immediately if kill switch is active."
    }
  ],
  "raw_params": [
    "max_session_lifetime_h \u00b7 int",
    "max_calls_per_session \u00b7 int",
    "scope_per_strategy \u00b7 bool",
    "auto_revoke_on_idle_h \u00b7 int"
  ],
  "parameters": [
    {
      "name": "max_session_lifetime_h",
      "default": 8,
      "warning": "Session age > 0.75 * max_session_lifetime_h",
      "hard": "Session age >= max_session_lifetime_h",
      "controls": "Maximum lifetime in hours of an issued session key.",
      "why_default_matters": "8-hour default matches a trading day; keys expire overnight reducing standing exposure.",
      "threshold_logic": [
        {
          "condition": "session_age < max_session_lifetime_h",
          "action": "APPROVE \u2014 key valid"
        },
        {
          "condition": "session_age >= max_session_lifetime_h",
          "action": "REJECT \u2014 SESSION_KEY_EXPIRED"
        }
      ],
      "dev_check": "if (session.age_h >= p.max_session_lifetime_h) return reject('SESSION_KEY_EXPIRED');",
      "user_facing": "Your session has reached its maximum lifetime and has expired."
    },
    {
      "name": "max_calls_per_session",
      "default": 1000,
      "warning": "Call count > 0.8 * max_calls_per_session",
      "hard": "Call count >= max_calls_per_session",
      "controls": "Maximum number of signing calls before the session key is rotated.",
      "why_default_matters": "Limiting calls-per-session prevents a key from being used indefinitely even within its lifetime window.",
      "threshold_logic": [
        {
          "condition": "call_count < max_calls_per_session",
          "action": "APPROVE"
        },
        {
          "condition": "call_count >= max_calls_per_session",
          "action": "REJECT \u2014 SESSION_KEY_EXPIRED (call budget exhausted)"
        }
      ],
      "dev_check": "if (session.call_count >= p.max_calls_per_session) return reject('SESSION_KEY_EXPIRED');",
      "user_facing": "Your session has reached its signing limit. Please re-authorise."
    }
  ],
  "default_config": {
    "bot_id": "sec.session_key_manager",
    "version": "0.1.0",
    "mode": "hard_guard",
    "defaults": {
      "max_session_lifetime_h": 8,
      "max_calls_per_session": 1000,
      "scope_per_strategy": true,
      "auto_revoke_on_idle_h": 2
    }
  },
  "implementation_flow": [
    "Receive session key request or signing call.",
    "Check KillSwitch; if active, revoke all session keys and REJECT(KILL_SWITCH_ACTIVE).",
    "For new key request: issue key with scope (strategy_id, methods, max_size, expiry=now+max_session_lifetime_h).",
    "For signing call: verify key exists and is not expired (age check).",
    "Verify call_count < max_calls_per_session; if exceeded, REJECT(SESSION_KEY_EXPIRED).",
    "Verify idle duration < auto_revoke_on_idle_h; if exceeded, revoke and REJECT(SESSION_KEY_EXPIRED).",
    "Increment call_count; emit RiskVote(APPROVE).",
    "Log session event to governance audit trail."
  ],
  "decision_logic": {
    "approve": "Key exists, within lifetime, call budget not exhausted, and idle time within limit.",
    "reshape_required": "Not applicable \u2014 manager approves or rejects signing calls.",
    "reject": "Key expired, call budget exhausted, idle timeout exceeded, or KillSwitch active.",
    "warning_only": "Warn at 75% of lifetime and 80% of call budget."
  },
  "decision_output_schema": "RiskVote",
  "decision_output_example": {
    "vote_id": "sec.session_key_manager.20260509T150000Z",
    "decision": "APPROVE",
    "reason_code": null,
    "evidence": {
      "session_id": "sk_4e5f6a7b8c9d0e1f",
      "age_h": 2.5,
      "call_count": 42,
      "calls_remaining": 958,
      "scope": "strat.sports_model"
    },
    "checked_at": "2026-05-09T15:00:00Z"
  },
  "developer_log": {
    "bot_id": "sec.session_key_manager",
    "decision": "APPROVE",
    "inputs_used": [
      "session.age_h",
      "session.call_count",
      "session.scope"
    ],
    "checked_at": "2026-05-09T15:00:00Z"
  },
  "user_explanations": [
    {
      "situation": "Session key expired",
      "message": "Your session has expired. Please re-authorise to continue trading."
    },
    {
      "situation": "Call budget exhausted",
      "message": "Your session has reached its signing limit. Please re-authorise."
    },
    {
      "situation": "Session revoked \u2014 idle timeout",
      "message": "Your session was revoked due to inactivity. Please re-authorise."
    }
  ],
  "failure_modes": {
    "main_failure_mode": "Session key used after expiry because expiry check was bypassed.",
    "false_positive_risk": "Legitimate session rejected because system clock is slightly ahead of key's issued_at.",
    "false_negative_risk": "Expired key accepted if clock skew is large and no monotonic check is used.",
    "safe_fallback": "If session store is unreachable, fail-closed: reject all signing calls until store recovers.",
    "required_dependencies": [
      "Admin UI for session scope config",
      "KillSwitch",
      "CLOB auth token service"
    ]
  },
  "acceptance_tests": {
    "unit": [
      {
        "test": "Approve active session within lifetime and call budget",
        "setup": "age_h=2, call_count=100, max_session_lifetime_h=8, max_calls_per_session=1000",
        "expected": "APPROVE"
      },
      {
        "test": "Reject expired session",
        "setup": "age_h=9, max_session_lifetime_h=8",
        "expected": "DENY(SESSION_KEY_EXPIRED)"
      },
      {
        "test": "Reject when call budget exhausted",
        "setup": "call_count=1000, max_calls_per_session=1000",
        "expected": "DENY(SESSION_KEY_EXPIRED)"
      }
    ],
    "integration": [
      {
        "test": "KillSwitch revokes all active sessions",
        "expected": "All signing calls return DENY(KILL_SWITCH_ACTIVE) immediately"
      },
      {
        "test": "New session issued with correct scope after re-authorisation",
        "expected": "Session scoped to strategy_id with expiry = now + max_session_lifetime_h"
      }
    ],
    "property": [
      {
        "property": "Expired sessions never produce APPROVE",
        "required": "Always true"
      },
      {
        "property": "call_count monotonically increases; never decremented",
        "required": "Always true"
      }
    ]
  },
  "checklist_overrides": {},
  "legacy_goal": "Issue, scope, and expire short-lived session keys so strategies can sign without re-prompting on every order.",
  "legacy_pm_signals": [
    "Active session keys per user, with scope and expiry",
    "Per-key usage rate vs. budget",
    "Revocations and emergency wipes",
    "Cross-device session conflicts"
  ],
  "legacy_external_feeds": [],
  "reporting_groups": [
    "risk_compliance",
    "governance_audit"
  ],
  "network": [
    "polygon"
  ],
  "api_surface": [
    "clob_auth",
    "internal"
  ],
  "version": {
    "spec": "2.0.0",
    "implementation": "0.1.0",
    "schema": "2",
    "released": null,
    "planned_release": "Q3-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)"
    }
  ],
  "polymarket_v2_compat": {
    "clob_version": "v2",
    "collateral": "pUSD",
    "eip712_domain_version": "2",
    "builder_code_aware": true,
    "negrisk_aware": false,
    "multichain_ready": false,
    "sdk_used": "py-clob-client-v2",
    "settlement_contract": "CTFExchangeV2",
    "notes": "Session keys are scoped to CTFExchangeV2 V2 EIP-712 domain; ClobAuth token version '1' remains separate."
  },
  "reference_implementation": {
    "pseudocode": "// SessionKeyManager\nFUNCTION validateSession(signingCall, sessions):\n  IF FETCH(internal.killswitch).active:\n    FOR session IN sessions.active: session.revoke()\n    EMIT RiskVote(DENY, KILL_SWITCH_ACTIVE); RETURN\n  session = sessions.get(signingCall.session_id)\n  IF session == null:\n    EMIT RiskVote(DENY, SESSION_KEY_EXPIRED); RETURN\n  // Lifetime check\n  age_h = (NOW() - session.issued_at) / 3600\n  IF age_h >= params.max_session_lifetime_h:\n    session.revoke()\n    EMIT RiskVote(DENY, SESSION_KEY_EXPIRED); RETURN\n  // Call budget check\n  IF session.call_count >= params.max_calls_per_session:\n    session.revoke()\n    EMIT RiskVote(DENY, SESSION_KEY_EXPIRED); RETURN\n  // Idle check\n  idle_h = (NOW() - session.last_used_at) / 3600\n  IF idle_h > params.auto_revoke_on_idle_h:\n    session.revoke()\n    EMIT RiskVote(DENY, SESSION_KEY_EXPIRED); RETURN\n  session.call_count += 1\n  session.last_used_at = NOW()\n  EMIT RiskVote(APPROVE)\n  LOG(governance.audit, {session_id, call_count})",
    "sdk_calls": [
      "clob_auth.issue_session_token(scope)",
      "internal.killswitch.status()"
    ],
    "complexity": "O(1) per call \u2014 hash map session lookup"
  },
  "wire_examples": {
    "input": [
      {
        "label": "Signing call with active session",
        "source": "internal",
        "payload": {
          "intent_id": "int_4d5e6f7a8b9c0d1e",
          "session_id": "sk_4e5f6a7b8c9d0e1f",
          "strategy_id": "strat.sports_model",
          "timestamp_ms": 1746768672000
        }
      }
    ],
    "output": [
      {
        "label": "RiskVote \u2014 APPROVE",
        "payload": {
          "vote_id": "sec.session_key_manager.20260509T150000Z",
          "decision": "APPROVE",
          "reason_code": null,
          "evidence": {
            "session_id": "sk_4e5f6a7b8c9d0e1f",
            "calls_remaining": 958
          },
          "checked_at": "2026-05-09T15:00:00Z"
        }
      }
    ]
  },
  "reason_codes": [
    {
      "code": "KILL_SWITCH_ACTIVE",
      "severity": "HARD_REJECT",
      "meaning": "Global kill switch is active; all sessions revoked.",
      "action": "Immediately return DENY and revoke all sessions.",
      "user_message": "Trading is currently paused."
    },
    {
      "code": "SESSION_KEY_EXPIRED",
      "severity": "HARD_REJECT",
      "meaning": "Session key has exceeded lifetime, call budget, or idle timeout.",
      "action": "Return DENY; prompt user to re-authorise.",
      "user_message": "Your session has expired. Please re-authorise."
    },
    {
      "code": "SESSION_BUDGET_WARN",
      "severity": "WARN",
      "meaning": "Session call count exceeds 80% of max_calls_per_session.",
      "action": "Emit warn; notify user to prepare re-authorisation.",
      "user_message": "Your session is nearly at its signing limit."
    },
    {
      "code": "SESSION_EXPIRY_WARN",
      "severity": "WARN",
      "meaning": "Session age exceeds 75% of max_session_lifetime_h.",
      "action": "Emit warn; notify user.",
      "user_message": "Your session will expire soon."
    },
    {
      "code": "SESSION_ISSUED",
      "severity": "INFO",
      "meaning": "New session key issued successfully.",
      "action": "Log issuance event.",
      "user_message": "New session started."
    }
  ],
  "metrics": {
    "emitted": [
      {
        "name": "polytraders_sec_sessionkeymanager_active_sessions",
        "type": "gauge",
        "unit": "count",
        "labels": [
          "strategy_id"
        ],
        "meaning": "Number of active session keys per strategy."
      },
      {
        "name": "polytraders_sec_sessionkeymanager_expirations_total",
        "type": "counter",
        "unit": "count",
        "labels": [
          "reason"
        ],
        "meaning": "Total session expirations by reason."
      },
      {
        "name": "polytraders_sec_sessionkeymanager_calls_total",
        "type": "counter",
        "unit": "count",
        "labels": [
          "decision"
        ],
        "meaning": "Total signing call decisions."
      },
      {
        "name": "polytraders_sec_sessionkeymanager_session_age_h",
        "type": "histogram",
        "unit": "hours",
        "labels": [],
        "meaning": "Distribution of session ages at expiry."
      }
    ],
    "alerts": [
      {
        "name": "SessionKeyExpiredHighRate",
        "condition": "rate(polytraders_sec_sessionkeymanager_expirations_total[5m]) > 5",
        "severity": "P2",
        "runbook": "#runbook-sessionkey-expiry"
      },
      {
        "name": "SessionKeyNoneActive",
        "condition": "polytraders_sec_sessionkeymanager_active_sessions == 0",
        "severity": "P1",
        "runbook": "#runbook-sessionkey-none"
      }
    ]
  },
  "state": {
    "store": "in-process hash map + persistent session store (Redis or equivalent)",
    "shape": "{session_id -> {strategy_id, issued_at, last_used_at, call_count, scope, expiry}}",
    "ttl": "max_session_lifetime_h (default 8h)",
    "recovery": "Reload active sessions from persistent store on restart; expired sessions discarded.",
    "size_estimate": "< 1 KB per session; < 100 KB total for typical load"
  },
  "concurrency": {
    "execution_model": "async event loop",
    "max_in_flight": 1000,
    "idempotency_key": "intent_id",
    "timeout_ms": 5,
    "backpressure": "drop newest above 1000 in-flight",
    "locking": "mutex on session call_count increment"
  },
  "dependencies": {
    "depends_on": [
      {
        "bot_id": "risk.kill_switch",
        "why": "KillSwitch triggers mass session revocation.",
        "contract": "DENY(KILL_SWITCH_ACTIVE) and revoke all sessions."
      }
    ],
    "emits_to": [
      {
        "bot_id": "sec.wallet_permission_guard",
        "why": "Provides session expiry to permission guard.",
        "contract": "Expired sessions cause DENY(SESSION_KEY_EXPIRED) in permission guard."
      },
      {
        "bot_id": "gov.builder_attribution",
        "why": "Session issuance and revocation audit log.",
        "contract": "GovernanceLog entry on each issue/revoke event."
      }
    ],
    "sibling": [
      "sec.wallet_permission_guard"
    ],
    "external": [
      {
        "service": "CLOB Auth API",
        "endpoint": "https://clob.polymarket.com",
        "sla": "99.9%",
        "failure_mode": "Fail-closed on unreachable auth endpoint."
      }
    ]
  },
  "security_surfaces": {
    "signs_orders": false,
    "private_key_access": "signing-only",
    "abuse_vectors": [
      "Session token theft enabling signing calls beyond user intent",
      "Call count manipulation to reset budget and extend session"
    ],
    "mitigations": [
      "Session tokens are scoped to a single strategy_id and methods list",
      "call_count is server-side authoritative; never trusted from client"
    ]
  },
  "failure_injection": [
    {
      "scenario": "SESSION_LIFETIME_EXCEEDED",
      "how_to_inject": "Set session.issued_at to now - max_session_lifetime_h - 1",
      "expected_behaviour": "DENY(SESSION_KEY_EXPIRED); session revoked",
      "recovery": "User re-authorises."
    },
    {
      "scenario": "CALL_BUDGET_EXHAUSTED",
      "how_to_inject": "Set session.call_count = max_calls_per_session",
      "expected_behaviour": "DENY(SESSION_KEY_EXPIRED); session revoked",
      "recovery": "User re-authorises."
    },
    {
      "scenario": "KILL_SWITCH_MASS_REVOKE",
      "how_to_inject": "Set killswitch.active=true",
      "expected_behaviour": "All active sessions revoked; all signing calls DENY(KILL_SWITCH_ACTIVE)",
      "recovery": "Manual KillSwitch reset; users must re-authorise."
    }
  ],
  "runbook": {
    "summary": "Session key events are routine security hygiene; mass expiry or zero-active alerts require immediate investigation.",
    "oncall_actions": [
      {
        "alert": "SessionKeyNoneActive",
        "first_action": "Check if KillSwitch fired or if re-authorisation flow is broken.",
        "escalate_to": "Security pod lead if KillSwitch not the cause."
      },
      {
        "alert": "SessionKeyExpiredHighRate",
        "first_action": "Check for clock skew or misconfigured max_session_lifetime_h.",
        "escalate_to": "On-call engineer for config fix."
      }
    ],
    "manual_overrides": [
      {
        "name": "Revoke all sessions for a user",
        "how": "polytraders admin revoke-sessions sec.session_key_manager --user <user_id>",
        "when": "Suspected key compromise."
      }
    ],
    "healthcheck": "GET /internal/health/sessionkeymanager \u2192 green if Session store reachable; at least 1 active session for active strategies.; red if Session store unreachable or zero active sessions while strategies running."
  },
  "promotion_gates": {
    "to_shadow": [
      {
        "gate": "Unit tests pass for lifetime, call budget, and idle revocation",
        "how_measured": "CI test run",
        "threshold": "100% pass"
      }
    ],
    "to_limited_live": [
      {
        "gate": "KillSwitch mass-revoke test fires correctly",
        "how_measured": "Failure injection test",
        "threshold": "Pass"
      }
    ],
    "to_general_live": [
      {
        "gate": "Zero false-positive session expirations in 48h shadow",
        "how_measured": "Grafana SessionKeyExpiredHighRate history",
        "threshold": "0 false positives"
      }
    ]
  },
  "reporting": {
    "emits_kinds": [
      "RiskVote"
    ],
    "topics": [
      "polytraders.reports.risk"
    ],
    "retention_class": "2y",
    "cadence": "every-event",
    "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": 5,
    "phase_name": "Execution rails",
    "docs": {
      "done": 27,
      "total": 27,
      "state": "done"
    },
    "impl": {
      "done": 0,
      "total": 15,
      "state": "pending"
    },
    "runtime": {
      "done": 0,
      "total": 8,
      "state": "pending"
    },
    "overall": "pending"
  }
}