"""Infonet — append-only signed event ledger for decentralized consensus.

The Infonet is ShadowBroker's consensus protocol. Every action on the mesh
(message, vote, gate creation, oracle prediction) becomes a chain event.
Each event references the previous event's hash, creating an immutable
ordered sequence. No mining, no proof-of-work — just cryptographic linking
and signature verification.

This is the consensus layer. The reputation, gates, and oracle systems are the
application layer that sits on top.

Event types:
  - message:      Public broadcast message
  - vote:         Reputation vote (+1/-1 on a node)
  - gate_create:  New gate/community creation
  - prediction:   Oracle market prediction
  - stake:        Oracle truth stake
  - key_rotate:   Link old and new public keys
  - key_revoke:   Revoke a compromised key (with grace window)

Private DM registration, mailbox access, and transport routing metadata are
intentionally kept off-ledger.

Each event contains:
  - event_id:     SHA-256 hash of (prev_hash + type + payload + timestamp + node_id)
  - prev_hash:    Hash of the previous event (chain link)
  - type:         Event type string
  - node_id:      Author's node ID
  - payload:      Event-specific data
  - timestamp:    Unix timestamp
  - sequence:     Per-node monotonic sequence number (replay protection)
  - signature:    Node's cryptographic signature

Persistence: JSON file at backend/data/infonet.json

Encrypted gate chat events are private-chain ciphertext records. They are
excluded from public read surfaces and replicated only over private Infonet
transports.
"""

import json
import orjson
import os
import time
import hmac
import hashlib
import logging
import threading
import atexit
import tempfile
import base64
import zlib
from pathlib import Path
from collections import deque
from typing import Any

from services.mesh.mesh_secure_storage import read_domain_json, write_domain_json
from services.mesh.mesh_crypto import (
    build_signature_payload,
    parse_public_key_algo,
    verify_node_binding,
    verify_signature,
)
from services.mesh.mesh_protocol import NETWORK_ID, PROTOCOL_VERSION, normalize_payload
from services.mesh.mesh_schema import (
    ACTIVE_PUBLIC_LEDGER_EVENT_TYPES,
    PUBLIC_LEDGER_EVENT_TYPES,
    validate_event_payload,
    validate_private_dm_ledger_payload,
    validate_private_gate_ledger_payload,
    validate_protocol_fields,
    validate_public_ledger_payload,
)

logger = logging.getLogger("services.mesh_hashchain")
_PRIVACY_LOGS = os.environ.get("MESH_PRIVACY_LOGS", "").strip().lower() in ("1", "true", "yes")
_MESH_ONLY = os.environ.get("MESH_ONLY", "").strip().lower() in ("1", "true", "yes")


def _safe_int(val, default=0):
    try:
        return int(val)
    except (TypeError, ValueError):
        return default


def _redact_node(node_id: str) -> str:
    if not node_id:
        return ""
    if _PRIVACY_LOGS or _MESH_ONLY:
        return f"{node_id[:6]}…"
    return node_id


def _atomic_write_text(target: Path, content: str, encoding: str = "utf-8") -> None:
    """Write content atomically via temp file + os.replace()."""
    parent = target.parent
    parent.mkdir(parents=True, exist_ok=True)
    fd, tmp_path = tempfile.mkstemp(dir=str(parent), suffix=".tmp")
    try:
        with os.fdopen(fd, "w", encoding=encoding) as handle:
            handle.write(content)
            handle.flush()
            os.fsync(handle.fileno())
        os.replace(tmp_path, str(target))
    except BaseException:
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise


def _atomic_write_bytes(target: Path, content: bytes) -> None:
    """Write binary content atomically via temp file + os.replace()."""
    parent = target.parent
    parent.mkdir(parents=True, exist_ok=True)
    fd, tmp_path = tempfile.mkstemp(dir=str(parent), suffix=".tmp")
    try:
        with os.fdopen(fd, "wb") as handle:
            handle.write(content)
            handle.flush()
            os.fsync(handle.fileno())
        os.replace(tmp_path, str(target))
    except BaseException:
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise


DATA_DIR = Path(__file__).resolve().parents[2] / "data"
CHAIN_FILE = DATA_DIR / "infonet.json"
WAL_FILE = DATA_DIR / "infonet.wal"
CHAIN_COLD_DIR = DATA_DIR / "infonet_cold"
GATE_STORE_DIR = DATA_DIR / "gate_messages"
GATE_STORAGE_DOMAIN = "gates"

# ─── Constants ────────────────────────────────────────────────────────────

GENESIS_HASH = "0" * 64  # The "previous hash" for the first event
MAX_CHAIN_MEMORY = 50000  # Max events to keep in memory (older ones on disk only)
EPHEMERAL_TTL = 86400  # 24 hours — ephemeral messages auto-purge
MESSAGE_RETENTION_DAYS = 90  # Non-ephemeral messages kept for 90 days
CHAIN_LOCK_DEPTH = 6
GATE_REPLAY_WINDOW_S = 86400 * 30
GATE_REPLAY_PRUNE_INTERVAL = 256
GATE_SEGMENT_EVENT_TARGET = max(1, int(os.environ.get("MESH_GATE_SEGMENT_EVENT_TARGET", "1000") or "1000"))
GATE_SEGMENT_MAX_COMPRESSED_BYTES = max(
    16 * 1024,
    int(os.environ.get("MESH_GATE_SEGMENT_MAX_COMPRESSED_BYTES", str(2 * 1024 * 1024)) or str(2 * 1024 * 1024)),
)
GATE_SEGMENT_STORAGE_VERSION = 1
DM_HASHCHAIN_SPOOL_LIMIT = max(1, int(os.environ.get("MESH_DM_HASHCHAIN_SPOOL_LIMIT", "2") or "2"))
DM_HASHCHAIN_SPOOL_SENDER_LIMIT = max(
    1,
    int(os.environ.get("MESH_DM_HASHCHAIN_SPOOL_SENDER_LIMIT", "1") or "1"),
)
DM_HASHCHAIN_SPOOL_TTL_S = max(60, int(os.environ.get("MESH_DM_HASHCHAIN_SPOOL_TTL_S", "3600") or "3600"))
_PUBLIC_EVENT_APPEND_HOOKS: list[Any] = []
_PUBLIC_EVENT_APPEND_HOOKS_LOCK = threading.Lock()


def register_public_event_append_hook(callback: Any) -> None:
    if callback is None:
        return
    with _PUBLIC_EVENT_APPEND_HOOKS_LOCK:
        if callback not in _PUBLIC_EVENT_APPEND_HOOKS:
            _PUBLIC_EVENT_APPEND_HOOKS.append(callback)


def unregister_public_event_append_hook(callback: Any) -> None:
    with _PUBLIC_EVENT_APPEND_HOOKS_LOCK:
        if callback in _PUBLIC_EVENT_APPEND_HOOKS:
            _PUBLIC_EVENT_APPEND_HOOKS.remove(callback)


def _notify_public_event_append_hooks(event_dict: dict[str, Any]) -> None:
    with _PUBLIC_EVENT_APPEND_HOOKS_LOCK:
        hooks = list(_PUBLIC_EVENT_APPEND_HOOKS)
    for hook in hooks:
        try:
            hook(dict(event_dict))
        except Exception:
            logger.exception("public event append hook failed")


# ─── Network Identity ────────────────────────────────────────────────────
# NETWORK_ID is defined in services.mesh_protocol to avoid circular imports.

# ─── Protocol Constraints ────────────────────────────────────────────────

ACTIVE_APPEND_EVENT_TYPES = set(ACTIVE_PUBLIC_LEDGER_EVENT_TYPES)
"""Event types allowed for new append() calls — gate_message excluded since S3A/S4B."""

ALLOWED_EVENT_TYPES = set(PUBLIC_LEDGER_EVENT_TYPES)
"""Full set including legacy types — used by ingest_events() and apply_fork()."""

MAX_PAYLOAD_BYTES = 4096
REPLAY_FILTER_BITS = 1_000_000
REPLAY_FILTER_HASHES = 3
REPLAY_FILTER_ROTATE_S = 3600
CRITICAL_EVENT_TYPES = {"key_rotate", "key_revoke"}
MIN_CONFIRMATIONS_CRITICAL = 3


def _gate_wire_event_material(event: dict[str, Any]) -> str:
    payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
    material = {
        "event_type": str(event.get("event_type", "gate_message") or "gate_message"),
        "timestamp": float(event.get("timestamp", 0) or 0),
        "ciphertext": str(payload.get("ciphertext", "") or ""),
        "format": str(payload.get("format", "") or ""),
    }
    return json.dumps(material, sort_keys=True, separators=(",", ":"), ensure_ascii=False)


