mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-08 07:13:53 +02:00
277 lines
8.9 KiB
Python
277 lines
8.9 KiB
Python
"""Identity rotation gates and obligation inheritance.
|
||
|
||
Source of truth: ``infonet-economy/RULES_SKELETON.md`` §3.13.
|
||
|
||
Pure functions over the chain. Two sets of responsibilities:
|
||
|
||
1. **Gating (``validate_rotation``):** reject a rotation if the old
|
||
identity holds active resolution stakes, dispute stakes, or truth
|
||
stakes. Predictor exclusion + governance decay + rep transfer are
|
||
inherited automatically by ``rotation_descendants`` — they are NOT
|
||
gates, they are computations downstream resolvers run.
|
||
|
||
2. **Descendant tracking (``rotation_descendants``):** given a node,
|
||
return the full transitive closure of identities it has rotated
|
||
into. Used by Sprint 4's predictor-exclusion logic to compute
|
||
``frozen_predictor_ids ∪ rotation_descendants(frozen_predictor_ids)``
|
||
from the snapshot at resolution time.
|
||
|
||
Cross-cutting design rule (BUILD_LOG.md): a user attempting to rotate
|
||
while holding active stakes must NOT see a hostile UI message. The
|
||
caller is expected to:
|
||
|
||
- Show the user which stakes are blocking rotation.
|
||
- Offer to wait for those stakes to settle, or cancel pending
|
||
unresolved stakes (where the protocol allows).
|
||
- Queue the rotation for retry after settlement.
|
||
|
||
This module returns structured rejection reasons (a tuple of
|
||
``(blocker_kind, count, sample_ids)``) so the UI can render exactly
|
||
that. It never returns "rejected" without the diagnostic shape.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from typing import Any, Iterable
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class RotationBlocker:
|
||
"""One reason the rotation is currently rejected.
|
||
|
||
``kind`` is one of:
|
||
- ``"resolution_stake"`` — open resolution stakes on a market
|
||
that has not yet finalized.
|
||
- ``"dispute_stake"`` — open dispute stakes that have not yet
|
||
resolved.
|
||
- ``"truth_stake"`` — truth stakes still inside their
|
||
``duration_days`` window without a resolve event.
|
||
|
||
``count`` is the number of blocking obligations of that kind.
|
||
``sample_ids`` is up to 5 string identifiers (market_id /
|
||
dispute_id / message_id) so the UI can show "3 markets and 1
|
||
dispute are still pending" with deep links.
|
||
"""
|
||
kind: str
|
||
count: int
|
||
sample_ids: tuple[str, ...]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class RotationDecision:
|
||
accepted: bool
|
||
blockers: tuple[RotationBlocker, ...]
|
||
|
||
|
||
def _payload(event: dict[str, Any]) -> dict[str, Any]:
|
||
p = event.get("payload")
|
||
return p if isinstance(p, dict) else {}
|
||
|
||
|
||
def _market_status_lookup(events: list[dict[str, Any]]) -> dict[str, str]:
|
||
"""Last-write-wins map of market_id → terminal status.
|
||
|
||
Sprint 2 only knows two terminal statuses (FINAL, INVALID) — the
|
||
full lifecycle is Sprint 4. Markets without a ``resolution_finalize``
|
||
are treated as still open (active stakes).
|
||
"""
|
||
status: dict[str, str] = {}
|
||
for ev in events:
|
||
if ev.get("event_type") != "resolution_finalize":
|
||
continue
|
||
p = _payload(ev)
|
||
mid = p.get("market_id")
|
||
if isinstance(mid, str) and mid:
|
||
outcome = p.get("outcome")
|
||
status[mid] = "invalid" if outcome == "invalid" else "final"
|
||
return status
|
||
|
||
|
||
def _dispute_status_lookup(events: list[dict[str, Any]]) -> dict[str, str]:
|
||
status: dict[str, str] = {}
|
||
for ev in events:
|
||
if ev.get("event_type") != "dispute_resolve":
|
||
continue
|
||
p = _payload(ev)
|
||
did = p.get("dispute_id")
|
||
if isinstance(did, str) and did:
|
||
status[did] = "resolved"
|
||
return status
|
||
|
||
|
||
def _truth_stake_resolved_messages(events: list[dict[str, Any]]) -> set[str]:
|
||
resolved: set[str] = set()
|
||
for ev in events:
|
||
if ev.get("event_type") != "truth_stake_resolve":
|
||
continue
|
||
p = _payload(ev)
|
||
mid = p.get("message_id")
|
||
if isinstance(mid, str) and mid:
|
||
resolved.add(mid)
|
||
return resolved
|
||
|
||
|
||
def _active_resolution_stakes(node_id: str, events: list[dict[str, Any]]) -> list[str]:
|
||
market_status = _market_status_lookup(events)
|
||
out: list[str] = []
|
||
for ev in events:
|
||
if ev.get("event_type") != "resolution_stake":
|
||
continue
|
||
if ev.get("node_id") != node_id:
|
||
continue
|
||
p = _payload(ev)
|
||
mid = p.get("market_id")
|
||
if not isinstance(mid, str) or not mid:
|
||
continue
|
||
if market_status.get(mid) is None:
|
||
out.append(mid)
|
||
return out
|
||
|
||
|
||
def _active_dispute_stakes(node_id: str, events: list[dict[str, Any]]) -> list[str]:
|
||
dispute_status = _dispute_status_lookup(events)
|
||
out: list[str] = []
|
||
for ev in events:
|
||
if ev.get("event_type") != "dispute_stake":
|
||
continue
|
||
if ev.get("node_id") != node_id:
|
||
continue
|
||
p = _payload(ev)
|
||
did = p.get("dispute_id")
|
||
if not isinstance(did, str) or not did:
|
||
continue
|
||
if dispute_status.get(did) is None:
|
||
out.append(did)
|
||
return out
|
||
|
||
|
||
def _active_truth_stakes(
|
||
node_id: str,
|
||
events: list[dict[str, Any]],
|
||
*,
|
||
now: float,
|
||
) -> list[str]:
|
||
"""A truth stake is active if its (placed_at + duration_days * 86400)
|
||
is in the future relative to ``now`` AND no ``truth_stake_resolve``
|
||
has landed for its message.
|
||
"""
|
||
resolved = _truth_stake_resolved_messages(events)
|
||
out: list[str] = []
|
||
for ev in events:
|
||
if ev.get("event_type") != "truth_stake_place":
|
||
continue
|
||
if ev.get("node_id") != node_id:
|
||
continue
|
||
p = _payload(ev)
|
||
mid = p.get("message_id")
|
||
if not isinstance(mid, str) or not mid:
|
||
continue
|
||
if mid in resolved:
|
||
continue
|
||
try:
|
||
placed_at = float(ev.get("timestamp") or 0.0)
|
||
duration_days = int(p.get("duration_days") or 0)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
expires_at = placed_at + duration_days * 86400.0
|
||
if expires_at > now:
|
||
out.append(mid)
|
||
return out
|
||
|
||
|
||
def validate_rotation(
|
||
rotation_event: dict[str, Any],
|
||
chain: Iterable[dict[str, Any]],
|
||
*,
|
||
now: float,
|
||
) -> RotationDecision:
|
||
"""Decide whether ``rotation_event`` is permitted right now.
|
||
|
||
Sprint 2 enforces RULES §3.13 Gate 1 only. Gate 2 (obligation
|
||
inheritance) is computation, not gating, and is handled by the
|
||
Sprint 4 predictor-exclusion logic that consults
|
||
``rotation_descendants``.
|
||
|
||
The returned ``RotationDecision`` includes structured blockers so
|
||
the UI can offer a non-hostile retry path (see module docstring).
|
||
"""
|
||
if rotation_event.get("event_type") != "identity_rotate":
|
||
raise ValueError("validate_rotation requires an identity_rotate event")
|
||
payload = _payload(rotation_event)
|
||
old_node_id = payload.get("old_node_id")
|
||
if not isinstance(old_node_id, str) or not old_node_id:
|
||
raise ValueError("identity_rotate payload missing old_node_id")
|
||
|
||
events = [e for e in chain if isinstance(e, dict)]
|
||
|
||
blockers: list[RotationBlocker] = []
|
||
res = _active_resolution_stakes(old_node_id, events)
|
||
if res:
|
||
blockers.append(RotationBlocker(
|
||
kind="resolution_stake",
|
||
count=len(res),
|
||
sample_ids=tuple(res[:5]),
|
||
))
|
||
dis = _active_dispute_stakes(old_node_id, events)
|
||
if dis:
|
||
blockers.append(RotationBlocker(
|
||
kind="dispute_stake",
|
||
count=len(dis),
|
||
sample_ids=tuple(dis[:5]),
|
||
))
|
||
tru = _active_truth_stakes(old_node_id, events, now=now)
|
||
if tru:
|
||
blockers.append(RotationBlocker(
|
||
kind="truth_stake",
|
||
count=len(tru),
|
||
sample_ids=tuple(tru[:5]),
|
||
))
|
||
|
||
return RotationDecision(accepted=not blockers, blockers=tuple(blockers))
|
||
|
||
|
||
def rotation_descendants(
|
||
node_id: str,
|
||
chain: Iterable[dict[str, Any]],
|
||
) -> set[str]:
|
||
"""All identities that descend from ``node_id`` via ``identity_rotate``.
|
||
|
||
Excludes ``node_id`` itself. Used by Sprint 4 predictor exclusion.
|
||
"""
|
||
events = [e for e in chain if isinstance(e, dict)]
|
||
# Build a forward map: old_node_id -> {new_node_id, new_node_id, ...}.
|
||
# New node_id of an identity_rotate is the event's signer (per spec).
|
||
forward: dict[str, set[str]] = {}
|
||
for ev in events:
|
||
if ev.get("event_type") != "identity_rotate":
|
||
continue
|
||
p = _payload(ev)
|
||
old = p.get("old_node_id")
|
||
new = ev.get("node_id")
|
||
if not isinstance(old, str) or not isinstance(new, str):
|
||
continue
|
||
if not old or not new or old == new:
|
||
continue
|
||
forward.setdefault(old, set()).add(new)
|
||
|
||
out: set[str] = set()
|
||
stack = list(forward.get(node_id, set()))
|
||
while stack:
|
||
cur = stack.pop()
|
||
if cur in out:
|
||
continue
|
||
out.add(cur)
|
||
for nxt in forward.get(cur, ()):
|
||
if nxt not in out:
|
||
stack.append(nxt)
|
||
return out
|
||
|
||
|
||
__all__ = [
|
||
"RotationBlocker",
|
||
"RotationDecision",
|
||
"rotation_descendants",
|
||
"validate_rotation",
|
||
]
|