def build_gate_replay_fingerprint(gate_id: str, event: dict[str, Any]) -> str:
    payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
    material = {
        "gate": str(gate_id or "").strip().lower(),
        "event_type": "gate_message",
        "timestamp": float(event.get("timestamp", 0) or 0),
        "ciphertext": str(payload.get("ciphertext", "") or ""),
        "nonce": str(payload.get("nonce", "") or ""),
        "format": str(payload.get("format", "") or ""),
    }
    return hashlib.sha256(
        json.dumps(material, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
    ).hexdigest()


def _peer_pair_ref_key(peer_url: str) -> bytes:
    """Derive a per-hop HMAC key for gate wire refs.

    Sprint 3 / Rec #4: the wire ref used to be HMAC-keyed by the
    global ``MESH_PEER_PUSH_SECRET``, which let any authenticated peer
    enumerate gate memberships by HMACing every gate_id they knew. The
    new key is bound to the authenticated *hop* (the receiving peer's
    URL) via the same HKDF chain as the peer-push HMAC, with a fresh
    domain separator. A peer who intercepts push traffic addressed to
    *another* receiver cannot derive the matching key — they only
    learn gate_ids on pushes where they are the intended receiver,
    which they would learn anyway via MLS membership.

    Returns an empty key on misconfiguration so callers fail closed.
    """
    try:
        from services.mesh.mesh_crypto import (
            normalize_peer_url,
            resolve_peer_key_for_url,
        )
    except Exception:
        return b""
    normalized = normalize_peer_url(peer_url or "")
    if not normalized:
        return b""
    # Issue #256: resolve_peer_key_for_url() prefers per-peer secrets
    # from MESH_PEER_SECRETS and falls back to the global
    # MESH_PEER_PUSH_SECRET only when the URL has no per-peer entry.
    peer_key = resolve_peer_key_for_url(normalized)
    if not peer_key:
        return b""
    # Domain-separate from the transport HMAC key so the two
    # derivations can't cross-contaminate in analysis.
    return hmac.new(peer_key, b"sb-gate-ref-v2", hashlib.sha256).digest()


def build_gate_wire_ref(
    gate_id: str,
    event: dict[str, Any],
    *,
    peer_url: str = "",
) -> str:
    gate_key = str(gate_id or "").strip().lower()
    if not gate_key:
        return ""
    key = _peer_pair_ref_key(peer_url)
    if not key:
        return ""
    material = f"{gate_key}|{_gate_wire_event_material(event)}".encode("utf-8")
    return hmac.new(key, material, hashlib.sha256).hexdigest()


def resolve_gate_wire_ref(
    gate_ref: str,
    event: dict[str, Any],
    *,
    peer_url: str = "",
) -> str:
    ref = str(gate_ref or "").strip().lower()
    if not ref:
        return ""
    if not peer_url:
        # Sprint 3 / Rec #4: pair-binding is mandatory. Refuse to
        # resolve refs that don't identify the hop — fail-closed
        # stops stale callers from enumerating via a one-sided key.
        return ""
    candidates: set[str] = set()
    try:
        candidates.update(gate_store.known_gate_ids())
    except Exception:
        pass
    try:
        from services.mesh.mesh_reputation import gate_manager

        for gate in gate_manager.list_gates():
            gate_id = str((gate or {}).get("gate_id", "") or "").strip().lower()
            if gate_id:
                candidates.add(gate_id)
    except Exception:
        pass
    for gate_id in sorted(candidates):
        candidate_ref = build_gate_wire_ref(
            gate_id,
            event,
            peer_url=peer_url,
        )
        if candidate_ref and hmac.compare_digest(candidate_ref, ref):
            return gate_id
    return ""


def _private_gate_signature_payload(
    gate_id: str,
    event: dict[str, Any],
    *,
    include_reply_to: bool = True,
) -> dict[str, Any]:
    payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
    normalized = {
        "gate": str(gate_id or "").strip().lower(),
        "ciphertext": str(payload.get("ciphertext", "") or ""),
        "nonce": str(payload.get("nonce", "") or ""),
        "sender_ref": str(payload.get("sender_ref", "") or ""),
        "format": str(payload.get("format", "mls1") or "mls1"),
    }
    epoch = _safe_int(payload.get("epoch", 0) or 0, 0)
    if epoch > 0:
        normalized["epoch"] = epoch
    envelope_hash = str(payload.get("envelope_hash", "") or "").strip()
    if envelope_hash:
        normalized["envelope_hash"] = envelope_hash
    transport_lock = str(payload.get("transport_lock", "") or "").strip().lower()
    if transport_lock:
        normalized["transport_lock"] = transport_lock
    reply_to = str(payload.get("reply_to", "") or "").strip()
    if include_reply_to and reply_to:
        normalized["reply_to"] = reply_to
    return normalize_payload("gate_message", normalized)


def _private_gate_event_id(
    gate_id: str,
    node_id: str,
    sequence: int,
    event: dict[str, Any],
    *,
    include_reply_to: bool = True,
) -> str:
    payload_json = json.dumps(
        _private_gate_signature_payload(gate_id, event, include_reply_to=include_reply_to),
        sort_keys=True,
        separators=(",", ":"),
        ensure_ascii=False,
    )
    timestamp = float(event.get("timestamp", 0) or 0)
    return hashlib.sha256(
        f"{gate_id}:{node_id}:{payload_json}:{timestamp}:{int(sequence)}".encode("utf-8")
    ).hexdigest()


def _private_gate_signature_payload_variants(gate_id: str, event: dict[str, Any]) -> list[dict[str, Any]]:
    payload = _private_gate_signature_payload(gate_id, event)
    variants: list[dict[str, Any]] = [payload]
    event_payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
    reply_to = str(event_payload.get("reply_to", "") or "").strip()
    if reply_to:
        variants.append(_private_gate_signature_payload(gate_id, event, include_reply_to=False))
    if "epoch" in payload:
        no_epoch = dict(payload)
        no_epoch.pop("epoch", None)
        variants.append(no_epoch)
        if reply_to:
            no_epoch_no_reply = _private_gate_signature_payload(gate_id, event, include_reply_to=False)
            no_epoch_no_reply.pop("epoch", None)
            variants.append(no_epoch_no_reply)
    deduped: list[dict[str, Any]] = []
    seen: set[str] = set()
    for variant in variants:
        material = json.dumps(variant, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
        if material in seen:
            continue
        seen.add(material)
        deduped.append(variant)
    return deduped


def _sanitize_private_gate_event(gate_id: str, event: dict[str, Any]) -> dict[str, Any]:
    payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
    sanitized = {
        "event_id": str(event.get("event_id", "") or ""),
        "event_type": "gate_message",
        "node_id": str(event.get("node_id", "") or ""),
        "timestamp": float(event.get("timestamp", 0) or 0),
        "sequence": int(event.get("sequence", 0) or 0),
        "signature": str(event.get("signature", "") or ""),
        "public_key": str(event.get("public_key", "") or ""),
        "public_key_algo": str(event.get("public_key_algo", "") or ""),
        "protocol_version": str(event.get("protocol_version", "") or ""),
        "payload": {
            "gate": str(gate_id or "").strip().lower(),
            "ciphertext": str(payload.get("ciphertext", "") or ""),
            "nonce": str(payload.get("nonce", "") or ""),
            "sender_ref": str(payload.get("sender_ref", "") or ""),
            "format": str(payload.get("format", "mls1") or "mls1"),
        },
    }
    epoch = _safe_int(payload.get("epoch", 0) or 0, 0)
    if epoch > 0:
        sanitized["payload"]["epoch"] = epoch
    envelope_hash = str(payload.get("envelope_hash", "") or "").strip()
    if envelope_hash:
        sanitized["payload"]["envelope_hash"] = envelope_hash
    gate_envelope = str(payload.get("gate_envelope", "") or "").strip()
    if gate_envelope:
        sanitized["payload"]["gate_envelope"] = gate_envelope
    transport_lock = str(payload.get("transport_lock", "") or "").strip().lower()
    if transport_lock:
        sanitized["payload"]["transport_lock"] = transport_lock
    reply_to = str(payload.get("reply_to", "") or "").strip()
    if reply_to:
        sanitized["payload"]["reply_to"] = reply_to
    # Local-only decrypted plaintext — persisted on the private chain so
    # leave/rejoin and restarts never lose readable messages. These fields are
    # stamped post-decrypt and never leave the node.
    local_pt = payload.get("_local_plaintext")
    if isinstance(local_pt, str) and local_pt:
        sanitized["payload"]["_local_plaintext"] = local_pt
    local_rt = payload.get("_local_reply_to")
    if isinstance(local_rt, str) and local_rt:
        sanitized["payload"]["_local_reply_to"] = local_rt
    return sanitized


def _authorize_private_gate_transport_author(
    gate_id: str,
    node_id: str,
    public_key: str,
    public_key_algo: str,
) -> tuple[bool, str]:
    gate_key = str(gate_id or "").strip().lower()
    candidate = str(node_id or "").strip()
    if not gate_key or not candidate:
        return False, "private gate authorization unavailable"
    try:
        from services.mesh.mesh_reputation import gate_manager, reputation_ledger
    except Exception:
        return False, "private gate authorization unavailable"
    try:
        reputation_ledger.register_node(candidate, public_key, public_key_algo)
    except Exception:
        return False, "private gate authorization unavailable"
    ok, reason = gate_manager.can_enter(candidate, gate_key)
    if ok:
        return True, "ok"
    return False, str(reason or "Gate access denied")


def _verify_private_gate_transport_event(gate_id: str, event: dict[str, Any]) -> tuple[bool, str, dict[str, Any] | None]:
    node_id = str(event.get("node_id", "") or event.get("sender_id", "") or "").strip()
    public_key = str(event.get("public_key", "") or "").strip()
    public_key_algo = str(event.get("public_key_algo", "") or "").strip()
    signature = str(event.get("signature", "") or "").strip()
    protocol_version = str(event.get("protocol_version", "") or "").strip()
    sequence = _safe_int(event.get("sequence", 0) or 0, 0)
    if not node_id or not public_key or not public_key_algo or not signature:
        return False, "missing private gate auth fields", None
    if sequence <= 0:
        return False, "invalid private gate sequence", None
    if protocol_version != PROTOCOL_VERSION:
        return False, "Unsupported protocol_version", None
    payload = _private_gate_signature_payload(gate_id, event)
    ok, reason = validate_event_payload("gate_message", payload)
    if not ok:
        return False, reason, None
    if not verify_node_binding(node_id, public_key):
        return False, "node_id mismatch", None
    algo = parse_public_key_algo(public_key_algo)
    if not algo:
        return False, "Unsupported public_key_algo", None
    reply_to = str(((event.get("payload") or {}) if isinstance(event.get("payload"), dict) else {}).get("reply_to", "") or "").strip()
    legacy_unsigned_reply_to = False
    legacy_unsigned_epoch = False
    variants: list[tuple[dict[str, Any], bool, bool]] = [(payload, False, False)]
    if reply_to:
        variants.append((_private_gate_signature_payload(gate_id, event, include_reply_to=False), True, False))
    if "epoch" in payload:
        no_epoch = dict(payload)
        no_epoch.pop("epoch", None)
        variants.append((no_epoch, False, True))
        if reply_to:
            no_epoch_no_reply = _private_gate_signature_payload(gate_id, event, include_reply_to=False)
            no_epoch_no_reply.pop("epoch", None)
            variants.append((no_epoch_no_reply, True, True))
    sig_ok = False
    for candidate_payload, candidate_unsigned_reply, candidate_unsigned_epoch in variants:
        candidate_sig_payload = build_signature_payload(
            event_type="gate_message",
            node_id=node_id,
            sequence=sequence,
            payload=candidate_payload,
        )
        if verify_signature(
            public_key_b64=public_key,
            public_key_algo=algo,
            signature_hex=signature,
            payload=candidate_sig_payload,
        ):
            sig_ok = True
            legacy_unsigned_reply_to = candidate_unsigned_reply
            legacy_unsigned_epoch = candidate_unsigned_epoch
            break
    if not sig_ok:
        return False, "Invalid signature", None
    envelope_hash = str(((event.get("payload") or {}) if isinstance(event.get("payload"), dict) else {}).get("envelope_hash", "") or "").strip()
    gate_envelope = str(((event.get("payload") or {}) if isinstance(event.get("payload"), dict) else {}).get("gate_envelope", "") or "").strip()
    if envelope_hash:
        if not gate_envelope:
            return False, "gate_envelope required when envelope_hash is present", None
        if hashlib.sha256(gate_envelope.encode("ascii")).hexdigest() != envelope_hash:
            return False, "gate_envelope does not match envelope_hash", None
    authorized, reason = _authorize_private_gate_transport_author(gate_id, node_id, public_key, public_key_algo)
    if not authorized:
        return False, f"private gate access denied: {reason}", None
    event_for_id = event
    if legacy_unsigned_epoch:
        event_for_id = dict(event)
        event_payload_for_id = dict((event.get("payload") or {}) if isinstance(event.get("payload"), dict) else {})
        event_payload_for_id.pop("epoch", None)
        event_for_id["payload"] = event_payload_for_id
    expected_event_id = _private_gate_event_id(
        gate_id,
        node_id,
        sequence,
        event_for_id,
        include_reply_to=not legacy_unsigned_reply_to,
    )
    provided_event_id = str(event.get("event_id", "") or "").strip()
    if provided_event_id and provided_event_id != expected_event_id:
        return False, "private gate event_id mismatch", None
    sanitized = _sanitize_private_gate_event(gate_id, event)
    if legacy_unsigned_reply_to:
        sanitized["payload"].pop("reply_to", None)
    if legacy_unsigned_epoch:
        sanitized["payload"].pop("epoch", None)
    sanitized["event_id"] = provided_event_id or expected_event_id
    return True, "ok", sanitized


class GateMessageStore:
    """Private-plane storage for encrypted gate messages."""

    def __init__(self, data_dir: str = ""):
        self._gates: dict[str, list[dict]] = {}
        self._event_index: dict[str, dict] = {}
        self._replay_index: dict[str, dict[str, Any]] = {}
        self._replay_prune_counter = 0
        self._data_dir = Path(data_dir) if data_dir else GATE_STORE_DIR
        self._lock = threading.Lock()
        self._change_condition = threading.Condition(self._lock)
        self._load()

    def _gate_digest(self, gate_id: str) -> str:
        return hashlib.sha256(str(gate_id or "").encode("utf-8")).hexdigest()

    def _gate_file_path(self, gate_id: str) -> Path:
        return self._data_dir / f"gate_{self._gate_digest(gate_id)}.jsonl"

    def _gate_legacy_domain_filename(self, gate_id: str) -> str:
        return f"gate_{self._gate_digest(gate_id)}.jsonl"

    def _gate_manifest_filename_for_digest(self, digest: str) -> str:
        return f"gate_{digest}.manifest.json"

    def _gate_manifest_filename(self, gate_id: str) -> str:
        return self._gate_manifest_filename_for_digest(self._gate_digest(gate_id))

    def _gate_segment_filename_for_digest(self, digest: str, segment_no: int) -> str:
        return f"gate_{digest}_seg_{max(0, int(segment_no)):08d}.gseg"

    def _gate_segment_filename(self, gate_id: str, segment_no: int) -> str:
        return self._gate_segment_filename_for_digest(self._gate_digest(gate_id), segment_no)

    def _gate_storage_base_dir(self) -> Path:
        return self._data_dir.parent

    def _gate_domain_dir(self) -> Path:
        return self._gate_storage_base_dir() / GATE_STORAGE_DOMAIN

    def _sort_gate(self, gate_id: str) -> None:
        events = self._gates.get(gate_id, [])
        events.sort(
            key=lambda evt: (
                float(evt.get("timestamp", 0) or 0),
                _safe_int(evt.get("sequence", 0) or 0, 0),
                str(evt.get("event_id", "") or ""),
            )
        )

    def _remember_replay_fingerprint(self, replay_fingerprint: str, event: dict) -> None:
        self._replay_index[replay_fingerprint] = {
            "event": event,
            "timestamp": float(event.get("timestamp", 0) or 0.0),
        }

    def _replay_existing_event(self, replay_fingerprint: str) -> dict | None:
        entry = self._replay_index.get(replay_fingerprint) or {}
        event = entry.get("event")
        return event if isinstance(event, dict) else None

    def _prune_replay_index(self, now: float | None = None) -> int:
        current = float(now if now is not None else time.time())
        cutoff = current - GATE_REPLAY_WINDOW_S
        stale = [
            fingerprint
            for fingerprint, entry in list(self._replay_index.items())
            if float((entry or {}).get("timestamp", 0) or 0.0) < cutoff
        ]
        for fingerprint in stale:
            self._replay_index.pop(fingerprint, None)
        return len(stale)

    def _maybe_prune_replay_index(self) -> None:
        self._replay_prune_counter += 1
        if self._replay_prune_counter % GATE_REPLAY_PRUNE_INTERVAL == 0:
            self._prune_replay_index()

    def _stable_bytes(self, payload: Any) -> bytes:
        return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")

    def _segment_material_hash(self, payload: dict[str, Any]) -> str:
        material = dict(payload)
        material.pop("segment_hash", None)
        return hashlib.sha256(self._stable_bytes(material)).hexdigest()

    def _encode_segment_events(self, events: list[dict]) -> str:
        raw = self._stable_bytes(events)
        return base64.b64encode(zlib.compress(raw, level=9)).decode("ascii")

    def _decode_segment_events(self, segment_payload: dict[str, Any]) -> list[dict[str, Any]]:
        if not isinstance(segment_payload, dict):
            return []
        if str(segment_payload.get("codec", "") or "") != "zlib":
            return []
        encoded = str(segment_payload.get("events_b64", "") or "")
        if not encoded:
            return []
        try:
            raw = zlib.decompress(base64.b64decode(encoded.encode("ascii")))
            decoded = json.loads(raw.decode("utf-8"))
        except Exception:
            return []
        return [evt for evt in decoded if isinstance(evt, dict)] if isinstance(decoded, list) else []

    def _build_segment_payload(
        self,
        *,
        gate_digest: str,
        segment_no: int,
        events: list[dict],
        prev_segment_hash: str = "",
    ) -> dict[str, Any]:
        encoded_events = self._encode_segment_events(events)
        first_event_id = str((events[0] or {}).get("event_id", "") or "") if events else ""
        last_event_id = str((events[-1] or {}).get("event_id", "") or "") if events else ""
        payload = {
            "version": GATE_SEGMENT_STORAGE_VERSION,
            "storage": "gate-segment-v1",
            "gate_digest": str(gate_digest or ""),
            "segment_no": int(segment_no),
            "prev_segment_hash": str(prev_segment_hash or ""),
            "count": len(events),
            "first_event_id": first_event_id,
            "last_event_id": last_event_id,
            "codec": "zlib",
            "encoding": "json",
            "events_b64": encoded_events,
        }
        payload["segment_hash"] = self._segment_material_hash(payload)
        return payload

    def _segment_meta_from_payload(self, payload: dict[str, Any], filename: str) -> dict[str, Any]:
        return {
            "segment_no": int(payload.get("segment_no", 0) or 0),
            "filename": str(filename or ""),
            "count": int(payload.get("count", 0) or 0),
            "first_event_id": str(payload.get("first_event_id", "") or ""),
            "last_event_id": str(payload.get("last_event_id", "") or ""),
            "prev_segment_hash": str(payload.get("prev_segment_hash", "") or ""),
            "segment_hash": str(payload.get("segment_hash", "") or ""),
        }

    def _read_gate_manifest(self, gate_id: str) -> dict[str, Any] | None:
        try:
            manifest = read_domain_json(
                GATE_STORAGE_DOMAIN,
                self._gate_manifest_filename(gate_id),
                lambda: {},
                base_dir=self._gate_storage_base_dir(),
            )
        except Exception:
            return None
        if not isinstance(manifest, dict):
            return None
        if str(manifest.get("storage", "") or "") != "gate-segments-v1":
            return None
        return manifest

    def _read_segment_file(self, filename: str) -> tuple[dict[str, Any], list[dict[str, Any]]]:
        payload = read_domain_json(
            GATE_STORAGE_DOMAIN,
            filename,
            lambda: {},
            base_dir=self._gate_storage_base_dir(),
        )
        if not isinstance(payload, dict):
            return {}, []
        expected_hash = str(payload.get("segment_hash", "") or "")
        if expected_hash and expected_hash != self._segment_material_hash(payload):
            logger.warning("Gate segment hash mismatch for %s", filename)
            return {}, []
        return payload, self._decode_segment_events(payload)

    def _load_segmented_gates(self) -> set[str]:
        encrypted_dir = self._gate_domain_dir()
        loaded_digests: set[str] = set()
        if not encrypted_dir.exists():
            return loaded_digests
        for manifest_path in sorted(encrypted_dir.glob("gate_*.manifest.json")):
            try:
                manifest = read_domain_json(
                    GATE_STORAGE_DOMAIN,
                    manifest_path.name,
                    lambda: {},
                    base_dir=self._gate_storage_base_dir(),
                )
            except Exception:
                continue
            if not isinstance(manifest, dict) or str(manifest.get("storage", "") or "") != "gate-segments-v1":
                continue
            gate_digest = str(manifest.get("gate_digest", "") or "")
            if gate_digest:
                loaded_digests.add(gate_digest)
            segments = manifest.get("segments", [])
            if not isinstance(segments, list):
                continue
            for segment_meta in sorted(
                [item for item in segments if isinstance(item, dict)],
                key=lambda item: int(item.get("segment_no", 0) or 0),
            ):
                filename = str(segment_meta.get("filename", "") or "")
                if not filename:
                    continue
                _payload, events = self._read_segment_file(filename)
                for evt in events:
                    payload = evt.get("payload") or {}
                    if not isinstance(payload, dict):
                        continue
                    gate_id = str(payload.get("gate", "") or "").strip().lower()
                    if not gate_id:
                        continue
                    storage_event = _sanitize_private_gate_event(gate_id, evt)
                    if not str(storage_event.get("event_id", "") or "").strip():
                        storage_event["event_id"] = self._synth_event_id(gate_id, storage_event)
                    replay_fingerprint = build_gate_replay_fingerprint(gate_id, storage_event)
                    if replay_fingerprint in self._replay_index:
                        continue
                    event_id = str(storage_event.get("event_id", "") or "")
                    if event_id and event_id in self._event_index:
                        continue
                    self._gates.setdefault(gate_id, []).append(storage_event)
                    if event_id:
                        self._event_index[event_id] = storage_event
                    self._remember_replay_fingerprint(replay_fingerprint, storage_event)
        return loaded_digests

    def _load(self) -> None:
        encrypted_dir = self._gate_domain_dir()
        if not self._data_dir.exists() and not encrypted_dir.exists():
            return
        segmented_digests = self._load_segmented_gates()
        dirty_gates: set[str] = set()
        file_names = {
            path.name for path in self._data_dir.glob("gate_*.jsonl")
        } | {
            path.name for path in encrypted_dir.glob("gate_*.jsonl")
        }
        for file_name in sorted(file_names):
            digest = file_name.removeprefix("gate_").removesuffix(".jsonl")
            if digest in segmented_digests:
                continue
            events: list[dict[str, Any]] | None = None
            encrypted_path = encrypted_dir / file_name
            loaded_from_legacy_domain_list = False
            if encrypted_path.exists():
                try:
                    loaded = read_domain_json(
                        GATE_STORAGE_DOMAIN,
                        file_name,
                        lambda: [],
                        base_dir=self._gate_storage_base_dir(),
                    )
                    if isinstance(loaded, list):
                        events = [evt for evt in loaded if isinstance(evt, dict)]
                        loaded_from_legacy_domain_list = True
                except Exception:
                    events = None
            if events is None:
                legacy_path = self._data_dir / file_name
                if not legacy_path.exists():
                    continue
                try:
                    lines = legacy_path.read_text(encoding="utf-8").splitlines()
                except Exception:
                    continue
                events = []
                for line in lines:
                    if not line.strip():
                        continue
                    try:
                        evt = json.loads(line)
                    except Exception:
                        continue
                    if isinstance(evt, dict):
                        events.append(evt)
            loaded_gate_ids: set[str] = set()
            for evt in events:
                payload = evt.get("payload") or {}
                if not isinstance(payload, dict):
                    continue
                gate_id = str(payload.get("gate", "") or "").strip().lower()
                if not gate_id:
                    continue
                storage_event = _sanitize_private_gate_event(gate_id, evt)
                if storage_event != evt:
                    dirty_gates.add(gate_id)
                evt = storage_event
                if not str(evt.get("event_id", "") or "").strip():
                    evt["event_id"] = self._synth_event_id(gate_id, evt)
                    dirty_gates.add(gate_id)
                loaded_gate_ids.add(gate_id)
                replay_fingerprint = build_gate_replay_fingerprint(gate_id, evt)
                if replay_fingerprint in self._replay_index:
                    dirty_gates.add(gate_id)
                    continue
                event_id = str(evt.get("event_id", "") or "")
                if event_id and event_id in self._event_index:
                    dirty_gates.add(gate_id)
                    continue
                self._gates.setdefault(gate_id, []).append(evt)
                if event_id:
                    self._event_index[event_id] = evt
                self._remember_replay_fingerprint(replay_fingerprint, evt)
            if loaded_from_legacy_domain_list or not encrypted_path.exists():
                dirty_gates.update(loaded_gate_ids)
        self._prune_replay_index()
        for gate_id in list(self._gates.keys()):
            self._sort_gate(gate_id)
        for gate_id in sorted(dirty_gates):
            self._persist_gate(gate_id)

    def _persist_gate(self, gate_id: str, events: list[dict] | None = None) -> None:
        if events is None:
            events = self._gates.get(gate_id, [])
        gate_key = str(gate_id or "").strip().lower()
        if not gate_key:
            return
        gate_digest = self._gate_digest(gate_key)
        old_manifest = self._read_gate_manifest(gate_key)
        old_segment_files = {
            str(item.get("filename", "") or "")
            for item in list((old_manifest or {}).get("segments", []) or [])
            if isinstance(item, dict)
        }
        clean_events = [_sanitize_private_gate_event(gate_key, evt) for evt in list(events or []) if isinstance(evt, dict)]
        segments: list[dict[str, Any]] = []
        prev_hash = ""
        written_segment_files: set[str] = set()
        for segment_no, start in enumerate(range(0, len(clean_events), GATE_SEGMENT_EVENT_TARGET)):
            chunk = clean_events[start : start + GATE_SEGMENT_EVENT_TARGET]
            filename = self._gate_segment_filename_for_digest(gate_digest, segment_no)
            segment_payload = self._build_segment_payload(
                gate_digest=gate_digest,
                segment_no=segment_no,
                events=chunk,
                prev_segment_hash=prev_hash,
            )
            write_domain_json(
                GATE_STORAGE_DOMAIN,
                filename,
                segment_payload,
                base_dir=self._gate_storage_base_dir(),
            )
            written_segment_files.add(filename)
            segments.append(self._segment_meta_from_payload(segment_payload, filename))
            prev_hash = str(segment_payload.get("segment_hash", "") or "")
        manifest = {
            "version": GATE_SEGMENT_STORAGE_VERSION,
            "storage": "gate-segments-v1",
            "gate_digest": gate_digest,
            "segment_event_target": GATE_SEGMENT_EVENT_TARGET,
            "segment_max_compressed_bytes": GATE_SEGMENT_MAX_COMPRESSED_BYTES,
            "total_events": len(clean_events),
            "segment_count": len(segments),
            "head_segment_hash": prev_hash,
            "segments": segments,
            "updated_at": int(time.time()),
        }
        write_domain_json(
            GATE_STORAGE_DOMAIN,
            self._gate_manifest_filename_for_digest(gate_digest),
            manifest,
            base_dir=self._gate_storage_base_dir(),
        )
        for stale_filename in old_segment_files - written_segment_files:
            if stale_filename:
                (self._gate_domain_dir() / stale_filename).unlink(missing_ok=True)
        legacy_domain_path = self._gate_domain_dir() / self._gate_legacy_domain_filename(gate_key)
        legacy_domain_path.unlink(missing_ok=True)
        self._gate_file_path(gate_id).unlink(missing_ok=True)

    def _persist_gate_new_events(self, gate_id: str, new_events: list[dict]) -> None:
        gate_key = str(gate_id or "").strip().lower()
        clean_new_events = [
            _sanitize_private_gate_event(gate_key, evt)
            for evt in list(new_events or [])
            if isinstance(evt, dict)
        ]
        if not gate_key or not clean_new_events:
            return
        manifest = self._read_gate_manifest(gate_key)
        if not manifest:
            self._persist_gate(gate_key, list(self._gates.get(gate_key, [])) + clean_new_events)
            return

        gate_digest = self._gate_digest(gate_key)
        segments = [
            dict(item)
            for item in list(manifest.get("segments", []) or [])
            if isinstance(item, dict)
        ]
        remaining = list(clean_new_events)
        if not segments:
            segment_no = 0
            events_for_segment: list[dict] = []
            filename = self._gate_segment_filename_for_digest(gate_digest, segment_no)
            prev_for_segment = ""
        else:
            last_meta = dict(segments[-1])
            segment_no = int(last_meta.get("segment_no", len(segments) - 1) or 0)
            filename = str(last_meta.get("filename", "") or self._gate_segment_filename_for_digest(gate_digest, segment_no))
            segment_payload, events_for_segment = self._read_segment_file(filename)
            prev_for_segment = str(segment_payload.get("prev_segment_hash", "") or last_meta.get("prev_segment_hash", "") or "")
            if not events_for_segment:
                self._persist_gate(gate_key, list(self._gates.get(gate_key, [])) + clean_new_events)
                return
        while remaining:
            candidate = events_for_segment + [remaining[0]]
            candidate_payload = self._build_segment_payload(
                gate_digest=gate_digest,
                segment_no=segment_no,
                events=candidate,
                prev_segment_hash=prev_for_segment,
            )
            compressed_len = len(str(candidate_payload.get("events_b64", "") or ""))
            if (
                events_for_segment
                and (
                    len(candidate) > GATE_SEGMENT_EVENT_TARGET
                    or compressed_len > GATE_SEGMENT_MAX_COMPRESSED_BYTES
                )
            ):
                segment_payload = self._build_segment_payload(
                    gate_digest=gate_digest,
                    segment_no=segment_no,
                    events=events_for_segment,
                    prev_segment_hash=prev_for_segment,
                )
                existing_meta_matches = (
                    bool(segments)
                    and _safe_int(segments[-1].get("segment_no", -1), -1) == segment_no
                    and str(segments[-1].get("segment_hash", "") or "") == str(segment_payload.get("segment_hash", "") or "")
                )
                if existing_meta_matches:
                    meta = segments[-1]
                else:
                    write_domain_json(
                        GATE_STORAGE_DOMAIN,
                        filename,
                        segment_payload,
                        base_dir=self._gate_storage_base_dir(),
                    )
                    meta = self._segment_meta_from_payload(segment_payload, filename)
                    if segments and _safe_int(segments[-1].get("segment_no", -1), -1) == segment_no:
                        segments[-1] = meta
                    else:
                        segments.append(meta)
                prev_for_segment = str(meta.get("segment_hash", "") or segment_payload.get("segment_hash", "") or "")
                segment_no += 1
                filename = self._gate_segment_filename_for_digest(gate_digest, segment_no)
                events_for_segment = []
                continue
            events_for_segment = candidate
            remaining.pop(0)

        segment_payload = self._build_segment_payload(
            gate_digest=gate_digest,
            segment_no=segment_no,
            events=events_for_segment,
            prev_segment_hash=prev_for_segment,
        )
        write_domain_json(
            GATE_STORAGE_DOMAIN,
            filename,
            segment_payload,
            base_dir=self._gate_storage_base_dir(),
        )
        meta = self._segment_meta_from_payload(segment_payload, filename)
        if segments and _safe_int(segments[-1].get("segment_no", -1), -1) == segment_no:
            segments[-1] = meta
        else:
            segments.append(meta)
        manifest = {
            "version": GATE_SEGMENT_STORAGE_VERSION,
            "storage": "gate-segments-v1",
            "gate_digest": gate_digest,
            "segment_event_target": GATE_SEGMENT_EVENT_TARGET,
            "segment_max_compressed_bytes": GATE_SEGMENT_MAX_COMPRESSED_BYTES,
            "total_events": int(manifest.get("total_events", 0) or 0) + len(clean_new_events),
            "segment_count": len(segments),
            "head_segment_hash": str(segment_payload.get("segment_hash", "") or ""),
            "segments": segments,
            "updated_at": int(time.time()),
        }
        write_domain_json(
            GATE_STORAGE_DOMAIN,
            self._gate_manifest_filename(gate_key),
            manifest,
            base_dir=self._gate_storage_base_dir(),
        )
        legacy_domain_path = self._gate_domain_dir() / self._gate_legacy_domain_filename(gate_key)
        legacy_domain_path.unlink(missing_ok=True)
        self._gate_file_path(gate_key).unlink(missing_ok=True)

    def _synth_event_id(self, gate_id: str, event: dict) -> str:
        payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
        material = {
            "gate": str(gate_id or "").strip().lower(),
            "event_type": str(event.get("event_type", "") or ""),
            "timestamp": float(event.get("timestamp", 0) or 0),
            "ciphertext": str(payload.get("ciphertext", "") or ""),
            "format": str(payload.get("format", "") or ""),
        }
        return hashlib.sha256(
            json.dumps(material, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
        ).hexdigest()

    def append(self, gate_id: str, event: dict) -> dict:
        gate_id = str(gate_id or "").strip().lower()
        if not gate_id:
            return event
        clean_event = _sanitize_private_gate_event(gate_id, event)
        if not str(clean_event.get("event_id", "") or "").strip():
            clean_event["event_id"] = self._synth_event_id(gate_id, clean_event)
        with self._lock:
            self._gates.setdefault(gate_id, [])
            replay_fingerprint = build_gate_replay_fingerprint(gate_id, clean_event)
            existing = self._replay_existing_event(replay_fingerprint)
            if existing is not None:
                return existing
            event_id = str(clean_event.get("event_id", "") or "")
            if event_id and event_id in self._event_index:
                return self._event_index[event_id]
            # Stage: build new gate list without mutating in-memory state yet
            staged = list(self._gates[gate_id]) + [clean_event]
            staged.sort(
                key=lambda evt: (
                    float(evt.get("timestamp", 0) or 0),
                    _safe_int(evt.get("sequence", 0) or 0, 0),
                    str(evt.get("event_id", "") or ""),
                )
            )
            # Persist first — raises on failure, no in-memory mutation yet
            self._persist_gate_new_events(gate_id, [clean_event])
            # Commit in-memory state only after durable persistence
            self._gates[gate_id] = staged
            if event_id:
                self._event_index[event_id] = clean_event
            self._remember_replay_fingerprint(replay_fingerprint, clean_event)
            self._maybe_prune_replay_index()
            self._change_condition.notify_all()
            return clean_event

    def get_messages(self, gate_id: str, limit: int = 20, offset: int = 0) -> list[dict]:
        messages, _cursor = self.get_messages_with_cursor(gate_id, limit=limit, offset=offset)
        return messages

    def get_messages_with_cursor(self, gate_id: str, limit: int = 20, offset: int = 0) -> tuple[list[dict], int]:
        gate_id = str(gate_id or "").strip().lower()
        with self._lock:
            msgs = self._gates.get(gate_id, [])
            cursor = len(msgs)
            return list(reversed(msgs))[offset : offset + limit], cursor

    def gate_cursor(self, gate_id: str) -> int:
        gate_id = str(gate_id or "").strip().lower()
        with self._lock:
            return len(self._gates.get(gate_id, []))

    def wait_for_gate_change(
        self,
        gate_id: str,
        after_cursor: int = 0,
        timeout_s: float = 20.0,
    ) -> tuple[bool, int]:
        gate_key = str(gate_id or "").strip().lower()
        if not gate_key:
            return False, 0
        target_cursor = max(0, _safe_int(after_cursor, 0))
        deadline = time.monotonic() + max(0.0, float(timeout_s or 0.0))
        with self._lock:
            current_cursor = len(self._gates.get(gate_key, []))
            if current_cursor > target_cursor:
                return True, current_cursor
            while True:
                remaining = deadline - time.monotonic()
                if remaining <= 0:
                    return False, len(self._gates.get(gate_key, []))
                self._change_condition.wait(timeout=remaining)
                current_cursor = len(self._gates.get(gate_key, []))
                if current_cursor > target_cursor:
                    return True, current_cursor

    def wait_for_any_gate_change(
        self,
        gate_cursors: dict[str, int],
        timeout_s: float = 20.0,
    ) -> dict[str, int]:
        normalized = {
            str(gate_id or "").strip().lower(): max(0, _safe_int(cursor, 0))
            for gate_id, cursor in dict(gate_cursors or {}).items()
            if str(gate_id or "").strip()
        }
        if not normalized:
            return {}
        deadline = time.monotonic() + max(0.0, float(timeout_s or 0.0))

        def _changed() -> dict[str, int]:
            updates: dict[str, int] = {}
            for gate_id, after_cursor in normalized.items():
                current_cursor = len(self._gates.get(gate_id, []))
                if current_cursor > after_cursor:
                    updates[gate_id] = current_cursor
            return updates

        with self._lock:
            updates = _changed()
            if updates:
                return updates
            while True:
                remaining = deadline - time.monotonic()
                if remaining <= 0:
                    return {}
                self._change_condition.wait(timeout=remaining)
                updates = _changed()
                if updates:
                    return updates

    def known_gate_ids(self) -> list[str]:
        with self._lock:
            return sorted(self._gates.keys())

    def get_event(self, event_id: str) -> dict | None:
        with self._lock:
            return self._event_index.get(str(event_id or ""))

    def stamp_local_plaintext(
        self,
        gate_id: str,
        event_id: str,
        plaintext: str,
        reply_to: str = "",
    ) -> bool:
        """Stamp decrypted plaintext onto a stored event and re-persist.

        This is the durable path for the leave/rejoin invariant: once a message
        is decrypted (by any path — MLS, envelope, self-echo), the plaintext is
        written into the private chain so it survives restarts and MLS epoch
        resets.  The ``_local_plaintext`` / ``_local_reply_to`` fields are
        local-only and never transmitted to peers.
        """
        gate_id = str(gate_id or "").strip().lower()
        event_id = str(event_id or "").strip()
        if not gate_id or not event_id or not plaintext:
            return False
        with self._lock:
            evt = self._event_index.get(event_id)
            if evt is None:
                return False
            payload = evt.get("payload")
            if not isinstance(payload, dict):
                return False
            if payload.get("_local_plaintext"):
                return True  # already stamped
            payload["_local_plaintext"] = plaintext
            if reply_to:
                payload["_local_reply_to"] = reply_to
            self._persist_gate(gate_id)
            return True

    def lookup_local_plaintext(
        self,
        gate_id: str,
        event_id: str,
    ) -> tuple[str, str] | None:
        """Return stamped plaintext for an event, or None."""
        event_id = str(event_id or "").strip()
        if not event_id:
            return None
        with self._lock:
            evt = self._event_index.get(event_id)
            if evt is None:
                return None
            payload = evt.get("payload")
            if not isinstance(payload, dict):
                return None
            pt = payload.get("_local_plaintext")
            if not isinstance(pt, str) or not pt:
                return None
            return pt, str(payload.get("_local_reply_to", "") or "")

    def ingest_peer_events(self, gate_id: str, events: list[dict]) -> dict:
        gate_id = str(gate_id or "").strip().lower()
        duplicates = 0
        rejected = 0
        if not gate_id:
            return {"accepted": 0, "duplicates": 0, "rejected": 0}
        with self._lock:
            self._gates.setdefault(gate_id, [])
            # Collect validated candidates without mutating in-memory state
            candidates: list[tuple[dict, str, str]] = []  # (clean_event, event_id, fingerprint)
            batch_fingerprints: set[str] = set()
            batch_event_ids: set[str] = set()
            for evt in events:
                if not isinstance(evt, dict):
                    rejected += 1
                    continue
                event_id = str(evt.get("event_id", "") or "")
                payload = evt.get("payload")
                if not isinstance(payload, dict):
                    rejected += 1
                    continue
                if not payload.get("ciphertext"):
                    rejected += 1
                    continue
                if evt.get("event_type") != "gate_message":
                    rejected += 1
                    continue
                ts = evt.get("timestamp", 0)
                now = time.time()
                if not isinstance(ts, (int, float)) or ts > now + 300 or ts < now - 86400 * 30:
                    rejected += 1
                    continue
                replay_fingerprint = build_gate_replay_fingerprint(gate_id, evt)
                if replay_fingerprint in self._replay_index or replay_fingerprint in batch_fingerprints:
                    duplicates += 1
                    continue
                if event_id:
                    if len(event_id) != 64:
                        rejected += 1
                        continue
                    try:
                        int(event_id, 16)
                    except ValueError:
                        rejected += 1
                        continue
                else:
                    event_id = ""
                if event_id and (event_id in self._event_index or event_id in batch_event_ids):
                    duplicates += 1
                    continue
                ok, reason, clean_event = _verify_private_gate_transport_event(gate_id, evt)
                if not ok or clean_event is None:
                    logger.warning("Rejected private gate peer event: %s", reason)
                    rejected += 1
                    continue
                event_id = str(clean_event.get("event_id", "") or "")
                if event_id in self._event_index or event_id in batch_event_ids:
                    duplicates += 1
                    continue
                candidates.append((clean_event, event_id, replay_fingerprint))
                batch_fingerprints.add(replay_fingerprint)
                if event_id:
                    batch_event_ids.add(event_id)
            if not candidates:
                return {"accepted": 0, "duplicates": duplicates, "rejected": rejected}
            # Stage: build new gate list without mutating in-memory state
            staged = list(self._gates[gate_id])
            for clean_event, _, _ in candidates:
                staged.append(clean_event)
            staged.sort(
                key=lambda evt: (
                    float(evt.get("timestamp", 0) or 0),
                    _safe_int(evt.get("sequence", 0) or 0, 0),
                    str(evt.get("event_id", "") or ""),
                )
            )
            # Persist first — raises on failure, no in-memory mutation yet
            self._persist_gate_new_events(gate_id, [clean_event for clean_event, _, _ in candidates])
            # Commit in-memory state only after durable persistence
            self._gates[gate_id] = staged
            for clean_event, event_id, replay_fingerprint in candidates:
                if event_id:
                    self._event_index[event_id] = clean_event
                self._remember_replay_fingerprint(replay_fingerprint, clean_event)
            self._maybe_prune_replay_index()
        return {"accepted": len(candidates), "duplicates": duplicates, "rejected": rejected}


class ReplayFilter:
    """Bounded bloom-style replay filter with rotation."""

    def __init__(
        self,
        *,
        size_bits: int = REPLAY_FILTER_BITS,
        hash_count: int = REPLAY_FILTER_HASHES,
        rotate_s: int = REPLAY_FILTER_ROTATE_S,
    ) -> None:
        self._size_bits = max(1024, int(size_bits))
        self._hash_count = max(2, int(hash_count))
        self._rotate_s = max(60, int(rotate_s))
        self._salt = os.urandom(16)
        self._active = bytearray(self._size_bits // 8 + 1)
        self._previous = bytearray(self._size_bits // 8 + 1)
        self._last_rotate = time.time()

    def _rotate_if_needed(self) -> None:
        now = time.time()
        if now - self._last_rotate < self._rotate_s:
            return
        self._previous = self._active
        self._active = bytearray(self._size_bits // 8 + 1)
        self._last_rotate = now

    def _positions(self, value: str) -> list[int]:
        positions: list[int] = []
        data = value.encode("utf-8")
        for idx in range(self._hash_count):
            digest = hashlib.sha256(self._salt + idx.to_bytes(2, "big") + data).digest()
            pos = int.from_bytes(digest[:8], "big") % self._size_bits
            positions.append(pos)
        return positions

    def add(self, value: str) -> None:
        self._rotate_if_needed()
        for pos in self._positions(value):
            byte_idx = pos // 8
            bit = 1 << (pos % 8)
            self._active[byte_idx] |= bit

    def seen(self, value: str) -> bool:
        self._rotate_if_needed()
        for pos in self._positions(value):
            byte_idx = pos // 8
            bit = 1 << (pos % 8)
            if not (self._active[byte_idx] & bit or self._previous[byte_idx] & bit):
                return False
        return True


class ChainEvent:
    """Single event on the Infonet."""

    __slots__ = (
        "event_id",
        "prev_hash",
        "event_type",
        "node_id",
        "payload",
        "timestamp",
        "sequence",
        "signature",
        "network_id",
        "public_key",
        "public_key_algo",
        "protocol_version",
    )

    def __init__(
        self,
        prev_hash: str,
        event_type: str,
        node_id: str,
        payload: dict,
        timestamp: float = 0,
        sequence: int = 0,
        signature: str = "",
        network_id: str = "",
        public_key: str = "",
        public_key_algo: str = "",
        protocol_version: str = "",
    ):
        self.prev_hash = prev_hash
        self.event_type = event_type
        self.node_id = node_id
        self.payload = payload
        self.timestamp = timestamp or time.time()
        self.sequence = sequence
        self.signature = signature
        self.network_id = network_id or NETWORK_ID
        self.public_key = public_key
        self.public_key_algo = public_key_algo
        self.protocol_version = protocol_version or PROTOCOL_VERSION
        # Compute deterministic event ID
        self.event_id = self._compute_hash()

    def _compute_hash(self) -> str:
        """Deterministic SHA-256 hash of the event content."""
        content = (
            f"{self.prev_hash}:{self.event_type}:{self.node_id}:"
            f"{json.dumps(self.payload, sort_keys=True, separators=(',', ':'), ensure_ascii=False)}:"
            f"{self.timestamp}:{self.sequence}:{self.network_id}"
        )
        return hashlib.sha256(content.encode("utf-8")).hexdigest()

    def to_dict(self) -> dict:
        return {
            "event_id": self.event_id,
            "prev_hash": self.prev_hash,
            "event_type": self.event_type,
            "node_id": self.node_id,
            "payload": self.payload,
            "timestamp": self.timestamp,
            "sequence": self.sequence,
            "signature": self.signature,
            "network_id": self.network_id,
            "public_key": self.public_key,
            "public_key_algo": self.public_key_algo,
            "protocol_version": self.protocol_version,
        }

    @classmethod
    def from_dict(cls, d: dict) -> "ChainEvent":
        evt = cls(
            prev_hash=d["prev_hash"],
            event_type=d["event_type"],
            node_id=d["node_id"],
            payload=d["payload"],
            timestamp=d["timestamp"],
            sequence=d.get("sequence", 0),
            signature=d.get("signature", ""),
            network_id=d.get("network_id", NETWORK_ID),
            public_key=d.get("public_key", ""),
            public_key_algo=d.get("public_key_algo", ""),
            protocol_version=d.get("protocol_version", PROTOCOL_VERSION),
        )
        # Verify hash matches
        if evt.event_id != d.get("event_id"):
            raise ValueError(
                f"Hash mismatch on event load: computed {evt.event_id[:16]}, "
                f"stored {d.get('event_id', '?')[:16]}"
            )
        return evt


class Infonet:
    """The Infonet — ShadowBroker's append-only signed event ledger.

    The Infonet is the single source of truth. All actions go through here.
    The reputation ledger, gates, and oracle are computed views of Infonet state.
    """

    def __init__(self):
        self.events: list[dict] = []  # Stored as dicts for efficiency
        self.head_hash: str = GENESIS_HASH  # Hash of the latest event
        self.node_sequences: dict[str, int] = {}  # {node_id: last_sequence}
        self.sequence_domains: dict[str, int] = {}  # {node_id|domain: last_sequence}
        self.event_index: dict[str, int] = {}  # {event_id: index in events list}
        self.public_key_bindings: dict[str, str] = {}  # {public_key: canonical node_id}
        self.revocations: dict[str, dict] = {}
        self._cold_segments: list[dict] = []  # Archived prefix metadata (P8)
        self._replay_filter = ReplayFilter()
        self._last_validated_index: int = 0  # For incremental validation
        # Running counters — avoid O(N) scans in get_info()
        self._type_counts: dict[str, int] = {}
        self._active_count: int = 0
        self._registered_nodes: set[str] = set()
        self._chain_bytes: int = 2  # Start with "[]" empty JSON array
        self._dirty = False
        self._save_lock = threading.Lock()
        self._save_timer: threading.Timer | None = None
        self._SAVE_INTERVAL = 5.0  # seconds — coalesce writes
        # Issue #208: Merkle levels cache so get_merkle_proofs() doesn't
        # rebuild O(n) levels on every public call. Invalidated whenever
        # self.events mutates. Computed lazily on first read after an
        # invalidation.
        self._merkle_levels_cache: list[list[str]] | None = None
        self._merkle_levels_for_event_count: int = -1
        atexit.register(self._flush)
        self._load()

    def _invalidate_merkle_cache(self) -> None:
        """Clear the precomputed Merkle levels.

        Called whenever ``self.events`` may have mutated (append, rebuild,
        cleanup, fork resolution). The next call to ``get_merkle_root()``
        or ``get_merkle_proofs()`` will recompute and re-cache.
        """
        self._merkle_levels_cache = None
        self._merkle_levels_for_event_count = -1

    def _get_merkle_levels(self) -> list[list[str]]:
        """Return Merkle levels for the current chain, recomputing if
        the cache is invalid or out of date.

        Issue #208: a public endpoint (``/api/mesh/infonet/sync?include_proofs=true``)
        used to rebuild Merkle levels on every request, which is O(n) in
        chain length and trivially abusable for CPU exhaustion. By caching
        the levels and invalidating on mutation, repeated proof requests
        become O(1) per proof; the rebuild only happens after a genuine
        append/rebuild/cleanup.
        """
        from services.mesh.mesh_merkle import build_merkle_levels

        current_count = len(self.events)
        if (
            self._merkle_levels_cache is not None
            and self._merkle_levels_for_event_count == current_count
        ):
            return self._merkle_levels_cache

        leaves = [e["event_id"] for e in self.events]
        levels = build_merkle_levels(leaves)
        self._merkle_levels_cache = levels
        self._merkle_levels_for_event_count = current_count
        return levels

    # ─── Persistence ──────────────────────────────────────────────────

    def _load(self):
        """Load Infonet from disk, self-healing on corruption.

        Sprint 2 / Rec #8: if the chain file or WAL is unreadable we
        quarantine the bad files, reset to genesis, and let the peer
        sync worker rebuild state from the network. The user never sees
        a crashed backend — recovery happens in the background.
        """
        if CHAIN_FILE.exists():
            try:
                data = json.loads(CHAIN_FILE.read_text(encoding="utf-8"))
                loaded_events = data.get("events", [])
                if not isinstance(loaded_events, list):
                    raise ValueError("Malformed chain: events must be a list")
                for evt in loaded_events:
                    if not isinstance(evt, dict):
                        raise ValueError("Malformed chain: event entry must be an object")
                    ChainEvent.from_dict(evt)
                self.events = loaded_events
                self.head_hash = data.get("head_hash", GENESIS_HASH)
                self.node_sequences = data.get("node_sequences", {})
                self.sequence_domains = data.get("sequence_domains", {})
                cold = data.get("cold_segments") or []
                self._cold_segments = list(cold) if isinstance(cold, list) else []
                self._rebuild_state()
                self._rebuild_revocations()
                self._rebuild_counters()
                # Honor MAX_CHAIN_MEMORY after load (archive overflow, keep hot window).
                if len(self.events) > MAX_CHAIN_MEMORY:
                    self._enforce_memory_cap()
                    self._flush()
                logger.info(
                    f"Loaded Infonet: {len(self.events)} events, head={self.head_hash[:16]}..."
                )
            except Exception as e:
                logger.error("Failed to load Infonet: %s — quarantining and resetting", e)
                self._quarantine_chain_file(reason=f"load_failed:{e}")
                self._reset_to_genesis()
        try:
            self._replay_wal()
        except RuntimeError as exc:
            # WAL quarantine already happened inside _replay_wal — the
            # chain advances we lost will re-flow from peers. Degraded
            # state, not a crash.
            logger.error("[infonet] WAL replay failed, continuing in re-sync mode: %s", exc)
            self._reset_to_genesis()

    def _quarantine_chain_file(self, *, reason: str) -> None:
        """Move a corrupt chain file aside so the next boot starts clean."""
        try:
            if not CHAIN_FILE.exists():
                return
            stamp = int(time.time())
            dest = CHAIN_FILE.with_suffix(f".json.quarantine.{stamp}")
            CHAIN_FILE.rename(dest)
            logger.error(
                "[infonet] Chain file quarantined (%s) → %s. Node will re-sync from peers.",
                reason,
                dest.name,
            )
        except Exception as exc:
            logger.error("[infonet] Failed to quarantine chain file: %s", exc)

    def _reset_to_genesis(self) -> None:
        """In-memory reset to empty state so peer sync can rebuild."""
        self.events = []
        self.head_hash = GENESIS_HASH
        self.node_sequences = {}
        self.sequence_domains = {}
        self.event_index = {}
        self.public_key_bindings = {}
        self.revocations = {}
        self._replay_filter = ReplayFilter()
        self._last_validated_index = 0
        self._type_counts = {}
        self._active_count = 0
        self._registered_nodes = set()
        self._chain_bytes = 2

    def _rebuild_state(self) -> None:
        self.event_index = {}
        self.node_sequences = {}
        # Keep private signed-write replay domains that are not represented
        # on-chain, but rebuild the gate_message sequence domain from chain
        # events so reloads/fork application do not mix it with public
        # per-node message sequences.
        preserved_domains = {}
        if isinstance(getattr(self, "sequence_domains", None), dict):
            preserved_domains = {
                key: value
                for key, value in self.sequence_domains.items()
                if not str(key or "").endswith("|gate_message")
            }
        self.sequence_domains = dict(preserved_domains)
        self.public_key_bindings = {}
        self.revocations = {}
        self._replay_filter = ReplayFilter()
        for idx, evt in enumerate(self.events):
            event_id = evt.get("event_id", "")
            if event_id:
                self.event_index[event_id] = idx
                self._replay_filter.add(event_id)
            node_id = evt.get("node_id", "")
            sequence = _safe_int(evt.get("sequence", 0) or 0, 0)
            if node_id and sequence:
                sequence_table, sequence_key = self._sequence_table_for_event(
                    evt.get("event_type", ""), node_id
                )
                last = sequence_table.get(sequence_key, 0)
                if sequence > last:
                    sequence_table[sequence_key] = sequence
            public_key = str(evt.get("public_key", "") or "")
            if public_key and node_id:
                existing = self.public_key_bindings.get(public_key)
                if not existing:
                    self.public_key_bindings[public_key] = node_id
                elif existing != node_id:
                    logger.warning(
                        "Public key binding conflict in stored chain for %s: %s vs %s",
                        public_key[:12],
                        _redact_node(existing),
                        _redact_node(node_id),
                    )
            if evt.get("event_type") == "key_revoke":
                self._apply_revocation(evt)
        if self.events:
            self.head_hash = self.events[-1].get("event_id", GENESIS_HASH)
        else:
            self.head_hash = GENESIS_HASH

    def _rebuild_counters(self) -> None:
        """Rebuild running counters from the full event list (called on load)."""
        now = time.time()
        self._type_counts = {}
        self._active_count = 0
        self._registered_nodes = set()
        self._chain_bytes = 2  # "[]"
        for evt in self.events:
            t = evt.get("event_type", "unknown")
            self._type_counts[t] = self._type_counts.get(t, 0) + 1
            if t == "node_register":
                node_id = str(evt.get("node_id", "") or "")
                if node_id:
                    self._registered_nodes.add(node_id)
            is_eph = evt.get("payload", {}).get("ephemeral") or evt.get("payload", {}).get("_ephemeral")
            if not is_eph or (now - evt.get("timestamp", 0)) < EPHEMERAL_TTL:
                self._active_count += 1
            self._chain_bytes += len(json.dumps(evt)) + 2  # +2 for ", " separator

    def _update_counters_for_event(self, evt: dict) -> None:
        """Incrementally update counters when a new event is appended."""
        t = evt.get("event_type", "unknown")
        self._type_counts[t] = self._type_counts.get(t, 0) + 1
        if t == "node_register":
            node_id = str(evt.get("node_id", "") or "")
            if node_id:
                self._registered_nodes.add(node_id)
        self._active_count += 1
        self._chain_bytes += len(json.dumps(evt)) + 2

    def _write_wal(self, event_dict: dict) -> None:
        try:
            DATA_DIR.mkdir(parents=True, exist_ok=True)
            _atomic_write_text(WAL_FILE, json.dumps({"event": event_dict}), encoding="utf-8")
        except Exception as e:
            logger.error(f"Failed to write WAL: {e}")

    def _clear_wal(self) -> None:
        try:
            if WAL_FILE.exists():
                WAL_FILE.unlink()
        except Exception as e:
            logger.error(f"Failed to clear WAL: {e}")

    def _replay_wal(self) -> None:
        """Replay any surviving WAL entry after a crash, fail-closed.

        Sprint 2 / Rec #8: a corrupt or unreplayable WAL means the node
        crashed mid-append — we do NOT silently discard it. Instead we
        quarantine the WAL file, log loudly, and raise so the caller
        (__init__ via _load) can surface the degraded state rather than
        pretending the chain is healthy. The user never gets a silent
        data-loss window.
        """
        if not WAL_FILE.exists():
            return
        try:
            data = json.loads(WAL_FILE.read_text(encoding="utf-8"))
        except Exception as exc:
            self._quarantine_wal(f"corrupt_json:{exc}")
            raise RuntimeError(
                "Infonet WAL is corrupt — quarantined. Chain is in a degraded state; "
                "recover by re-syncing from peers."
            ) from exc
        evt = data.get("event") if isinstance(data, dict) else None
        if not isinstance(evt, dict):
            self._quarantine_wal("malformed_shape")
            raise RuntimeError(
                "Infonet WAL shape invalid — quarantined. Chain is in a degraded state."
            )
        if evt.get("event_id") in self.event_index:
            # Already durable in the chain file — WAL is stale but safe.
            self._clear_wal()
            return
        if evt.get("prev_hash") != self.head_hash:
            # The WAL entry is for an older head — the chain advanced past
            # it through another path. Safe to drop.
            self._clear_wal()
            return
        try:
            result = self.ingest_events([evt])
        except Exception as exc:
            self._quarantine_wal(f"replay_raised:{exc}")
            raise RuntimeError(
                "Infonet WAL event failed to replay — quarantined. Recover by re-syncing."
            ) from exc
        if not result.get("accepted"):
            self._quarantine_wal("replay_rejected")
            raise RuntimeError(
                f"Infonet WAL event rejected on replay: {result.get('rejected') or 'unknown'}"
            )
        logger.info("Replayed WAL event after restart")
        # Force a synchronous flush so the replayed event is durable
        # before we hand control back to the rest of the boot sequence.
        # _flush() clears the WAL as part of a successful write.
        self._flush()

    def _quarantine_wal(self, reason: str) -> None:
        """Move a bad WAL file aside so subsequent boots don't loop on it."""
        try:
            if not WAL_FILE.exists():
                return
            stamp = int(time.time())
            dest = WAL_FILE.with_suffix(f".wal.quarantine.{stamp}")
            WAL_FILE.rename(dest)
            logger.error(
                "[infonet] WAL quarantined (%s) → %s. Node is degraded until re-sync.",
                reason,
                dest.name,
            )
        except Exception as exc:
            logger.error("[infonet] Failed to quarantine WAL: %s", exc)

    def reset_chain(self) -> None:
        """Wipe local chain state so the next sync starts from genesis.

        Used for automatic fork recovery when the local chain is small and
        has diverged from the network.  Does NOT touch gate_store or WAL.
        """
        prev_len = len(self.events)
        self.events = []
        self.head_hash = GENESIS_HASH
        self.node_sequences = {}
        self.sequence_domains = {}
        self.event_index = {}
        self.public_key_bindings = {}
        self.revocations = {}
        self._replay_filter = ReplayFilter()
        self._last_validated_index = 0
        self._type_counts = {}
        self._active_count = 0
        self._chain_bytes = 2
        self._dirty = True
        self._flush()
        logger.warning("Chain reset: discarded %d local events for fork recovery", prev_len)

    def _save(self):
        """Mark dirty and schedule a coalesced disk write.

        Instead of writing multi-MB JSON on every event, we set a dirty flag
        and schedule a single write after _SAVE_INTERVAL seconds. Multiple
        rapid calls collapse into one I/O operation.
        """
        self._enforce_memory_cap()
        self._dirty = True
        with self._save_lock:
            if self._save_timer is None or not self._save_timer.is_alive():
                self._save_timer = threading.Timer(self._SAVE_INTERVAL, self._flush)
                self._save_timer.daemon = True
                self._save_timer.start()

    def _flush(self):
        """Actually write to disk (called by timer or atexit).

        Sprint 2 / Rec #8: clears the WAL only after the chain file has
        been durably written. A crash before _flush() succeeds leaves
        the WAL in place so _replay_wal() can recover on next boot.

        P8: compact orjson (no indent=2) — flush cost scales with chain size.
        """
        if not self._dirty:
            return
        try:
            DATA_DIR.mkdir(parents=True, exist_ok=True)
            data = {
                "protocol": "infonet",
                "network_id": NETWORK_ID,
                "head_hash": self.head_hash,
                "node_sequences": self.node_sequences,
                "sequence_domains": self.sequence_domains,
                "cold_segments": list(getattr(self, "_cold_segments", []) or []),
                "events": self.events,
            }
            _atomic_write_bytes(CHAIN_FILE, orjson.dumps(data, option=orjson.OPT_NON_STR_KEYS))
            self._dirty = False
            # Chain file is now durable — safe to retire the WAL entry.
            self._clear_wal()
        except Exception as e:
            logger.error(f"Failed to save Infonet: {e}")

    def ensure_materialized(self) -> None:
        """Write the current chain state to disk even if nothing is dirty yet."""
        try:
            DATA_DIR.mkdir(parents=True, exist_ok=True)
            data = {
                "protocol": "infonet",
                "network_id": NETWORK_ID,
                "head_hash": self.head_hash,
                "node_sequences": self.node_sequences,
                "sequence_domains": self.sequence_domains,
                "cold_segments": list(getattr(self, "_cold_segments", []) or []),
                "events": self.events,
            }
            _atomic_write_bytes(CHAIN_FILE, orjson.dumps(data, option=orjson.OPT_NON_STR_KEYS))
        except Exception as e:
            logger.error(f"Failed to materialize Infonet: {e}")
            raise

    def _enforce_memory_cap(self) -> None:
        """Spill oldest events to cold segments when RAM exceeds MAX_CHAIN_MEMORY.

        History is archived on disk (not deleted). Hot chain + indexes stay
        coherent for head/sync; deep history remains in cold segments.
        """
        if len(self.events) <= MAX_CHAIN_MEMORY:
            return
        overflow = len(self.events) - MAX_CHAIN_MEMORY
        cold = self.events[:overflow]
        CHAIN_COLD_DIR.mkdir(parents=True, exist_ok=True)
        if not hasattr(self, "_cold_segments") or self._cold_segments is None:
            self._cold_segments = []
        seg_no = len(self._cold_segments)
        filename = f"cold_{seg_no:08d}.orjson"
        path = CHAIN_COLD_DIR / filename
        _atomic_write_bytes(path, orjson.dumps(cold, option=orjson.OPT_NON_STR_KEYS))
        self._cold_segments.append(
            {
                "filename": filename,
                "count": len(cold),
                "first_id": str(cold[0].get("event_id", "") or "") if cold else "",
                "last_id": str(cold[-1].get("event_id", "") or "") if cold else "",
            }
        )
        self.events = self.events[overflow:]
        # Rebuild indexes for the hot window only.
        self.event_index = {
            str(evt.get("event_id", "") or ""): idx
            for idx, evt in enumerate(self.events)
            if evt.get("event_id")
        }
        self._rebuild_counters()
        self._invalidate_merkle_cache()
        self._dirty = True
        logger.info(
            "Infonet memory cap: archived %d events to %s (hot=%d)",
            overflow,
            filename,
            len(self.events),
        )

    def confirmations_for_event(self, event_id: str) -> int:
        idx = self.event_index.get(event_id)
        if idx is None:
            return 0
        return max(0, len(self.events) - 1 - idx)

    def decorate_event(self, evt: dict) -> dict:
        if evt.get("event_type") not in CRITICAL_EVENT_TYPES:
            return evt
        confirmations = self.confirmations_for_event(evt.get("event_id", ""))
        decorated = dict(evt)
        decorated["confirmations"] = confirmations
        decorated["confirmed"] = confirmations >= MIN_CONFIRMATIONS_CRITICAL
        return decorated

    def decorate_events(self, events: list[dict]) -> list[dict]:
        return [self.decorate_event(evt) for evt in events]

    def chain_lock(self) -> dict:
        if len(self.events) <= CHAIN_LOCK_DEPTH:
            return {"depth": CHAIN_LOCK_DEPTH, "event_id": "", "active": False}
        idx = max(0, len(self.events) - 1 - CHAIN_LOCK_DEPTH)
        return {
            "depth": CHAIN_LOCK_DEPTH,
            "event_id": self.events[idx].get("event_id", ""),
            "active": True,
        }

    def _bind_public_key(self, public_key: str, node_id: str) -> tuple[bool, str]:
        key = str(public_key or "")
        node = str(node_id or "")
        if not key or not node:
            return False, "Missing public key binding fields"
        existing = self.public_key_bindings.get(key)
        if existing and existing != node:
            return False, f"public key already bound to {existing}"
        self.public_key_bindings[key] = node
        return True, "ok"

    def _apply_revocation(self, evt: dict) -> None:
        payload = evt.get("payload", {})
        public_key = payload.get("revoked_public_key") or evt.get("public_key", "")
        if not public_key:
            return
        revoked_at = _safe_int(payload.get("revoked_at", 0) or 0, 0)
        grace_until = _safe_int(payload.get("grace_until", revoked_at) or revoked_at, revoked_at)
        info = {
            "public_key": public_key,
            "public_key_algo": payload.get("revoked_public_key_algo") or evt.get("public_key_algo"),
            "revoked_at": revoked_at,
            "grace_until": grace_until,
            "reason": payload.get("reason", ""),
            "event_id": evt.get("event_id", ""),
            "node_id": evt.get("node_id", ""),
        }
        existing = self.revocations.get(public_key)
        if not existing or revoked_at >= _safe_int(existing.get("revoked_at", 0), 0):
            self.revocations[public_key] = info

    def _rebuild_revocations(self) -> None:
        self.revocations = {}
        for evt in self.events:
            if evt.get("event_type") == "key_revoke":
                self._apply_revocation(evt)

    def _revocation_status(self, public_key: str) -> tuple[bool, dict | None]:
        info = self.revocations.get(public_key)
        if not info:
            return False, None
        now = time.time()
        if now > _safe_int(info.get("grace_until", 0) or 0, 0):
            return True, info
        return False, info

    # ─── Append ───────────────────────────────────────────────────────

    def validate_and_set_sequence(
        self,
        node_id: str,
        sequence: int,
        *,
        domain: str = "",
    ) -> tuple[bool, str]:
        """Validate monotonic sequence and update last-seen value if valid."""
        if sequence <= 0:
            return False, "Sequence must be a positive integer"
        normalized_domain = str(domain or "").strip().lower()
        table = self.sequence_domains if normalized_domain else self.node_sequences
        key = f"{node_id}|{normalized_domain}" if normalized_domain else node_id
        last = table.get(key, 0)
        if sequence <= last:
            from services.mesh.mesh_metrics import increment as metrics_inc

            metrics_inc("replay_attempts")
            return False, f"Replay detected: sequence {sequence} <= last {last}"
        table[key] = sequence
        self._save()
        return True, "ok"

    def _sequence_table_for_event(self, event_type: str, node_id: str) -> tuple[dict[str, int], str]:
        normalized = str(event_type or "").strip().lower()
        if normalized == "gate_message":
            return self.sequence_domains, f"{node_id}|gate_message"
        if normalized == "dm_message":
            return self.sequence_domains, f"{node_id}|dm_message"
        return self.node_sequences, node_id

    def _dm_spool_target_key(self, payload: dict[str, Any]) -> tuple[str, str]:
        delivery_class = str(payload.get("delivery_class", "") or "").strip().lower()
        if delivery_class == "shared":
            key = str(payload.get("recipient_token", "") or "").strip()
        else:
            key = str(payload.get("recipient_id", "") or "").strip()
        return delivery_class, key

    def _dm_spool_active_counts(
        self,
        payload: dict[str, Any],
        *,
        sender_id: str = "",
        now: float | None = None,
    ) -> tuple[int, int]:
        delivery_class, key = self._dm_spool_target_key(payload)
        if not key:
            return 0, 0
        sender_id = str(sender_id or "").strip()
        current = time.time() if now is None else float(now)
        total_count = 0
        sender_count = 0
        for evt in reversed(self.events):
            if evt.get("event_type") != "dm_message":
                continue
            evt_payload = evt.get("payload") if isinstance(evt.get("payload"), dict) else {}
            evt_delivery_class, evt_key = self._dm_spool_target_key(evt_payload)
            if evt_delivery_class != delivery_class:
                continue
            if evt_key != key:
                continue
            evt_ts = float(evt_payload.get("timestamp", evt.get("timestamp", 0)) or 0)
            if evt_ts > 0 and current - evt_ts > DM_HASHCHAIN_SPOOL_TTL_S:
                continue
            total_count += 1
            if sender_id and str(evt.get("node_id", "") or "").strip() == sender_id:
                sender_count += 1
            if total_count >= DM_HASHCHAIN_SPOOL_LIMIT and (
                not sender_id or sender_count >= DM_HASHCHAIN_SPOOL_SENDER_LIMIT
            ):
                break
        return total_count, sender_count

    def _dm_spool_active_count(self, payload: dict[str, Any], *, now: float | None = None) -> int:
        total_count, _sender_count = self._dm_spool_active_counts(payload, now=now)
        return total_count

    def append_private_dm_message(
        self,
        *,
        node_id: str,
        payload: dict,
        signature: str,
        sequence: int,
        public_key: str,
        public_key_algo: str,
        protocol_version: str = "",
        timestamp: float = 0,
    ) -> dict:
        """Append an encrypted DM dead-drop message to the private Infonet ledger.

        The event is a small offline spool, capped per mailbox target, so the
        hashchain can carry a couple of sealed DMs without becoming an
        unbounded global mailbox.
        """
        event_type = "dm_message"
        if sequence <= 0:
            raise ValueError("sequence is required and must be > 0")
        sequence_table, sequence_key = self._sequence_table_for_event(event_type, node_id)
        last = sequence_table.get(sequence_key, 0)
        if sequence <= last:
            raise ValueError(f"Replay detected: sequence {sequence} <= last {last}")

        raw_payload = dict(payload or {})
        if "message" in raw_payload or "plaintext" in raw_payload or "_local_plaintext" in raw_payload:
            raise ValueError("private DM ledger payload must not contain plaintext")
        if str(raw_payload.get("transport_lock", "") or "").strip().lower() != "private_strong":
            raise ValueError("DM hashchain spool requires private_strong transport_lock")

        payload = normalize_payload(event_type, raw_payload)
        ok, reason = validate_private_dm_ledger_payload(payload)
        if not ok:
            raise ValueError(reason)
        total_count, sender_count = self._dm_spool_active_counts(payload, sender_id=node_id)
        if sender_count >= DM_HASHCHAIN_SPOOL_SENDER_LIMIT:
            raise ValueError("DM hashchain sender spool full for recipient")
        if total_count >= DM_HASHCHAIN_SPOOL_LIMIT:
            raise ValueError("DM hashchain spool full for recipient")

        payload_json = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
        if len(payload_json.encode("utf-8")) > MAX_PAYLOAD_BYTES:
            raise ValueError("payload exceeds max size")

        protocol_version = str(protocol_version or PROTOCOL_VERSION)
        ok, reason = validate_protocol_fields(protocol_version, NETWORK_ID)
        if not ok:
            raise ValueError(reason)

        if not (signature and public_key and public_key_algo):
            raise ValueError("Missing signature fields")
        algo = parse_public_key_algo(public_key_algo)
        if not algo:
            raise ValueError("Unsupported public_key_algo")
        if not verify_node_binding(node_id, public_key):
            raise ValueError("node_id mismatch")
        bound, bind_reason = self._bind_public_key(public_key, node_id)
        if not bound:
            raise ValueError(bind_reason)
        sig_payload = build_signature_payload(
            event_type=event_type,
            node_id=node_id,
            sequence=sequence,
            payload=payload,
        )
        if not verify_signature(
            public_key_b64=public_key,
            public_key_algo=public_key_algo,
            signature_hex=signature,
            payload=sig_payload,
        ):
            raise ValueError("Invalid signature")

        revoked, _info = self._revocation_status(public_key)
        if revoked:
            raise ValueError("public key is revoked")

        event = ChainEvent(
            prev_hash=self.head_hash,
            event_type=event_type,
            node_id=node_id,
            payload=payload,
            timestamp=float(timestamp or time.time()),
            sequence=sequence,
            signature=signature,
            public_key=public_key,
            public_key_algo=public_key_algo,
            protocol_version=protocol_version,
        )
        event_dict = event.to_dict()
        self._write_wal(event_dict)
        self.events.append(event_dict)
        self.event_index[event.event_id] = len(self.events) - 1
        self.head_hash = event.event_id
        sequence_table[sequence_key] = sequence
        self._replay_filter.add(event.event_id)
        self._invalidate_merkle_cache()
        self._update_counters_for_event(event_dict)
        self._save()

        try:
            from services.mesh.mesh_rns import rns_bridge

            rns_bridge.publish_event(event_dict)
        except Exception:
            pass
        _notify_public_event_append_hooks(event_dict)
        logger.info(
            f"Infonet append [dm_message] by {_redact_node(node_id)} seq={sequence} "
            f"id={event.event_id[:16]}..."
        )
        return event_dict

    def append_private_gate_message(
        self,
        *,
        node_id: str,
        payload: dict,
        signature: str,
        sequence: int,
        public_key: str,
        public_key_algo: str,
        protocol_version: str = "",
        timestamp: float = 0,
    ) -> dict:
        """Append an encrypted gate message to the private Infonet ledger.

        Gate messages use their own sequence domain so a gate post cannot
        consume or replay-block the author's public broadcast sequence.
        """
        event_type = "gate_message"
        if sequence <= 0:
            raise ValueError("sequence is required and must be > 0")
        sequence_table, sequence_key = self._sequence_table_for_event(event_type, node_id)
        last = sequence_table.get(sequence_key, 0)
        if sequence <= last:
            raise ValueError(f"Replay detected: sequence {sequence} <= last {last}")

        raw_payload = dict(payload or {})
        if "message" in raw_payload or "_local_plaintext" in raw_payload or "_local_reply_to" in raw_payload:
            raise ValueError("private gate ledger payload must not contain plaintext")
        if str(raw_payload.get("transport_lock", "") or "").strip().lower() != "private_strong":
            raise ValueError("gate messages require private_strong transport_lock")

        payload = normalize_payload(event_type, raw_payload)
        ok, reason = validate_private_gate_ledger_payload(payload)
        if not ok:
            raise ValueError(reason)

        payload_json = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
        if len(payload_json.encode("utf-8")) > MAX_PAYLOAD_BYTES:
            raise ValueError("payload exceeds max size")

        protocol_version = str(protocol_version or PROTOCOL_VERSION)
        ok, reason = validate_protocol_fields(protocol_version, NETWORK_ID)
        if not ok:
            raise ValueError(reason)

        if not (signature and public_key and public_key_algo):
            raise ValueError("Missing signature fields")
        algo = parse_public_key_algo(public_key_algo)
        if not algo:
            raise ValueError("Unsupported public_key_algo")
        if not verify_node_binding(node_id, public_key):
            raise ValueError("node_id mismatch")
        bound, bind_reason = self._bind_public_key(public_key, node_id)
        if not bound:
            raise ValueError(bind_reason)
        event_for_signature = {"payload": payload}
        signature_ok = False
        for signature_payload in _private_gate_signature_payload_variants(
            str(payload.get("gate", "") or ""),
            event_for_signature,
        ):
            sig_payload = build_signature_payload(
                event_type=event_type,
                node_id=node_id,
                sequence=sequence,
                payload=signature_payload,
            )
            if verify_signature(
                public_key_b64=public_key,
                public_key_algo=public_key_algo,
                signature_hex=signature,
                payload=sig_payload,
            ):
                signature_ok = True
                break
        if not signature_ok:
            raise ValueError("Invalid signature")

        revoked, _info = self._revocation_status(public_key)
        if revoked:
            raise ValueError("public key is revoked")

        event = ChainEvent(
            prev_hash=self.head_hash,
            event_type=event_type,
            node_id=node_id,
            payload=payload,
            timestamp=float(timestamp or time.time()),
            sequence=sequence,
            signature=signature,
            public_key=public_key,
            public_key_algo=public_key_algo,
            protocol_version=protocol_version,
        )
        event_dict = event.to_dict()
        self._write_wal(event_dict)
        self.events.append(event_dict)
        self.event_index[event.event_id] = len(self.events) - 1
        self.head_hash = event.event_id
        sequence_table[sequence_key] = sequence
        self._replay_filter.add(event.event_id)
        self._invalidate_merkle_cache()
        self._update_counters_for_event(event_dict)
        self._save()

        try:
            from services.mesh.mesh_rns import rns_bridge

            rns_bridge.publish_event(event_dict)
        except Exception:
            pass
        _notify_public_event_append_hooks(event_dict)

        logger.info(
            f"Infonet append [gate_message] by {_redact_node(node_id)} seq={sequence} "
            f"id={event.event_id[:16]}..."
        )
        return event_dict

    def append(
        self,
        event_type: str,
        node_id: str,
        payload: dict,
        signature: str = "",
        sequence: int = 0,
        ephemeral: bool = False,
        public_key: str = "",
        public_key_algo: str = "",
        protocol_version: str = "",
        timestamp_bucket_s: int = 0,
    ) -> dict:
        """Append a new event to the Infonet. Returns the event dict.

        Args:
            event_type: Type of event (message, vote, gate_create, etc.)
            node_id: Author node ID
            payload: Event-specific data
            signature: Cryptographic signature from node's private key
            ephemeral: If True, event auto-purges after 24h

        Returns:
            The event dict with computed event_id
        """
        from services.mesh.mesh_crypto import (
            build_signature_payload,
            parse_public_key_algo,
            verify_node_binding,
            verify_signature,
        )

        if event_type not in ACTIVE_APPEND_EVENT_TYPES:
            raise ValueError(f"Unsupported event_type: {event_type}")

        if sequence <= 0:
            raise ValueError("sequence is required and must be > 0")
        last = self.node_sequences.get(node_id, 0)
        if sequence <= last:
            raise ValueError(f"Replay detected: sequence {sequence} <= last {last}")

        payload = normalize_payload(event_type, dict(payload or {}))

        ok, reason = validate_event_payload(event_type, payload)
        if not ok:
            raise ValueError(reason)
        ok, reason = validate_public_ledger_payload(event_type, payload)
        if not ok:
            raise ValueError(reason)

        if event_type == "message":
            if "ephemeral" not in payload:
                payload["ephemeral"] = bool(ephemeral)
        else:
            payload.pop("ephemeral", None)

        payload_json = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
        if len(payload_json.encode("utf-8")) > MAX_PAYLOAD_BYTES:
            raise ValueError("payload exceeds max size")

        protocol_version = str(protocol_version or PROTOCOL_VERSION)
        ok, reason = validate_protocol_fields(protocol_version, NETWORK_ID)
        if not ok:
            raise ValueError(reason)

        if not (signature and public_key and public_key_algo):
            raise ValueError("Missing signature fields")
        if not parse_public_key_algo(public_key_algo):
            raise ValueError("Unsupported public_key_algo")
        if not verify_node_binding(node_id, public_key):
            raise ValueError("node_id mismatch")
        bound, bind_reason = self._bind_public_key(public_key, node_id)
        if not bound:
            raise ValueError(bind_reason)
        sig_payload = build_signature_payload(
            event_type=event_type,
            node_id=node_id,
            sequence=sequence,
            payload=payload,
        )
        if not verify_signature(
            public_key_b64=public_key,
            public_key_algo=public_key_algo,
            signature_hex=signature,
            payload=sig_payload,
        ):
            raise ValueError("Invalid signature")

        if public_key:
            revoked, _info = self._revocation_status(public_key)
            if revoked and event_type != "key_revoke":
                raise ValueError("public key is revoked")

        if event_type == "key_revoke":
            if payload.get("revoked_public_key") and payload.get("revoked_public_key") != public_key:
                raise ValueError("revoked_public_key must match event public_key")
            if payload.get("revoked_public_key_algo") and payload.get(
                "revoked_public_key_algo"
            ) != public_key_algo:
                raise ValueError("revoked_public_key_algo must match event public_key_algo")

        if timestamp_bucket_s > 0:
            ts = time.time()
            ts = float(int(ts / timestamp_bucket_s) * timestamp_bucket_s)
        else:
            ts = time.time()

        # Create event
        event = ChainEvent(
            prev_hash=self.head_hash,
            event_type=event_type,
            node_id=node_id,
            payload=payload,
            timestamp=ts,
            sequence=sequence,
            signature=signature,
            public_key=public_key,
            public_key_algo=public_key_algo,
            protocol_version=protocol_version,
        )

        event_dict = event.to_dict()
        self._write_wal(event_dict)
        self.events.append(event_dict)
        self.event_index[event.event_id] = len(self.events) - 1
        self.head_hash = event.event_id
        self.node_sequences[node_id] = sequence
        self._replay_filter.add(event.event_id)
        # Issue #208: chain advanced, cached Merkle levels are stale.
        self._invalidate_merkle_cache()
        self._update_counters_for_event(event_dict)

        if event_type == "key_revoke":
            self._apply_revocation(event_dict)

        # Sprint 2 / Rec #8: do NOT clear the WAL here. _save() only
        # schedules a coalesced flush; clearing now would open a crash
        # window where the event is gone from the WAL but not yet in
        # the chain file. _flush() clears the WAL only after a
        # successful durable write.
        self._save()

        try:
            from services.mesh.mesh_rns import rns_bridge

            rns_bridge.publish_event(event_dict)
        except Exception:
            pass
        _notify_public_event_append_hooks(event_dict)

        logger.info(
            f"Infonet append [{event_type}] by {_redact_node(node_id)} seq={sequence} "
            f"id={event.event_id[:16]}..."
        )
        return event_dict

    def ingest_events(self, events: list[dict]) -> dict:
        """Ingest a sequence of external events. Requires contiguous prev_hash."""
        accepted = 0
        duplicates = 0
        rejected: list[dict] = []
        expected_prev = self.head_hash

        for idx, evt in enumerate(events):
            if not isinstance(evt, dict):
                rejected.append({"index": idx, "reason": "Event is not an object"})
                continue

            event_type = evt.get("event_type", "")
            node_id = evt.get("node_id", "")
            event_id = evt.get("event_id", "")
            prev_hash = evt.get("prev_hash", "")
            sequence = _safe_int(evt.get("sequence", 0) or 0, 0)

            if event_type not in ALLOWED_EVENT_TYPES:
                rejected.append({"index": idx, "reason": "Unsupported event_type"})
                continue
            if not event_id or not prev_hash:
                rejected.append({"index": idx, "reason": "Missing event_id or prev_hash"})
                continue
            if event_id in self.event_index:
                duplicates += 1
                continue
            if self._replay_filter.seen(event_id):
                try:
                    from services.mesh.mesh_metrics import increment as metrics_inc

                    metrics_inc("ingest_replay_seen")
                except Exception:
                    pass
                duplicates += 1
                continue
            if prev_hash != expected_prev:
                try:
                    from services.mesh.mesh_metrics import increment as metrics_inc

                    metrics_inc("ingest_prev_hash_mismatch")
                except Exception:
                    pass
                rejected.append({"index": idx, "reason": "prev_hash does not match head"})
                continue
            if evt.get("network_id") != NETWORK_ID:
                try:
                    from services.mesh.mesh_metrics import increment as metrics_inc

                    metrics_inc("ingest_network_mismatch")
                except Exception:
                    pass
                rejected.append({"index": idx, "reason": "network_id mismatch"})
                continue
            if prev_hash != self.head_hash:
                rejected.append({"index": idx, "reason": "prev_hash does not match head"})
                continue
            if sequence <= 0:
                rejected.append({"index": idx, "reason": "Invalid sequence"})
                continue
            sequence_table, sequence_key = self._sequence_table_for_event(event_type, node_id)
            last = sequence_table.get(sequence_key, 0)
            if sequence <= last:
                rejected.append({"index": idx, "reason": "Replay detected"})
                continue
            # Hardening Rec #8: timestamp freshness bound. The sequence check
            # above catches replays once a node has observed the author, but
            # a fresh peer (node_sequences[node_id] == 0) accepts any
            # sequence > 0 — so an attacker could replay an ancient signed
            # event into a node that's never seen the author. Rejecting
            # events whose timestamp is outside a bounded freshness window
            # closes that hole without breaking catch-up sync for
            # short-lived network partitions.
            try:
                from services.mesh.mesh_rollout_flags import ingest_event_max_age_s

                max_age_s = int(ingest_event_max_age_s() or 0)
            except Exception:
                max_age_s = 0
            if max_age_s > 0:
                evt_ts = _safe_int(evt.get("timestamp", 0) or 0, 0)
                if evt_ts > 0 and abs(int(time.time()) - evt_ts) > max_age_s:
                    try:
                        from services.mesh.mesh_metrics import increment as metrics_inc

                        metrics_inc("ingest_timestamp_stale")
                    except Exception:
                        pass
                    rejected.append({"index": idx, "reason": "Event timestamp outside freshness window"})
                    continue

            payload = evt.get("payload", {})
            ok, reason = validate_event_payload(event_type, payload)
            if not ok:
                rejected.append({"index": idx, "reason": reason})
                continue
            if event_type == "gate_message":
                ok, reason = validate_private_gate_ledger_payload(payload)
            elif event_type == "dm_message":
                ok, reason = validate_private_dm_ledger_payload(payload)
                if ok:
                    total_count, sender_count = self._dm_spool_active_counts(payload, sender_id=str(evt.get("node_id", "") or ""))
                    if sender_count >= DM_HASHCHAIN_SPOOL_SENDER_LIMIT:
                        ok, reason = False, "DM hashchain sender spool full for recipient"
                    elif total_count >= DM_HASHCHAIN_SPOOL_LIMIT:
                        ok, reason = False, "DM hashchain spool full for recipient"
            else:
                ok, reason = validate_public_ledger_payload(event_type, payload)
            if not ok:
                rejected.append({"index": idx, "reason": reason})
                continue
            if event_type == "message" and "ephemeral" not in payload:
                rejected.append({"index": idx, "reason": "Missing ephemeral flag"})
                continue

            payload_json = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
            if len(payload_json.encode("utf-8")) > MAX_PAYLOAD_BYTES:
                try:
                    from services.mesh.mesh_metrics import increment as metrics_inc

                    metrics_inc("ingest_payload_too_large")
                except Exception:
                    pass
                rejected.append({"index": idx, "reason": "Payload too large"})
                continue

            proto = evt.get("protocol_version") or PROTOCOL_VERSION
            if proto != PROTOCOL_VERSION:
                try:
                    from services.mesh.mesh_metrics import increment as metrics_inc

                    metrics_inc("ingest_proto_mismatch")
                except Exception:
                    pass
                rejected.append({"index": idx, "reason": "Unsupported protocol_version"})
                continue

            signature = evt.get("signature", "")
            public_key = evt.get("public_key", "")
            public_key_algo = evt.get("public_key_algo", "")
            if not (signature and public_key and public_key_algo):
                try:
                    from services.mesh.mesh_metrics import increment as metrics_inc

                    metrics_inc("ingest_signature_missing")
                except Exception:
                    pass
                rejected.append({"index": idx, "reason": "Missing signature fields"})
                continue
            from services.mesh.mesh_crypto import parse_public_key_algo

            if not parse_public_key_algo(public_key_algo):
                rejected.append({"index": idx, "reason": "Unsupported public_key_algo"})
                continue

            if event_type == "key_revoke":
                if payload.get("revoked_public_key") and payload.get(
                    "revoked_public_key"
                ) != public_key:
                    rejected.append(
                        {"index": idx, "reason": "revoked_public_key must match public_key"}
                    )
                    continue
                if payload.get("revoked_public_key_algo") and payload.get(
                    "revoked_public_key_algo"
                ) != public_key_algo:
                    rejected.append(
                        {
                            "index": idx,
                            "reason": "revoked_public_key_algo must match public_key_algo",
                        }
                    )
                    continue
            revoked, _info = self._revocation_status(public_key)
            if revoked and event_type != "key_revoke":
                try:
                    from services.mesh.mesh_metrics import increment as metrics_inc

                    metrics_inc("ingest_key_revoked")
                except Exception:
                    pass
                rejected.append({"index": idx, "reason": "public key is revoked"})
                continue
            last_seq = sequence_table.get(sequence_key, 0)
            if sequence <= last_seq:
                try:
                    from services.mesh.mesh_metrics import increment as metrics_inc

                    metrics_inc("ingest_replay_sequence")
                except Exception:
                    pass
                rejected.append(
                    {
                        "index": idx,
                        "reason": f"Replay detected: sequence {sequence} <= last {last_seq}",
                    }
                )
                continue

            from services.mesh.mesh_crypto import (
                build_signature_payload,
                verify_signature,
                verify_node_binding,
            )

            if not verify_node_binding(node_id, public_key):
                try:
                    from services.mesh.mesh_metrics import increment as metrics_inc

                    metrics_inc("ingest_node_mismatch")
                except Exception:
                    pass
                rejected.append({"index": idx, "reason": "node_id mismatch"})
                continue
            bound, bind_reason = self._bind_public_key(public_key, node_id)
            if not bound:
                rejected.append({"index": idx, "reason": bind_reason})
                continue

            if event_type == "gate_message":
                signature_payloads = _private_gate_signature_payload_variants(
                    str(payload.get("gate", "") or ""),
                    evt,
                )
            else:
                signature_payloads = [payload]
            signature_ok = False
            for signature_payload in signature_payloads:
                sig_payload = build_signature_payload(
                    event_type=event_type,
                    node_id=node_id,
                    sequence=sequence,
                    payload=signature_payload,
                )
                if verify_signature(
                    public_key_b64=public_key,
                    public_key_algo=public_key_algo,
                    signature_hex=signature,
                    payload=sig_payload,
                ):
                    signature_ok = True
                    break
            if not signature_ok:
                try:
                    from services.mesh.mesh_metrics import increment as metrics_inc

                    metrics_inc("ingest_signature_invalid")
                except Exception:
                    pass
                rejected.append({"index": idx, "reason": "Invalid signature"})
                continue

            # Verify event_id/hash linkage
            try:
                computed = ChainEvent.from_dict(evt).event_id
            except (ValueError, KeyError, TypeError) as exc:
                rejected.append({"index": idx, "reason": f"event_id hash mismatch: {exc}"})
                continue
            if computed != event_id:
                try:
                    from services.mesh.mesh_metrics import increment as metrics_inc

                    metrics_inc("ingest_event_id_mismatch")
                except Exception:
                    pass
                rejected.append({"index": idx, "reason": "event_id mismatch"})
                continue

            # Accept
            self.events.append(evt)
            self.event_index[event_id] = len(self.events) - 1
            self.head_hash = event_id
            sequence_table[sequence_key] = sequence
            self._update_counters_for_event(evt)
            accepted += 1
            expected_prev = event_id
            self._replay_filter.add(event_id)
            if event_type == "key_revoke":
                self._apply_revocation(evt)

        if accepted:
            # Issue #208: any accepted event invalidates the cached Merkle
            # levels. One invalidation per batch, not per event.
            self._invalidate_merkle_cache()
            self._save()
        return {"accepted": accepted, "duplicates": duplicates, "rejected": rejected}

    # ─── Validation ───────────────────────────────────────────────────

    def validate_chain(self, verify_signatures: bool = False) -> tuple[bool, str]:
        """Verify the entire Infonet's integrity.

        Checks that each event's prev_hash matches the previous event's event_id,
        and that each event's hash is correct.

        Returns (valid, reason)
        """
        if not self.events:
            return True, "Empty chain"

        prev = GENESIS_HASH
        seen_public_keys: dict[str, str] = {}
        for i, evt_dict in enumerate(self.events):
            # Check prev_hash linkage
            if evt_dict["prev_hash"] != prev:
                return False, (
                    f"Broken link at index {i}: expected prev_hash "
                    f"{prev[:16]}..., got {evt_dict['prev_hash'][:16]}..."
                )

            # Recompute hash and verify
            evt = ChainEvent.from_dict(evt_dict)
            if evt.event_id != evt_dict["event_id"]:
                return False, (
                    f"Hash mismatch at index {i}: computed "
                    f"{evt.event_id[:16]}..., stored {evt_dict['event_id'][:16]}..."
                )

            if verify_signatures:
                proto = evt_dict.get("protocol_version") or PROTOCOL_VERSION
                if proto != PROTOCOL_VERSION:
                    return False, f"Unsupported protocol_version at index {i}: {proto}"
                signature = evt_dict.get("signature", "")
                public_key = evt_dict.get("public_key", "")
                public_key_algo = evt_dict.get("public_key_algo", "")
                if not (signature and public_key and public_key_algo):
                    return False, f"Missing signature fields at index {i}"

                from services.mesh.mesh_crypto import (
                    build_signature_payload,
                    parse_public_key_algo,
                    verify_signature,
                    verify_node_binding,
                )

                event_type = evt_dict.get("event_type", "")
                node_id = evt_dict.get("node_id", "")
                if not parse_public_key_algo(public_key_algo):
                    return False, f"Unsupported public_key_algo at index {i}"
                if not verify_node_binding(node_id, public_key):
                    return False, f"node_id mismatch at index {i}"
                existing = seen_public_keys.get(public_key)
                if existing and existing != node_id:
                    return False, f"public key binding conflict at index {i}"
                seen_public_keys[public_key] = node_id

                payload = evt_dict.get("payload", {})
                if event_type == "gate_message":
                    ok, reason = validate_private_gate_ledger_payload(payload)
                    if not ok:
                        return False, f"Invalid gate_message payload at index {i}: {reason}"
                    signature_payloads = _private_gate_signature_payload_variants(
                        str(payload.get("gate", "") or ""),
                        evt_dict,
                    )
                elif event_type == "dm_message":
                    ok, reason = validate_private_dm_ledger_payload(payload)
                    if not ok:
                        return False, f"Invalid dm_message payload at index {i}: {reason}"
                    signature_payloads = [normalize_payload(event_type, payload)]
                else:
                    signature_payloads = [
                        normalize_payload(event_type, payload)
                    ]
                signature_ok = False
                for signature_payload in signature_payloads:
                    sig_payload = build_signature_payload(
                        event_type=event_type,
                        node_id=node_id,
                        sequence=_safe_int(evt_dict.get("sequence", 0) or 0, 0),
                        payload=signature_payload,
                    )
                    if verify_signature(
                        public_key_b64=public_key,
                        public_key_algo=public_key_algo,
                        signature_hex=signature,
                        payload=sig_payload,
                    ):
                        signature_ok = True
                        break
                if not signature_ok:
                    return False, f"Invalid signature at index {i}"

            prev = evt_dict["event_id"]

        if prev != self.head_hash:
            return (
                False,
                f"Head hash mismatch: chain ends at {prev[:16]}... but head is {self.head_hash[:16]}...",
            )

        return True, f"Valid Infonet: {len(self.events)} events"

    def validate_chain_incremental(self, verify_signatures: bool = False) -> tuple[bool, str]:
        """Validate only events appended since last successful validation.

        Much faster than full validate_chain() on large chains — O(new) vs O(N).
        Falls back to full validation if the chain has been restructured.
        """
        total = len(self.events)
        start = self._last_validated_index
        if start > total:
            # Chain was truncated (fork resolution) — fall back to full
            self._last_validated_index = 0
            return self.validate_chain(verify_signatures=verify_signatures)
        if start >= total:
            return True, f"No new events (chain has {total} events)"

        # Determine expected prev_hash at the start index
        if start == 0:
            prev = GENESIS_HASH
        else:
            prev = self.events[start - 1]["event_id"]

        for i in range(start, total):
            evt_dict = self.events[i]
            if evt_dict["prev_hash"] != prev:
                return False, (
                    f"Broken link at index {i}: expected prev_hash "
                    f"{prev[:16]}..., got {evt_dict['prev_hash'][:16]}..."
                )
            evt = ChainEvent.from_dict(evt_dict)
            if evt.event_id != evt_dict["event_id"]:
                return False, (
                    f"Hash mismatch at index {i}: computed "
                    f"{evt.event_id[:16]}..., stored {evt_dict['event_id'][:16]}..."
                )

            if verify_signatures:
                proto = evt_dict.get("protocol_version") or PROTOCOL_VERSION
                if proto != PROTOCOL_VERSION:
                    return False, f"Unsupported protocol_version at index {i}: {proto}"
                signature = evt_dict.get("signature", "")
                public_key = evt_dict.get("public_key", "")
                public_key_algo = evt_dict.get("public_key_algo", "")
                if not (signature and public_key and public_key_algo):
                    return False, f"Missing signature fields at index {i}"

                from services.mesh.mesh_crypto import (
                    build_signature_payload,
                    parse_public_key_algo,
                    verify_signature,
                    verify_node_binding,
                )

                event_type = evt_dict.get("event_type", "")
                node_id = evt_dict.get("node_id", "")
                if not parse_public_key_algo(public_key_algo):
                    return False, f"Unsupported public_key_algo at index {i}"
                if not verify_node_binding(node_id, public_key):
                    return False, f"node_id mismatch at index {i}"

                payload = evt_dict.get("payload", {})
                if event_type == "gate_message":
                    ok, reason = validate_private_gate_ledger_payload(payload)
                    if not ok:
                        return False, f"Invalid gate_message payload at index {i}: {reason}"
                    signature_payloads = _private_gate_signature_payload_variants(
                        str(payload.get("gate", "") or ""),
                        evt_dict,
                    )
                elif event_type == "dm_message":
                    ok, reason = validate_private_dm_ledger_payload(payload)
                    if not ok:
                        return False, f"Invalid dm_message payload at index {i}: {reason}"
                    signature_payloads = [normalize_payload(event_type, payload)]
                else:
                    signature_payloads = [
                        normalize_payload(event_type, payload)
                    ]
                signature_ok = False
                for signature_payload in signature_payloads:
                    sig_payload = build_signature_payload(
                        event_type=event_type,
                        node_id=node_id,
                        sequence=_safe_int(evt_dict.get("sequence", 0) or 0, 0),
                        payload=signature_payload,
                    )
                    if verify_signature(
                        public_key_b64=public_key,
                        public_key_algo=public_key_algo,
                        signature_hex=signature,
                        payload=sig_payload,
                    ):
                        signature_ok = True
                        break
                if not signature_ok:
                    return False, f"Invalid signature at index {i}"
            prev = evt_dict["event_id"]

        if prev != self.head_hash:
            return False, (
                f"Head hash mismatch: chain ends at {prev[:16]}... but head is {self.head_hash[:16]}..."
            )

        self._last_validated_index = total
        return True, f"Valid Infonet: {total} events ({total - start} new)"

    def _order_chain_from(self, prev_hash: str, events: list[dict]) -> list[dict] | None:
        by_prev: dict[str, dict] = {}
        for evt in events:
            p = evt.get("prev_hash", "")
            if not p:
                return None
            if p in by_prev:
                return None
            by_prev[p] = evt
        ordered = []
        current = prev_hash
        while current in by_prev:
            evt = by_prev[current]
            ordered.append(evt)
            current = evt.get("event_id", "")
            if not current:
                return None
        if len(ordered) != len(events):
            return None
        return ordered

    def apply_fork(self, events: list[dict], head_hash: str, proof_count: int, quorum: int) -> tuple[bool, str]:
        if not events:
            return False, "empty fork"
        if proof_count < max(2, int(quorum)):
            return False, "insufficient quorum"
        prev_hash = events[0].get("prev_hash", "")
        if not prev_hash:
            return False, "missing prev_hash"
        prev_index = self.event_index.get(prev_hash)
        if prev_index is None:
            return False, "unknown ancestor"
        depth_from_head = len(self.events) - 1 - prev_index
        if depth_from_head > CHAIN_LOCK_DEPTH:
            return False, "chain lock prevents reorg"
        ordered = self._order_chain_from(prev_hash, events)
        if not ordered:
            return False, "non-contiguous fork"
        if ordered[-1].get("event_id", "") != head_hash:
            return False, "head_hash mismatch"
        current_tail_len = len(self.events) - 1 - prev_index
        if len(ordered) <= current_tail_len:
            return False, "fork not longer"

        # Validate events and sequences against prefix
        prefix = self.events[: prev_index + 1]
        last_seq: dict[str, int] = {}
        seen_public_keys: dict[str, str] = {}
        for evt in prefix:
            node_id = evt.get("node_id", "")
            sequence = _safe_int(evt.get("sequence", 0) or 0, 0)
            if node_id and sequence:
                sequence_key = (
                    f"{node_id}|gate_message"
                    if str(evt.get("event_type", "") or "").strip().lower() == "gate_message"
                    else f"{node_id}|dm_message"
                    if str(evt.get("event_type", "") or "").strip().lower() == "dm_message"
                    else node_id
                )
                last_seq[sequence_key] = max(last_seq.get(sequence_key, 0), sequence)
            public_key = str(evt.get("public_key", "") or "")
            if public_key and node_id:
                seen_public_keys.setdefault(public_key, node_id)

        for evt in ordered:
            event_type = evt.get("event_type", "")
            node_id = evt.get("node_id", "")
            event_id = evt.get("event_id", "")
            sequence = _safe_int(evt.get("sequence", 0) or 0, 0)
            payload = evt.get("payload", {})
            if event_type not in ALLOWED_EVENT_TYPES:
                return False, "unsupported event_type"
            if not event_id or not node_id:
                return False, "missing fields"
            if evt.get("network_id") != NETWORK_ID:
                return False, "network mismatch"
            existing_idx = self.event_index.get(event_id)
            if existing_idx is not None and existing_idx <= prev_index:
                return False, "duplicate event_id"
            if event_type == "gate_message":
                payload = dict(payload or {})
            elif event_type == "dm_message":
                payload = normalize_payload(event_type, dict(payload or {}))
            else:
                payload = normalize_payload(event_type, dict(payload or {}))
            ok, reason = validate_event_payload(event_type, payload)
            if not ok:
                return False, reason
            if event_type == "gate_message":
                ok, reason = validate_private_gate_ledger_payload(payload)
            elif event_type == "dm_message":
                ok, reason = validate_private_dm_ledger_payload(payload)
            else:
                ok, reason = validate_public_ledger_payload(event_type, payload)
            if not ok:
                return False, reason
            proto = evt.get("protocol_version") or PROTOCOL_VERSION
            if proto != PROTOCOL_VERSION:
                return False, "unsupported protocol_version"
            signature = evt.get("signature", "")
            public_key = evt.get("public_key", "")
            public_key_algo = evt.get("public_key_algo", "")
            if not (signature and public_key and public_key_algo):
                return False, "missing signature fields"
            revoked, _info = self._revocation_status(public_key)
            if revoked and event_type != "key_revoke":
                return False, "public key revoked"
            sequence_key = (
                f"{node_id}|gate_message"
                if event_type == "gate_message"
                else f"{node_id}|dm_message"
                if event_type == "dm_message"
                else node_id
            )
            last = last_seq.get(sequence_key, 0)
            if sequence <= last:
                return False, "sequence replay"
            from services.mesh.mesh_crypto import (
                build_signature_payload,
                parse_public_key_algo,
                verify_signature,
                verify_node_binding,
            )

            if not parse_public_key_algo(public_key_algo):
                return False, "unsupported public_key_algo"
            if not verify_node_binding(node_id, public_key):
                return False, "node_id mismatch"
            existing = seen_public_keys.get(public_key)
            if existing and existing != node_id:
                return False, "public key binding conflict"
            seen_public_keys[public_key] = node_id
            if event_type == "gate_message":
                signature_payloads = _private_gate_signature_payload_variants(
                    str(payload.get("gate", "") or ""),
                    evt,
                )
            else:
                signature_payloads = [payload]
            signature_ok = False
            for signature_payload in signature_payloads:
                sig_payload = build_signature_payload(
                    event_type=event_type,
                    node_id=node_id,
                    sequence=sequence,
                    payload=signature_payload,
                )
                if verify_signature(
                    public_key_b64=public_key,
                    public_key_algo=public_key_algo,
                    signature_hex=signature,
                    payload=sig_payload,
                ):
                    signature_ok = True
                    break
            if not signature_ok:
                return False, "invalid signature"
            computed = ChainEvent.from_dict(evt).event_id
            if computed != event_id:
                return False, "event_id mismatch"
            last_seq[sequence_key] = sequence

        # Apply fork
        self.events = prefix + ordered
        self._rebuild_state()
        self._rebuild_revocations()
        self._rebuild_counters()
        # Issue #208: chain replaced, cached Merkle levels are stale.
        self._invalidate_merkle_cache()
        self._save()
        try:
            from services.mesh.mesh_metrics import increment as metrics_inc

            metrics_inc("fork_applied")
        except Exception:
            pass
        return True, "applied"

    def check_replay(self, node_id: str, sequence: int) -> bool:
        """Check if a sequence number has already been used by this node.

        Returns True if this is a REPLAY (bad), False if fresh (good).
        """
        return sequence <= self.node_sequences.get(node_id, 0)

    # ─── Queries ──────────────────────────────────────────────────────

    def get_event(self, event_id: str) -> dict | None:
        """Look up a single event by ID."""
        idx = self.event_index.get(event_id)
        if idx is not None and idx < len(self.events):
            return self.events[idx]
        return None

    def annotate_event(self, event_id: str, meta: dict) -> bool:
        """Attach non-consensus metadata to an event (not part of hash)."""
        idx = self.event_index.get(event_id)
        if idx is None or idx >= len(self.events):
            return False
        self.events[idx]["meta"] = meta
        self._save()
        return True

    def get_events_by_type(
        self,
        event_type: str,
        limit: int = 50,
        offset: int = 0,
    ) -> list[dict]:
        """Get recent events of a specific type (newest first)."""
        matching = []
        for e in reversed(self.events):
            if e["event_type"] != event_type:
                continue
            matching.append(e)
        return matching[offset : offset + limit]

    def get_events_by_node(self, node_id: str, limit: int = 50) -> list[dict]:
        """Get recent events by a specific node (newest first)."""
        matching = []
        for e in reversed(self.events):
            if e["node_id"] != node_id:
                continue
            matching.append(e)
        return matching[:limit]

    def get_messages(
        self,
        gate_id: str = "",
        limit: int = 50,
        offset: int = 0,
    ) -> list[dict]:
        """Get messages, optionally filtered by gate.

        Returns public-plane 'message' events only.
        Returns newest first with message-specific fields extracted.
        """
        results = []
        for evt in reversed(self.events):
            if evt["event_type"] != "message":
                continue
            payload = evt.get("payload", {})
            # Skip ephemeral messages that have expired
            if payload.get("ephemeral") or payload.get("_ephemeral"):
                age = time.time() - evt["timestamp"]
                if age > EPHEMERAL_TTL:
                    continue
            # Gate filter
            msg_gate = payload.get("gate", "")
            if gate_id and msg_gate != gate_id:
                continue
            # Skip transport-routed messages (Meshtastic/APRS) from InfoNet feed —
            # they belong in their own tab. Only direct InfoNet/gate posts appear here.
            meta = evt.get("meta", {})
            if payload.get("routed_via") or meta.get("routed_via"):
                continue

            results.append(
                {
                    "event_id": evt["event_id"],
                    "event_type": evt.get("event_type", ""),
                    "node_id": evt["node_id"],
                    "message": payload.get("message", payload.get("text", "")),
                    "ciphertext": payload.get("ciphertext", ""),
                    "epoch": payload.get("epoch", 0),
                    "nonce": payload.get("nonce", payload.get("iv", "")),
                    "sender_ref": payload.get("sender_ref", ""),
                    "format": payload.get("format", ""),
                    "destination": payload.get("destination", "broadcast"),
                    "channel": payload.get("channel", "LongFast"),
                    "priority": payload.get("priority", "normal"),
                    "gate": msg_gate,
                    "timestamp": evt["timestamp"],
                    "sequence": evt.get("sequence", 0),
                    "ephemeral": payload.get("ephemeral", payload.get("_ephemeral", False)),
                    "signature": evt.get("signature", ""),
                    "public_key": evt.get("public_key", ""),
                    "public_key_algo": evt.get("public_key_algo", ""),
                    "protocol_version": evt.get("protocol_version", ""),
                }
            )

            if len(results) >= offset + limit:
                break

        return results[offset : offset + limit]

    def get_info(self) -> dict:
        """Infonet metadata for status display. O(1) via running counters."""
        return {
            "protocol": "infonet",
            "network_id": NETWORK_ID,
            "total_events": len(self.events),
            "active_events": self._active_count,
            "head_hash": self.head_hash[:16] + "...",
            "head_hash_full": self.head_hash,
            "chain_lock": self.chain_lock(),
            "known_nodes": len(self.node_sequences),
            "author_nodes": len(self.node_sequences),
            "registered_nodes": len(self._registered_nodes),
            "event_types": dict(self._type_counts),
            "chain_size_kb": round(self._chain_bytes / 1024, 1),
            "unsigned_events": 0,
        }

    # ─── Cleanup ──────────────────────────────────────────────────────

    def cleanup(self):
        """Remove expired ephemeral events and old events beyond retention window.

        Note: This breaks the chain linkage for removed events, so we only
        remove from the beginning (oldest events). The chain remains valid
        from the first remaining event forward.
        """
        now = time.time()
        retention_cutoff = now - (MESSAGE_RETENTION_DAYS * 86400)
        before = len(self.events)

        # Remove events that are both old AND ephemeral-expired
        new_events = []
        for evt in self.events:
            payload = evt.get("payload", {})
            is_ephemeral = payload.get("ephemeral", payload.get("_ephemeral", False))
            age = now - evt["timestamp"]

            # Keep if: not ephemeral-expired AND within retention window
            if is_ephemeral and age > EPHEMERAL_TTL:
                continue  # Expired ephemeral — drop
            if evt["timestamp"] < retention_cutoff and is_ephemeral:
                continue  # Old ephemeral — drop

            new_events.append(evt)

        if len(new_events) != before:
            self.events = new_events
            self._rebuild_state()
            self._rebuild_revocations()
            self._rebuild_counters()
            # Issue #208: cleanup may have dropped expired events.
            self._invalidate_merkle_cache()
            self._save()
            logger.info(f"Infonet cleanup: removed {before - len(new_events)} expired events")

    # ─── Gossip Sync (Future) ────────────────────────────────────────

    def get_merkle_root(self) -> str:
        """Compute a Merkle root hash of the Infonet for sync comparison.

        Two nodes with the same Merkle root have identical chains. Reads
        from the cached Merkle levels (issue #208) — O(1) when the chain
        hasn't changed since the last computation.
        """
        if not self.events:
            return GENESIS_HASH

        levels = self._get_merkle_levels()
        if not levels or not levels[-1]:
            return GENESIS_HASH
        return levels[-1][0] or GENESIS_HASH

    def get_merkle_proofs(self, start_index: int, count: int) -> dict:
        """Return merkle proofs for a contiguous range of events.

        Issue #208: uses the cached Merkle levels so this is O(count *
        log n) per request, not O(n + count * log n). Anonymous peers
        hitting ``/api/mesh/infonet/sync?include_proofs=true`` no longer
        force a rebuild on every call.
        """
        total = len(self.events)
        if total == 0:
            return {"root": GENESIS_HASH, "total": 0, "start": 0, "proofs": []}

        from services.mesh.mesh_merkle import merkle_proof_from_levels

        leaves = [e["event_id"] for e in self.events]
        start = max(0, start_index)
        end = min(total, start + max(0, count))
        levels = self._get_merkle_levels()
        root = levels[-1][0] if levels and levels[-1] else GENESIS_HASH

        proofs = []
        for idx in range(start, end):
            proofs.append(
                {
                    "index": idx,
                    "leaf": leaves[idx],
                    "proof": merkle_proof_from_levels(levels, idx),
                }
            )

        return {"root": root, "total": total, "start": start, "proofs": proofs}

    def get_locator(self, max_entries: int = 32) -> list[str]:
        """Build a block locator for fork-aware sync."""
        if not self.events:
            return [GENESIS_HASH]

        locator: list[str] = []
        idx = len(self.events) - 1
        step = 1
        count = 0

        while idx >= 0 and len(locator) < max_entries - 1:
            locator.append(self.events[idx]["event_id"])
            if count >= 9:
                step *= 2
            idx -= step
            count += 1

        locator.append(GENESIS_HASH)
        return locator

    def get_events_after_locator(
        self, locator: list[str], limit: int = 100
    ) -> tuple[str, int, list[dict]]:
        """Find a common ancestor in the locator and return events after it."""
        if not locator:
            locator = [GENESIS_HASH]

        for hsh in locator:
            if hsh == GENESIS_HASH:
                return GENESIS_HASH, 0, self.events[:limit]
            idx = self.event_index.get(hsh)
            if idx is not None:
                start = idx + 1
                return hsh, start, self.events[start : start + limit]

        return "", -1, []

    def get_events_after(self, after_hash: str, limit: int = 100) -> list[dict]:
        """Get events after a given hash (for delta sync).

        If after_hash is GENESIS_HASH, returns from the beginning.
        """
        if after_hash == GENESIS_HASH:
            return self.events[:limit]

        # Find the event with this hash
        idx = self.event_index.get(after_hash)
        if idx is None:
            return []  # Hash not found — full sync needed

        return self.events[idx + 1 : idx + 1 + limit]


# ─── Module-level singleton ─────────────────────────────────────────────

infonet = Infonet()
gate_store = GateMessageStore(data_dir=str(GATE_STORE_DIR))

# Backwards-compatible alias so existing imports don't break
hashchain = infonet
