"""Synchronous broker client + line-JSON transport.

The Zig side of the no-FFI boundary is ``hrb-code/src/server.zig``; this
file is the Python half. They share BYTES on a Unix socket. Memory
ownership never crosses the language boundary; errors cross as typed
status codes that become the Python exceptions defined in
``mgf.hrb.exceptions``.

DP-13 deterministic cleanup: the socket is owned via a context manager.
DP-09 explicit cancellation: every long-lived call takes an explicit
``deadline_ms`` argument (the SDK forwards it to the broker so the
survivability contract holds end-to-end — D-029).

The client surface here is the minimum needed for the recipes that ship
today:
    * ``list(scope)`` — Observer
    * ``claim(urn, mode, deadline_ms=0)`` → ``Lease`` (context manager)
    * ``release(urn, lease, deadline_ms=0)`` — total + idempotent
    * ``raw(urn, lease, deadline_ms=0)`` — RawAccessor

The async + event-subscription APIs (``monitor()`` / ``amonitor()`` /
``monitor_fd()`` per design Part 8.3) require a broker-side event stream
that doesn't exist yet — see CHANGELOG Tier-1 follow-ups.
"""

from __future__ import annotations

import json
import logging
import socket
import struct
import threading
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Self

from mgf.hrb._shm import ShmChannel
from mgf.hrb.domain import (
    AuditRecord,
    AuditTailResult,
    BackendCapability,
    ChainEntry,
    ClaimMode,
    ControlOutcome,
    ControlVerb,
    DriverInfo,
    DumpAllResult,
    DumpChainResult,
    InspectFullResult,
    LeaseRecord,
    ResourceDetail,
    StateSnapshot,
    Subscription,
    SupersededBy,
    WorkerSnapshot,
)
from mgf.hrb.exceptions import MgfHrbError, from_wire
from mgf.hrb.exceptions import TimeoutError as HrbTimeoutError

# I1 (review wave 2) — module-scope logger. Per the user's
# "rich, clear, explicit logging — no assumptions" standard
# this module was previously SILENT despite being the SDK's
# transport hot path. Every wire op now emits at DEBUG (entry
# + exit + outcome), broker disconnects + handshake failures
# log at WARNING, and the structured `extra={...}` shape is
# uniform across the SDK so log aggregators get parseable
# fields (op, urn, cap_id, deadline_ms, exc_type).
# Consumers tune via standard logging config:
#   logging.getLogger("mgf.hrb").setLevel(logging.DEBUG)
logger = logging.getLogger(__name__)

# Size of one fd as bytes in a SCM_RIGHTS cmsg (int = 4 bytes on Linux).
_FD_SIZE = struct.calcsize("i")

# F-HRB-001 — hard ceiling on a single broker response line. The control
# plane is line-delimited JSON; legitimate responses are small (kilobytes).
# A wedged or buggy broker that streams bytes without a terminating newline
# would otherwise grow `self._buf` without bound until the client OOMs. 8 MiB
# is far above any real response and well below a DoS. Tripping it means the
# broker is misbehaving, so we raise rather than truncate.
_MAX_RESPONSE_BYTES = 8 * 1024 * 1024

# Type aliases defined OUTSIDE the Client class so mypy resolves `list` /
# `dict` to the builtins, not the `Client.list` method (which would shadow
# them within the class body).
CapabilitiesReport = list[dict[str, Any]]
# Raw dict shape of the `inspect` response — preserved on
# `Client.inspect_raw()` as the escape hatch (FEEDBACK SDK-02 typed
# half). The typed projection is `domain.ResourceDetail`, exported
# at the top of this module.
ResourceDetailRaw = dict[str, Any]
EventList = list[dict[str, Any]]

if TYPE_CHECKING:
    from collections.abc import AsyncIterator, Iterator
    from types import TracebackType

    from mgf.hrb.diagnostics import DoctorFinding

# Type aliases for return types whose annotation reads `list[...]`.
# Declared at module level (NOT under TYPE_CHECKING) so they resolve
# at runtime, and to dodge mypy's confusion when `list[...]` appears
# in the body of a class that also defines a `list(...)` method
# (Client.list shadows the builtin inside method bodies; aliases
# defined outside that scope are unaffected).
DoctorReport = list["DoctorFinding"]
CapabilityList = list[BackendCapability]
LeaseList = list[LeaseRecord]
# Defined outside the class so `list` resolves to the builtin, not the
# `Client.list` method (which shadows it inside the class body) — same
# reason as the aliases above. Used by `Client.attenuate`'s `rights` arg.
RightNameList = list[str]

__all__ = ["Client", "Lease"]


@dataclass(frozen=True, slots=True)
class _MonitorFdResources:
    """Internal bookkeeping for the resources `monitor_fd()` spawns:
    the daemon thread polling drain_events, the stop event signalling
    it to exit, and the read+write pipe ends. The thread shares the
    main Client's transport — protected by the transport's lock — so
    it doesn't need a separate broker connection (which today's
    serial-accept broker couldn't grant alongside the main one).
    `Client.close()` cleans these up via `_close_monitor_fd()`.
    FEEDBACK SDK-01 implementation detail — consumers never see
    this type."""

    thread: threading.Thread
    stop_event: threading.Event
    read_fd: int
    write_fd: int


class _CorrelationScope:
    """Context manager set up by :py:meth:`Client.correlation_scope`.

    Saves the transport's previous `_correlation_id` on enter, sets
    the scope's value, restores on exit (nest-safe). Sentinel return
    of `None` from __enter__ — the consumer uses `with ... :`, not
    `with ... as x:`; the scope's effect is on the transport, not
    on a returned object.

    Part 13 §5.6.3 — the broker side does the echo; this is the
    SDK-side convenience that means "do this everywhere I call within
    the block."
    """

    __slots__ = ("_prev", "_transport", "_value")

    def __init__(self, transport: _Transport, value: str) -> None:
        self._transport = transport
        self._value = value
        self._prev: str | None = None

    def __enter__(self) -> None:
        self._prev = self._transport._correlation_id
        self._transport._correlation_id = self._value

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> None:
        self._transport._correlation_id = self._prev


class _Transport:
    """Owns the socket; serializes a request, reads one response line.

    The only 'FFI-like' thing the SDK ever does is recv bytes — handled
    natively by ``socket``. The protocol is line-JSON for the skeleton;
    the production data plane uses the shm ring in Part 5.3 with this
    socket as the control plane.
    """

    def __init__(self, sock: socket.socket) -> None:
        self._sock = sock
        self._buf = b""
        # Serialize wire access across threads. The line-JSON protocol
        # is strictly request → response, so interleaving sends from
        # two threads would scramble the response stream. This lock
        # makes Client.* methods safe to call from worker threads
        # (the `monitor_fd()` polling thread and `amonitor()`'s
        # `asyncio.to_thread` invocations both depend on it).
        # FEEDBACK SDK-01: thread-safety is the precondition for the
        # async/fd surfaces.
        self._lock = threading.Lock()
        # Part 13 §5.6.3 — when set by `Client.correlation_scope(...)`,
        # this id rides every wire request the transport issues inside
        # the scope; the broker echoes it back. Per-Transport (not
        # per-thread) because the scope is a Client-level concept;
        # callers wanting per-thread scoping pass `correlation_id=...`
        # explicitly to each op (which wins over this default).
        self._correlation_id: str | None = None

    def call(self, **req: Any) -> dict[str, Any]:
        with self._lock:
            # Inject the active correlation_id scope if one is set AND
            # the caller didn't pass an explicit one. Explicit wins.
            if self._correlation_id is not None and "correlation_id" not in req:
                req["correlation_id"] = self._correlation_id
            # I1 — DEBUG instrumentation on wire-call entry. Includes
            # every operator-meaningful field so a `-v` trace lets
            # someone reconstruct what the SDK actually sent without
            # re-running with a wire dump. We avoid logging `value`
            # for control (could carry user data); only the shape is
            # logged at DEBUG.
            op = req.get("op", "?")
            logger.debug(
                "wire send op=%s",
                op,
                extra={
                    "op": op,
                    "urn": req.get("urn"),
                    "cap_id": req.get("cap_id"),
                    "deadline_ms": req.get("deadline_ms"),
                    "correlation_id": req.get("correlation_id"),
                },
            )
            try:
                resp = self._call_locked(**req)
            except MgfHrbError as exc:
                # I1 — log AT THE TRANSPORT BOUNDARY so even consumers
                # who catch+swallow MgfHrbError leave a forensic trail
                # for `-v` operators. Use DEBUG (not WARNING) because
                # the broker's typed errors are expected business
                # outcomes (NotFound, Conflict, NotPermitted) — only
                # the connection-closed family is genuinely
                # exceptional and gets WARNING in `_recv_line`.
                logger.debug(
                    "wire op=%s raised %s: %s",
                    op,
                    type(exc).__name__,
                    exc,
                    extra={
                        "op": op,
                        "urn": req.get("urn"),
                        "exc_type": type(exc).__name__,
                    },
                )
                raise
            return resp

    def _call_locked(self, **req: Any) -> dict[str, Any]:
        # J4 (review wave 2 follow-up) — wrap socket OSError as
        # MgfHrbError so a consumer catching the typed family
        # doesn't miss the "broker died mid-call" path. Pre-J4 a
        # BrokenPipeError / ConnectionResetError would propagate
        # raw; a Qt callback or any `except MgfHrbError` block
        # would silently let it bypass + escape to an unhandled
        # event-loop crash.
        # F-HRB-002 / S2 (D-029) — bound the CLIENT's own wait, not just the
        # broker's. `deadline_ms` is already forwarded on the wire so the
        # broker can self-cancel, but a wedged broker that accepts the
        # request and then never replies would otherwise hang this SDK
        # thread forever — and Reliability is mgf-hrb's #1 priority. A
        # positive deadline bounds every blocking send/recv below; the
        # `finally` restores blocking mode so the timeout never leaks onto
        # the next call (mirrors the hello-handshake discipline). A socket
        # timeout (subclass of OSError) is re-typed to the broker
        # `TimeoutError` family by the send/recv handlers.
        deadline_ms = req.get("deadline_ms") or 0
        timed = deadline_ms > 0
        if timed:
            self._sock.settimeout(deadline_ms / 1000.0)
        try:
            try:
                self._sock.sendall((json.dumps(req) + "\n").encode())
            except OSError as exc:
                if isinstance(exc, socket.timeout):
                    raise HrbTimeoutError(
                        f"broker did not accept op={req.get('op')!r} within "
                        f"{deadline_ms}ms (client-side deadline)"
                    ) from exc
                logger.warning(
                    "broker connection lost during send (op=%s): %s",
                    req.get("op"),
                    exc,
                    extra={
                        "op": req.get("op"),
                        "urn": req.get("urn"),
                        "errno": getattr(exc, "errno", None),
                        "exc_type": type(exc).__name__,
                    },
                )
                raise MgfHrbError(
                    f"broker connection lost while sending op={req.get('op')!r} "
                    f"({type(exc).__name__}: {exc}) — broker may have crashed "
                    "or closed the socket"
                ) from exc
            # For ops that may carry an fd (`raw` today; future ops can opt
            # in by name), use recvmsg so the SCM_RIGHTS cmsg attaches to
            # the response bytes in one go. For everything else, plain recv
            # is fine and cheaper.
            if req.get("op") in ("raw", "open_shm_channel"):
                line, fd = self._recv_line_with_optional_fd()
            else:
                line = self._recv_line()
                fd = -1
        finally:
            if timed:
                self._sock.settimeout(None)

        # G4 (review wave 2) — fd ownership discipline.
        #
        # The kernel installed `fd` into our fd table as part of the
        # recvmsg above. EVERY exit path from here on must either
        # (a) transfer ownership to the returned dict (success +
        #     "fd":true), or
        # (b) close it (every other path: bad JSON, ok:false from
        #     broker, defensive "fd arrived without promise" branch).
        # The wrap below is the structural guarantee: `fd_consumed`
        # flips True the instant we commit ownership; the finally
        # block closes `fd` only when the consume flag is False.
        # Pre-G4 the json.loads on line below could raise (broker
        # returns malformed bytes after a recvmsg-installed cmsg) →
        # fd leaked silently.
        fd_consumed = False
        try:
            resp: dict[str, Any] = json.loads(line)
            if not resp.get("ok"):
                # Broker returned a typed error. If an fd somehow
                # arrived alongside an error response (broker bug),
                # the finally below closes it.
                raise from_wire(resp.get("err", "Unknown"))

            # The wire says `"fd":true` when an fd was passed; replace
            # the boolean with the received int fd. If the wire says
            # false but we somehow got one anyway, the finally closes it.
            if resp.get("fd") is True:
                if fd < 0:
                    raise MgfHrbError("broker promised an fd but the SCM_RIGHTS cmsg was missing")
                resp["fd"] = fd
                fd_consumed = True  # ownership transfers to `resp`
            return resp
        finally:
            if fd >= 0 and not fd_consumed:
                # Defensive close — fires on json.loads-failure (bad
                # wire bytes), ok:false response that nonetheless
                # carried an fd, or `"fd":false` with an fd attached
                # (broker bug). Importing os here keeps the SDK's
                # module namespace os-free for the common case.
                import os as _os

                try:
                    _os.close(fd)
                except OSError:
                    # close() raised: nothing we can do (kernel state
                    # was already weird). Swallow rather than mask the
                    # original exception (if any) that triggered the
                    # finally.
                    pass

    def _recv_line(self) -> bytes:
        """Read until a newline arrives in the buffer; return the line
        (without the newline). Subsequent bytes stay in self._buf.

        I1 (review wave 2) — on broker EOF we now WARNING-log with
        the bytes already buffered (so an operator chasing "broker
        disconnected mid-request" sees whether ANY of the response
        arrived) and raise with an op-aware message. Previously the
        exception said just "connection closed" with no context.
        """
        while b"\n" not in self._buf:
            # J4 — wrap recv OSError (ConnectionReset mid-stream,
            # ECONNABORTED, etc.) as MgfHrbError so the typed catch
            # in callers covers it. EOF (empty chunk) already maps
            # to MgfHrbError; this closes the remaining path.
            try:
                chunk = self._sock.recv(4096)
            except OSError as exc:
                if isinstance(exc, socket.timeout):
                    raise HrbTimeoutError(
                        "broker response not received within the client-side "
                        f"deadline (buffered={len(self._buf)} bytes)"
                    ) from exc
                logger.warning(
                    "broker connection lost during recv (buffered=%d): %s",
                    len(self._buf),
                    exc,
                    extra={
                        "buffered_bytes": len(self._buf),
                        "errno": getattr(exc, "errno", None),
                        "exc_type": type(exc).__name__,
                    },
                )
                raise MgfHrbError(
                    f"broker connection lost during recv "
                    f"({type(exc).__name__}: {exc}; "
                    f"buffered={len(self._buf)} bytes)"
                ) from exc
            if not chunk:
                logger.warning(
                    "broker connection closed mid-response (buffered=%d bytes)",
                    len(self._buf),
                    extra={"buffered_bytes": len(self._buf)},
                )
                raise MgfHrbError(
                    f"broker connection closed (buffered={len(self._buf)} bytes "
                    "before EOF) — broker may have crashed or hit the active-clients cap"
                )
            self._buf += chunk
            if len(self._buf) > _MAX_RESPONSE_BYTES:
                # F-HRB-001 — refuse to buffer an unbounded response line.
                raise MgfHrbError(
                    f"broker response exceeded {_MAX_RESPONSE_BYTES} bytes with no "
                    "newline — refusing to buffer unbounded data (broker bug or wedged)"
                )
        line, self._buf = self._buf.split(b"\n", 1)
        return line

    def _recv_line_with_optional_fd(self) -> tuple[bytes, int]:
        """Read the next response line via recvmsg so any SCM_RIGHTS cmsg
        attached to its bytes is captured atomically. Returns (line, fd)
        where fd is -1 when no cmsg arrived. Honors any already-buffered
        bytes (a previous recv may have over-read into the next line)."""
        # If a previous call already buffered the entire line, return it
        # — no cmsg would be available either way (cmsg can only attach
        # to a fresh recvmsg, not to data already in our buffer).
        if b"\n" in self._buf:
            line, self._buf = self._buf.split(b"\n", 1)
            return line, -1

        # CMSG_SPACE (not CMSG_LEN) because it accounts for the alignment
        # padding the kernel expects when scanning the control buffer.
        cbuf_size = socket.CMSG_SPACE(_FD_SIZE)
        received_fd = -1
        while b"\n" not in self._buf:
            # J4 — wrap recvmsg OSError as MgfHrbError. Same
            # rationale as in `_recv_line`: typed catch in callers
            # MUST cover broker-disconnect mid-stream.
            try:
                data, ancdata, _flags, _addr = self._sock.recvmsg(4096, cbuf_size)
            except OSError as exc:
                logger.warning(
                    "broker connection lost during recvmsg (buffered=%d, received_fd=%d): %s",
                    len(self._buf),
                    received_fd,
                    exc,
                    extra={
                        "buffered_bytes": len(self._buf),
                        "errno": getattr(exc, "errno", None),
                        "exc_type": type(exc).__name__,
                    },
                )
                # If we received an fd before the failure, close it
                # — the caller never gets a chance to.
                if received_fd >= 0:
                    import os as _os

                    try:
                        _os.close(received_fd)
                    except OSError:
                        pass
                if isinstance(exc, socket.timeout):
                    raise HrbTimeoutError(
                        "broker response not received within the client-side "
                        f"deadline (buffered={len(self._buf)} bytes)"
                    ) from exc
                raise MgfHrbError(
                    f"broker connection lost during recvmsg ({type(exc).__name__}: {exc})"
                ) from exc
            if not data:
                # If an fd arrived before EOF, close it — leak
                # avoidance same as the OSError branch above.
                if received_fd >= 0:
                    import os as _os

                    try:
                        _os.close(received_fd)
                    except OSError:
                        pass
                raise MgfHrbError("connection closed")
            self._buf += data
            if len(self._buf) > _MAX_RESPONSE_BYTES:
                # F-HRB-001 — bound the buffer here too; close any fd that
                # already arrived so the reject path never leaks it.
                if received_fd >= 0:
                    import os as _os

                    try:
                        _os.close(received_fd)
                    except OSError:
                        pass
                raise MgfHrbError(
                    f"broker response exceeded {_MAX_RESPONSE_BYTES} bytes with no "
                    "newline — refusing to buffer unbounded data (broker bug or wedged)"
                )
            for level, ctype, cdata in ancdata:
                if level == socket.SOL_SOCKET and ctype == socket.SCM_RIGHTS:
                    # struct.unpack returns a tuple of the parsed int; the
                    # earlier array.frombytes shape APPENDED to a sentinel
                    # array, returning the stale initial value — a real
                    # bug we caught only with end-to-end exercise.
                    (fd_int,) = struct.unpack("i", cdata[:_FD_SIZE])
                    received_fd = fd_int
        line, self._buf = self._buf.split(b"\n", 1)
        return line, received_fd

    def close(self) -> None:
        try:
            self._sock.close()
        except OSError:
            pass


class Lease:
    """A held lease, usable as a context manager (the canonical shape for
    bounded ownership — see ``recipes/claim-a-device-for-vfio-passthrough.md``
    §3). Leaving the block releases.

    For unbounded passthrough (lease outlives the immediate code path), call
    ``client.claim(...)`` and store the ``Lease`` explicitly; release with
    ``lease.release()``.
    """

    __slots__ = ("_client", "_released", "lease_id", "urn")

    def __init__(self, client: Client, urn: str, lease_id: int) -> None:
        self._client = client
        self.urn = urn
        self.lease_id = lease_id
        self._released = False

    def release(self, deadline_ms: int = 0) -> None:
        """Release this lease. Total + idempotent (Part 3.2): releasing an
        already-released lease is a no-op."""
        if self._released:
            return
        self._client.release(self.urn, self.lease_id, deadline_ms=deadline_ms)
        self._released = True

    def raw(self, deadline_ms: int = 0) -> dict[str, Any]:
        """Obtain the raw direct channel for the held resource (G4).

        When the broker's backend has an fd-shaped raw channel (USBDEVFS,
        eventually VFIO), the returned dict carries ``fd`` as an integer
        file descriptor installed in this process's fd table by the kernel.
        The fd outlives the broker's copy (Part 7.4 — survivability
        dividend; once delegated, use is broker-independent).

        Caller owns the fd: close it when done (``os.close(d['fd'])``) or
        wrap it in a file-like via ``os.fdopen(d['fd'], 'r+b', buffering=0)``
        for byte I/O. For USBDEVFS, use ``fcntl.ioctl(d['fd'], ...)``.

        When the backend has no fd-shaped raw channel (FakeBackend with
        ``pass_fd_for_raw=False``, PCI before VFIO lands, etc.), the
        returned dict has ``fd`` absent or ``False`` — the wire still
        confirms the holder has the right, but no kernel resource is
        passed.
        """
        return self._client.raw(self.urn, self.lease_id, deadline_ms=deadline_ms)

    def __enter__(self) -> Self:
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> None:
        self.release()


class Client:
    """The synchronous broker client.

    Construct with :py:meth:`connect`; close with :py:meth:`close` or by
    using the client as a context manager (DP-13). Every long-lived call
    accepts an explicit ``deadline_ms`` (DP-09); ``0`` means "no deadline."
    """

    def __init__(
        self,
        transport: _Transport,
        cap_id: int,
        client_id: int,
        *,
        socket_path: str | None = None,
        owns_transport: bool = True,
    ) -> None:
        self._t = transport
        self._cap = cap_id
        self._id = client_id
        # The socket path the connect() call used; surfaced via
        # `Client.socket_path` so `doctor()` can re-check it from the
        # caller's perspective without the caller having to thread the
        # path through separately.
        self._socket_path = socket_path
        # R-CAP-ATTEN-WIRE: a child view from `attenuate()` SHARES the
        # parent's transport (same connection, same peer — so the sub-cap's
        # connection binding matches). It must NOT own the socket: closing
        # the child leaves the parent's connection intact; the socket is
        # torn down once, by whoever opened it via `connect()`.
        self._owns_transport = owns_transport
        # FEEDBACK SDK-01 monitor_fd state. The fd-shaped event surface
        # spawns its own second Client (separate socket) + a daemon
        # thread that polls drain_events and pipes events to a self-pipe.
        # We track the resources here so `close()` can tear them down.
        # `None` means no monitor_fd is currently active.
        self._monitor_fd_resources: _MonitorFdResources | None = None

    @property
    def socket_path(self) -> str | None:
        """The Unix-socket path this Client connected to, or ``None``
        when constructed without a path (test fakes, direct
        ``Client(_Transport(...))`` calls)."""
        return self._socket_path

    @classmethod
    def connect(
        cls,
        path: str,
        *,
        client_id: int = 1,
        hello_deadline_ms: int = 2000,
    ) -> Client:
        """Open a Unix-socket connection to the broker and complete the
        hello handshake.

        Part 12 §3.3 Tier-0 #2: the broker mints its root capability
        with a CSPRNG-drawn id (unforgeable, ~2^63 entropy). On accept,
        the broker sends a hello message carrying that ``cap_id``; we
        read it here and present it on every subsequent request. A
        connection that doesn't speak the handshake cannot guess a
        working cap_id (the U3 forgery closure).

        **Hello wire format** (Part 12 §3.3): the broker writes one
        line on accept, before any client byte::

            {"ok": true, "hello": "mgf-hrb", "cap_id": <int>}\\n

        ``cap_id`` is the CSPRNG-drawn root capability id this client
        will present on every subsequent op. Consumer-side test fakes
        MUST volunteer this line on accept; ``Client.connect`` blocks
        reading it before returning.

        **Hello deadline (SDK-06).** ``hello_deadline_ms`` (default
        ``2000``) bounds the recv that waits for the hello line. If
        the peer completes ``accept`` but never writes the hello (DoS,
        partial implementation, wedged broker, half-baked test fake),
        ``Client.connect`` raises ``MgfHrbError`` with a precise
        message naming the deadline — instead of blocking
        indefinitely. The deadline is restored to blocking-mode after
        the hello completes, so subsequent ops use their own
        ``deadline_ms`` parameter.
        """
        # I1 — log connect attempt at DEBUG so `-v` traces show the
        # connection lifecycle from the first syscall.
        logger.debug(
            "connecting to broker at %s (client_id=%d, hello_deadline_ms=%d)",
            path,
            client_id,
            hello_deadline_ms,
            extra={
                "socket_path": path,
                "client_id": client_id,
                "hello_deadline_ms": hello_deadline_ms,
            },
        )
        s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        try:
            s.connect(path)
        except BaseException as exc:
            # Close the socket so it doesn't trigger a
            # PytestUnraisableExceptionWarning when gc'd later
            # (and so we don't leak the fd in production either).
            s.close()
            # I1 — connect failure is by far the most common operator-
            # facing "broker won't work" scenario; logging it with the
            # path AND the exception type AND the OS-level error gives
            # the operator everything they need to know whether to
            # check the broker, the permissions, or their own config.
            logger.warning(
                "broker connect failed: %s: %s (path=%s)",
                type(exc).__name__,
                exc,
                path,
                extra={"socket_path": path, "exc_type": type(exc).__name__},
            )
            raise

        # Hello handshake. Failure here is unrecoverable — without the
        # cap id, no cap-checked op can succeed.
        # SDK-06: bound the hello-read with a socket timeout so a
        # wedged peer can't hang us forever. Reset to blocking mode
        # before returning so subsequent ops use their own deadlines.
        s.settimeout(hello_deadline_ms / 1000.0)
        transport = _Transport(s)
        try:
            hello_line = transport._recv_line()
            hello: dict[str, Any] = json.loads(hello_line)
        except MgfHrbError as exc:
            # _recv_line wraps OSError (including socket.timeout,
            # which IS OSError since 3.10) as MgfHrbError via the J4
            # wrap. If the cause is a timeout, surface the precise
            # SDK-06 message naming the deadline; otherwise let the
            # generic handshake-failed handler downstream re-wrap.
            s.close()
            if isinstance(exc.__cause__, TimeoutError):
                logger.warning(
                    "broker hello not received within %dms (path=%s)",
                    hello_deadline_ms,
                    path,
                    extra={
                        "socket_path": path,
                        "hello_deadline_ms": hello_deadline_ms,
                    },
                )
                raise MgfHrbError(
                    f"broker hello not received within {hello_deadline_ms}ms "
                    f"(path={path}); broker may be wedged or speak no hello"
                ) from exc
            # Non-timeout MgfHrbError (broker EOF before hello, etc.).
            logger.warning(
                "broker handshake failed: %s: %s (path=%s)",
                type(exc).__name__,
                exc,
                path,
                extra={"socket_path": path, "exc_type": type(exc).__name__},
            )
            raise MgfHrbError(
                f"broker handshake failed: {exc} (path={path}, client_id={client_id})"
            ) from exc
        except Exception as exc:
            s.close()
            # I1 — the operator needs to know which phase failed
            # (connect succeeded but the broker didn't speak hello).
            # Include the socket path AND the exception kind so the
            # distinction is unambiguous.
            logger.warning(
                "broker handshake failed: %s: %s (path=%s)",
                type(exc).__name__,
                exc,
                path,
                extra={"socket_path": path, "exc_type": type(exc).__name__},
            )
            raise MgfHrbError(
                f"broker handshake failed: {exc} (path={path}, client_id={client_id})"
            ) from exc
        # Restore blocking mode — subsequent ops use their own
        # `deadline_ms` parameter and shouldn't inherit the hello
        # deadline.
        s.settimeout(None)
        if not hello.get("ok") or hello.get("hello") != "mgf-hrb":
            s.close()
            logger.warning(
                "unexpected broker greeting: %r (path=%s)",
                hello,
                path,
                extra={"socket_path": path, "hello": hello},
            )
            raise MgfHrbError(
                f"unexpected broker greeting: {hello!r} "
                f"(expected mgf-hrb hello with a cap_id; path={path})"
            )
        cap_id_val = hello.get("cap_id")
        if not isinstance(cap_id_val, int) or cap_id_val <= 0:
            s.close()
            logger.warning(
                "broker hello missing or invalid cap_id: %r (path=%s)",
                hello,
                path,
                extra={"socket_path": path, "hello": hello},
            )
            raise MgfHrbError(
                f"broker hello did not carry a positive cap_id: {hello!r} (path={path})"
            )
        logger.debug(
            "broker handshake ok cap_id=%d version=%s",
            cap_id_val,
            hello.get("version", "?"),
            extra={
                "socket_path": path,
                "cap_id": cap_id_val,
                "broker_version": hello.get("version"),
                "client_id": client_id,
            },
        )
        return cls(transport, cap_id_val, client_id, socket_path=path)

    # ---- Observer verbs --------------------------------------------------

    def list(self, urn_glob: str) -> list[str]:
        """Enumerate resources whose URN matches ``urn_glob``.

        The trailing-``**`` glob model: ``"hw:/**"`` matches everything,
        ``"hw:/pci/**"`` matches the PCI subtree only.

        The connection-bound ``cap_id`` (from the broker's hello) rides
        the request so the broker intersects the requested scope with the
        cap's scope before emitting URNs (R-T1-11A/C, S3 isolation). Today
        every client holds the root cap (scope ``hw:/**``), so the
        intersection is the request and nothing narrows; when per-client
        attenuated caps land (U-future), an attenuated client's ``list``
        is filtered automatically — no further SDK change.
        """
        resp = self._t.call(op="list", scope=urn_glob, cap_id=self._cap)
        result: list[str] = resp.get("resources", [])
        return result

    def drain_events(
        self,
        scope: str = "hw:/**",
        *,
        subscription_id: str | None = None,
    ) -> EventList:
        """Drain pending watch events. Two modes (Part 13 §5.4.2):

          * **Subscriber mode** (``subscription_id`` set) — drains
            from THAT subscription's bounded queue. Events were
            already filtered against the subscription's predicate at
            fan-out time; ``scope`` is ignored (the subscription's
            stored scope was used at fan-out).
          * **Legacy mode** (default) — pulls every backend-buffered
            event whose worker scope intersects ``scope``, returns
            them, and ALSO fans them out to every active
            subscription's queue (so concurrent subscribers see
            events even when nobody is in subscriber-mode drain).

        Events: ``[{'kind':'added'|'removed'|'driver_bound'|...,
        'urn':'...'}, ...]``. A buffer overrun arrives as
        ``{'kind':'gap','source':'netlink_uevent','since_ms':...}``;
        a subscription queue overrun as ``source='subscription_overflow'``.
        Non-blocking — call repeatedly to poll.

        ``Client.monitor()`` (below) wraps this in an iterator.
        """
        kwargs: dict[str, Any] = {"op": "drain_events", "scope": scope}
        if subscription_id is not None:
            kwargs["subscription_id"] = subscription_id
        resp = self._t.call(**kwargs)
        result: EventList = resp.get("events", [])
        return result

    def subscribe(
        self,
        sub_id: str,
        scope: str = "hw:/**",
        *,
        predicate: dict[str, Any] | None = None,
    ) -> Subscription:
        """Register a server-side event filter (Part 13 §5.4.2).

        Creates a per-client bounded queue (default 64 entries; cap
        :attr:`Subscription.queue_max`) into which the broker fans
        events that:
          * have a URN matching ``scope`` (or are gap events, which
            always pass — gap-honest), AND
          * match the ``predicate`` expression tree.

        ``predicate`` is the v1 grammar tree:
          * Operators: ``{"all_of":[...]}`` / ``{"any_of":[...]}`` /
            ``{"not": {...}}``
          * Terms:     ``{"kind_in":[...]}`` /
                       ``{"urn_glob":"..."}`` /
                       ``{"attr_eq":{"key":...,"value":...}}`` /
                       ``{"attr_in":{"key":...,"values":[...]}}``

        ``sub_id`` is caller-chosen and unique-per-connection; the
        broker rejects duplicates with ``BadRequest``. Subscriptions
        are tied to the connection — disconnect releases them all.

        To consume events, pass the returned ``Subscription.id`` as
        ``subscription_id`` to :py:meth:`drain_events`.
        """
        kwargs: dict[str, Any] = {
            "op": "subscribe",
            "id": sub_id,
            "scope": scope,
        }
        if predicate is not None:
            kwargs["predicate"] = predicate
        resp = self._t.call(**kwargs)
        return Subscription(
            id=str(resp.get("subscription_id", sub_id)),
            queue_max=int(resp.get("queue_max", 0)),
        )

    def unsubscribe(self, sub_id: str) -> None:
        """Tear down a subscription (Part 13 §5.4.2). Raises
        ``NotFoundError`` if no such subscription exists for this
        connection — idempotency is the caller's responsibility.

        A clean ``Client.close()`` reaps all the connection's
        subscriptions automatically; explicit unsubscribe is the
        ergonomic path for a long-lived client that adds and drops
        watches as its UI evolves."""
        self._t.call(op="unsubscribe", id=sub_id)

    def monitor(
        self,
        scope: str = "hw:/**",
        *,
        poll_interval_s: float = 0.5,
    ) -> Iterator[dict[str, Any]]:
        """Polling iterator over watch events. Yields each event as it
        becomes available; sleeps ``poll_interval_s`` between drains
        when nothing arrives.

        **Implementation note (Tier-1 today).** The underlying broker
        primitive is the polling ``drain_events`` op. True push (kernel
        wakes the client when an event arrives, no polling) needs the
        broker's accept loop replaced with a multi-client poll over
        per-client sockets + per-worker netlink fds (the U-future
        scope). For now the API shape is settled; only the wait
        strategy will tighten."""
        import time

        while True:
            yield from self.drain_events(scope)
            time.sleep(poll_interval_s)

    def control(
        self,
        urn: str,
        verb: ControlVerb | str,
        value: str | bool,
        *,
        cap_id: int = 0,
    ) -> ControlOutcome:
        """Dispatch a typed device-state control verb (USB-worker
        §5.10 / Part 13 §5.1).

        ``verb`` is one of ``set_authorized``, ``set_autosuspend``,
        ``set_port_power``, ``bind_driver``, ``unbind_driver``
        (also accepted as the matching :class:`ControlVerb` enum
        value). ``value`` is verb-specific — bool ``True``/``False``
        for the toggles (mapped to ``"1"``/``"0"``), ``"auto"``/
        ``"on"`` for autosuspend, the driver name for bind/unbind.

        ``cap_id`` is the writer's capability id (Part 12 §3.3
        Tier-0 #2); recorded on the per-(urn, verb) serialiser's
        state so the next writer learns who wrote before them.
        Defaults to 0 — "no cap presented"; today's broker uses
        a shared root cap so every writer looks the same on the
        wire.

        Returns a :class:`ControlOutcome` carrying the post-write
        ``generation``, ``applied_value``, ``queue_depth``, and
        ``superseded_by`` disclosure (Part 13 §5.1.2, sub-phase
        4B). Concurrent writers against the same (urn, verb)
        FIFO-serialize via the broker's per-tuple SpinMutex
        (sub-phase 4A); the disclosure tells the writer where
        their write landed in that order.

        Raises ``UnsupportedError`` against a backend without
        writable device state (PCI today, fake),
        ``NotPermittedError`` when the sysfs file isn't writable
        (the recovery hint — udev rule, plugdev membership —
        is carried in the structured permission error per Part 6).
        """
        verb_str = verb.value if isinstance(verb, ControlVerb) else verb
        if isinstance(value, bool):
            value_str = "1" if value else "0"
        else:
            value_str = value
        resp = self._t.call(
            op="control",
            urn=urn,
            verb=verb_str,
            value=value_str,
            cap_id=cap_id,
        )
        super_raw = resp.get("superseded_by")
        superseded: SupersededBy | None = None
        if super_raw is not None:
            # C2 — use .get with defaults; a future broker adding
            # fields to `superseded_by` must not KeyError the SDK.
            superseded = SupersededBy(
                generation=int(super_raw.get("generation", 0)),
                cap_id=int(super_raw.get("cap_id", 0)),
                at_ms=int(super_raw.get("at_ms", 0)),
            )
        return ControlOutcome(
            generation=int(resp.get("generation", 0)),
            applied_value=str(resp.get("applied_value", value_str)),
            queue_depth=int(resp.get("queue_depth", 0)),
            superseded_by=superseded,
        )

    def inspect(self, urn: str) -> ResourceDetail:
        """Per-resource detail (USB-worker design §5.9 / FEEDBACK
        SDK-02 typed half). Returns a typed :class:`ResourceDetail`
        frozen dataclass with ``urn``, ``family``, ``kind``,
        ``fidelity``, and a backend-specific ``attrs`` tuple
        (USB: ``idVendor`` / ``idProduct`` / ``serial`` /
        ``bInterfaceClass`` / ...; PCI: ``vendor`` / ``device`` /
        ``class`` / ``bdf``).

        Use ``detail.attr("idVendor")`` for single-key lookup, or
        ``dict(detail.attrs)`` for full dict semantics.

        Callers that need the raw wire dict (forward-compat fields
        not yet on the typed shape, or a renderer that walks
        arbitrary keys) can use :py:meth:`inspect_raw` instead.

        Raises ``NotFoundError`` if no worker owns the URN.
        """
        return ResourceDetail.from_wire(self.inspect_raw(urn))

    def inspect_raw(self, urn: str) -> ResourceDetailRaw:
        """Return the raw wire dict from ``inspect``. Same data as
        :py:meth:`inspect`, just un-projected; the escape hatch for
        consumers that need to read forward-compat fields not yet on
        the typed :class:`ResourceDetail`."""
        resp = self._t.call(op="inspect", urn=urn, cap_id=self._cap)
        result: ResourceDetailRaw = resp["resource"]
        return result

    def dump_all(
        self,
        scope: str = "hw:/**",
    ) -> DumpAllResult:
        """Bulk-inspect every URN matching ``scope`` (Part 13 §5.5.3).

        Returns a :class:`DumpAllResult` carrying the ordered list of
        per-URN :class:`ResourceDetail` records, the count, and a
        ``truncated`` flag that the broker sets when the response
        buffer filled before every match could be emitted.

        Use this when you want "everything sysfs knows about every
        device in this scope" in one round-trip — typically a GUI's
        initial-render path or a diagnostic dump. For per-URN access,
        :py:meth:`inspect` is the targeted alternative.

        ``scope`` uses the same trailing-``**`` glob model as
        :py:meth:`list` and :py:meth:`list_leases`. ``"hw:/**"``
        matches everything; ``"hw:/usb/**"`` matches the USB subtree
        only.

        Truncation is honest (G2): a scope matching more devices than
        fit in one response returns ``truncated=True`` rather than
        silently dropping records. Re-issue against a narrower scope
        in that case.
        """
        resp = self._t.call(op="dump_all", scope=scope, cap_id=self._cap)
        items_raw: list[dict[str, Any]] = resp.get("items", [])
        return DumpAllResult(
            scope=str(resp.get("scope", scope)),
            items=tuple(ResourceDetail.from_wire(item) for item in items_raw),
            count=int(resp.get("count", len(items_raw))),
            truncated=bool(resp.get("truncated", False)),
        )

    def inspect_full(
        self,
        urn: str,
        depth: str = "all",
    ) -> InspectFullResult:
        """Walk the sysfs subtree of a single URN (Part 13 §5.5.1).

        Returns an :class:`InspectFullResult` carrying the resolved
        URN, fidelity, broker-side ``captured_at_ms``, the nested
        ``tree`` of attributes / sub-directories / symlink sentinels /
        binary sentinels, and an honest ``truncated`` flag.

        ``depth`` is the v1 grammar: ``"shallow"`` walks only the
        URN's own sysfs directory; ``"all"`` recurses through every
        sub-directory (bounded by MAX_DEPTH / MAX_ENTRIES /
        MAX_FILE_BYTES; symlinks are NEVER followed). Default is
        ``"all"``. Any other value yields ``BadRequestError``.

        Symlinks render as ``{"_symlink": "<target>"}`` — the broker
        does not follow them (a sysfs symlink chain reaches all of
        ``/sys/bus/**``, which would explode the response). Binary
        attributes render as ``{"_binary": true}`` — bytes that aren't
        UTF-8 printable would corrupt JSON, so the broker emits a
        sentinel instead of the bytes.

        For an at-a-glance per-resource summary, use
        :py:meth:`inspect` (typed projection of the broker-curated
        attrs). ``inspect_full`` is the escape hatch for diagnostic
        UIs and conformance checks that need everything sysfs knows.

        Raises ``NotFoundError`` if no worker owns the URN, or
        ``UnsupportedError`` if the URN's backend does not implement
        inspect_full (e.g. the fake backend, by design).
        """
        resp = self._t.call(op="inspect_full", urn=urn, depth=depth, cap_id=self._cap)
        tree_raw: dict[str, Any] = resp.get("tree", {})
        return InspectFullResult(
            urn=str(resp.get("urn", urn)),
            fidelity=str(resp.get("fidelity", "live")),
            captured_at_ms=int(resp.get("captured_at_ms", 0)),
            tree=tree_raw,
            truncated=bool(resp.get("truncated", False)),
        )

    def inspect_full_raw(
        self,
        urn: str,
        depth: str = "all",
    ) -> dict[str, Any]:
        """Return the raw wire dict from ``inspect_full``. Same data
        as :py:meth:`inspect_full`, just un-projected; the escape
        hatch for diagnostic renderers that walk the response without
        the typed dataclass wrapper."""
        return self._t.call(op="inspect_full", urn=urn, depth=depth, cap_id=self._cap)

    def dump_chain(self, urn: str) -> DumpChainResult:
        """Walk the URN's parent chain upward (Part 13 §5.5.2).

        Returns a :class:`DumpChainResult` carrying the ordered tuple
        of :class:`ChainEntry` rungs — leaf-first, substrate root last
        — plus the broker's fidelity tag, ``captured_at_ms``, and an
        honest ``truncated`` flag.

        For a USB interface URN, the chain reads as: interface →
        device → (intermediate hubs, if any) → root_hub → controller
        (the PCI host controller's BDF, synthesized as a `hw:/pci/...`
        URN). For a PCI function URN: device → (upstream bridges, if
        any) → root_complex (a synthetic `hw:/pci/root/pci<dom>:<bus>`
        URN identifying the host bridge group).

        Intermediate URNs (device / hub / bridge / root_hub /
        root_complex) are TOPOLOGICAL LABELS — they are NOT first-class
        enumerable resources. Only the leaf rung's URN matches a URN
        returned by :py:meth:`list`; the consumer that wants per-rung
        detail calls :py:meth:`inspect_full` on enumerable rungs only.

        For diagnostic UIs and "what does this device hang off of?"
        scenarios, ``dump_chain`` is the primitive that gives an
        at-a-glance topology trace without the per-URN cost of
        ``inspect_full`` x N.

        Raises ``NotFoundError`` if no worker owns the URN, or
        ``UnsupportedError`` if the URN's backend has no parent-chain
        topology (the fake backend, by design).
        """
        resp = self._t.call(op="dump_chain", urn=urn, cap_id=self._cap)
        chain_raw: list[dict[str, Any]] = resp.get("chain", [])
        return DumpChainResult(
            urn=str(resp.get("urn", urn)),
            fidelity=str(resp.get("fidelity", "live")),
            captured_at_ms=int(resp.get("captured_at_ms", 0)),
            # C2 — .get defaults so a chain entry with a missing
            # field (older broker, or a future field rename) returns
            # an empty string instead of raising KeyError.
            chain=tuple(
                ChainEntry(
                    urn=str(row.get("urn", "")),
                    level=str(row.get("level", "")),
                    summary=str(row.get("summary", "")),
                )
                for row in chain_raw
            ),
            truncated=bool(resp.get("truncated", False)),
        )

    def dump_chain_raw(self, urn: str) -> dict[str, Any]:
        """Return the raw wire dict from ``dump_chain``. Same data
        as :py:meth:`dump_chain`, just un-projected; the escape
        hatch for renderers that prefer the dict shape."""
        return self._t.call(op="dump_chain", urn=urn, cap_id=self._cap)

    def capabilities(self) -> CapabilityList:
        """Report the broker's worker constellation (Part 6.2.1) as a
        list of typed :class:`BackendCapability` records.

        Each record carries ``scope_prefix`` (e.g. ``"hw:/pci/"``),
        ``backend_name`` (e.g. ``"mgf-hrb-linux"``), ``resource_count``,
        and the forward-compatible ``backend_version`` / ``mode`` /
        ``mode_reason`` / ``ops`` fields when the broker reports them.

        FEEDBACK SDK-05: the wire still emits dicts; the projection
        happens here at the SDK seam. Callers who need the raw dicts
        (CLI doctor's rich worker-grid render) call
        :py:meth:`capabilities_raw` instead — both stay supported.

        Newer-broker fields land as additional optional fields on
        :class:`BackendCapability`; older brokers see ``None``/empty
        for fields they don't yet send. No projection-layer break.
        """
        return [BackendCapability.from_wire(row) for row in self.capabilities_raw()]

    def capabilities_raw(self) -> CapabilitiesReport:
        """Return the raw wire dicts from ``capabilities``. Same data
        as :py:meth:`capabilities`, just un-projected; preserved as the
        escape hatch for consumers that need the dict shape (the CLI
        doctor's per-worker ops grid renders directly from this)."""
        resp = self._t.call(op="capabilities")
        result: list[dict[str, Any]] = resp.get("backends", [])
        return result

    def driver_info(self, name: str) -> DriverInfo:
        """Report the broker-visible interface of a kernel driver
        (Part 13 §5.2.2).

        Returns a :class:`DriverInfo` carrying:
          * the driver's load state + bound-device count (live, from
            `/sys/module/` and `/sys/bus/<bus>/drivers/`),
          * a sample bound device (best-effort; `sample_bound=None`
            when nothing's bound),
          * the curated registry's ioctls / char-devices /
            uevent-actions / registry-version fields (`None` in v1;
            populated when sub-phase 3B ships).

        Honest emptiness: a driver that's not present on this host
        returns ``module_loaded=False, bound_count=0`` rather than
        raising — consumers learn "driver not here" structurally,
        without a separate exception path.

        Raises ``BadRequestError`` for empty / out-of-charset names
        (only ``[A-Za-z0-9_-]`` is allowed — defends against
        path-traversal in the underlying sysfs read).
        """
        resp = self._t.call(op="driver_info", name=name)
        return DriverInfo(
            name=str(resp.get("name", name)),
            module=resp.get("module"),
            module_loaded=bool(resp.get("module_loaded", False)),
            bound_count=int(resp.get("bound_count", 0)),
            sample_bound=resp.get("sample_bound"),
            sysfs_attrs=tuple(resp.get("sysfs_attrs", [])),
            ioctls=tuple(resp["ioctls"]) if resp.get("ioctls") else None,
            char_devices=(tuple(resp["char_devices"]) if resp.get("char_devices") else None),
            uevent_actions=(tuple(resp["uevent_actions"]) if resp.get("uevent_actions") else None),
            registry_version=resp.get("registry_version"),
            fidelity=str(resp.get("fidelity", "unknown")),
        )

    def audit_tail(self, n: int = 100) -> AuditTailResult:
        """Read up to ``n`` most-recent records from the broker's
        audit ring (Part 13 §5.6.4).

        Useful for retrospective queries: "show me what happened
        in the last 30 seconds while my claim was failing". Each
        record carries timing, outcome, optional URN + err code,
        and any ``correlation_id`` the caller passed through —
        enough to reconstruct a causal trace from the broker side.

        ``n`` defaults to 100 (a reasonable "interactive minute"
        window). The broker caps requests at 4096 (the ring
        capacity); larger values reject with ``BadRequestError``.

        Gated by the ``audit_read`` right (Part 13 §8). The
        client's cap_id (from the broker's hello) is forwarded
        automatically; the broker rejects with
        ``NotPermittedError`` when the cap doesn't carry the
        right (e.g. a future attenuated sub-cap minted without
        ``audit_read``).

        Returns an :class:`AuditTailResult` carrying the records
        (newest first), the ring's total capacity, and the
        timestamp of the oldest record in the returned slice.
        """
        resp = self._t.call(op="audit_tail", n=n, cap_id=self._cap)
        recs_raw: list[dict[str, Any]] = resp.get("records", [])
        # C2 — .get-with-default so an audit record missing any
        # expected field (older broker, or future field rename)
        # doesn't KeyError the entire tail projection.
        return AuditTailResult(
            records=tuple(
                AuditRecord(
                    seq=int(r.get("seq", 0)),
                    ts_ms=int(r.get("ts_ms", 0)),
                    duration_us=int(r.get("duration_us", 0)),
                    op=str(r.get("op", "")),
                    outcome=str(r.get("outcome", "")),
                    peer_uid=int(r.get("peer_uid", 0)),
                    peer_pid=int(r.get("peer_pid", 0)),
                    cap_id=int(r.get("cap_id", 0)),
                    urn=r.get("urn"),
                    err=r.get("err"),
                    correlation_id=r.get("correlation_id"),
                )
                for r in recs_raw
            ),
            ring_capacity=int(resp.get("ring_capacity", 0)),
            earliest_ts_ms=int(resp.get("earliest_ts_ms", 0)),
        )

    def state_snapshot(self) -> StateSnapshot:
        """Aggregate broker-wide counters (Part 13 §5.6.2).

        Returns a :class:`StateSnapshot` carrying the broker's
        uptime, op count, active-connection / lease / subscription
        counts, audit-ring fill, and a per-worker summary
        (``WorkerSnapshot`` per scope prefix).

        Cheap — every value lives in broker memory; no I/O.
        Useful for ``mgf-hrb doctor``, dashboards, the GUI's
        "broker health" pane.

        Gated by the ``audit_read`` right (Part 13 §8); the
        client's cap_id is forwarded automatically.
        """
        resp = self._t.call(op="state_snapshot", cap_id=self._cap)
        workers_raw: dict[str, dict[str, Any]] = resp.get("workers", {})
        memory_raw = resp.get("memory") or {}
        return StateSnapshot(
            ts_ms=int(resp.get("ts_ms", 0)),
            uptime_ms=int(resp.get("uptime_ms", 0)),
            broker_version=str(resp.get("broker_version", "")),
            ops_total=int(resp.get("ops_total", 0)),
            ops_per_second_1m=float(resp.get("ops_per_second_1m", 0.0)),
            active_connections=int(resp.get("active_connections", 0)),
            active_leases=int(resp.get("active_leases", 0)),
            active_subscriptions=int(resp.get("active_subscriptions", 0)),
            cap_table_size=int(resp.get("cap_table_size", 0)),
            audit_ring_used=int(resp.get("audit_ring_used", 0)),
            audit_ring_capacity=int(resp.get("audit_ring_capacity", 0)),
            audit_dropped_total=int(resp.get("audit_dropped_total", 0)),
            memory_rss_bytes=int(memory_raw.get("rss_bytes", 0)),
            # C1: workers is now a tuple (genuinely hashable).
            # C2: use .get with sensible defaults so a future broker
            # adding a new worker field (or renaming one) doesn't
            # KeyError the SDK; the snapshot stays usable.
            workers=tuple(
                WorkerSnapshot(
                    scope_prefix=str(k),
                    backend=str(v.get("backend", "")),
                    mode=str(v.get("mode", "")),
                    url_count=int(v.get("url_count", 0)),
                )
                for k, v in workers_raw.items()
            ),
            fidelity=str(resp.get("fidelity", "live")),
        )

    def predicate_grammar_version(self) -> str:
        """The broker's predicate grammar wire version (Part 13 §7.3).

        Returns the semver-shaped string the broker reports in its
        ``capabilities`` response (``"1.0.0"`` for v1: the full
        kind_in / urn_glob / attr_eq / attr_in / all_of / any_of /
        not grammar shipped in sub-phase 2A).

        Consumers building a non-trivial predicate query this BEFORE
        sending a subscribe so they can downgrade unknown features
        rather than discover BadRequest at runtime. Future grammar
        growth bumps the version additively (OCP); subtractive
        changes are disallowed.

        Older brokers that don't yet report this field return
        ``"0.0.0"`` here — distinguishable from any real version so
        consumers can branch on absence."""
        resp = self._t.call(op="capabilities")
        return str(resp.get("predicate_grammar_version", "0.0.0"))

    def list_leases(
        self,
        scope: str = "hw:/**",
        *,
        deadline_ms: int = 0,
    ) -> LeaseList:
        """List live leases whose URN matches ``scope`` (FEEDBACK SDK-03).

        Pure read; never claims, never writes. Returns one
        :class:`LeaseRecord` per matching lease — including leases
        held by OTHER tenants (the GUI multi-operator UX rule that
        motivated this verb). Each record carries the lease's URN,
        id, mode, holder client-id, and the broker's monotonic
        ``granted_at_ms`` so a GUI can render "since when."

        ``scope`` uses the trailing-``**`` glob model: ``"hw:/**"``
        matches everything; ``"hw:/usb/**"`` matches the USB subtree
        only. The broker filters server-side so wide-net queries on
        a busy host don't pay the cost of serializing the full table.
        """
        resp = self._t.call(
            op="list_leases",
            scope=scope,
            cap_id=self._cap,
            deadline_ms=deadline_ms,
        )
        rows: list[dict[str, Any]] = resp.get("leases", [])
        return [
            LeaseRecord(
                urn=str(row.get("urn", "")),
                lease_id=int(row.get("lease_id", 0)),
                mode=ClaimMode(str(row.get("mode", "exclusive"))),
                holder=int(row.get("holder", 0)),
                granted_at_ms=int(row.get("granted_at_ms", 0)),
            )
            for row in rows
        ]

    def doctor(self, *, socket_path: str | None = None) -> DoctorReport:
        """Run the broker-precondition check suite (FEEDBACK TOOLS-02).

        Returns one :class:`mgf.hrb.DoctorFinding` per check with a
        stable ``code`` and four-way ``severity``
        (``ok`` / ``warn`` / ``fail`` / ``deferred``). The same
        engine the ``mgf-hrb doctor`` CLI command uses
        (:mod:`mgf.hrb.diagnostics`), so a GUI dashboard rendering
        the results sees identical findings in identical order.

        ``socket_path`` defaults to the path this Client connected to
        (``Client.socket_path``). Pass an explicit path to check a
        different broker without having to open a second connection.

        The local checks (SOCKET_REACHABLE from this process,
        PCI/USB sysfs readable, IOMMU populated, VFIO device) always
        run. The broker-side check (BACKEND_CAPABILITIES) runs against
        ``self`` — the open connection — and returns its finding
        alongside the locals.

        Pure read — never claims, never writes, never blocks past the
        underlying ``capabilities`` call.
        """
        # Late import keeps the diagnostics module out of every
        # consumer's import graph — only `Client.doctor()` callers pay
        # the cost.
        from mgf.hrb import diagnostics

        path = socket_path if socket_path is not None else self._socket_path
        if path is None:
            raise ValueError(
                "Client.doctor() needs a socket_path — pass one explicitly, or "
                "construct the Client via `Client.connect(path)` so it can "
                "remember the path."
            )
        return diagnostics.run_all(path, client=self)

    # ---- Claimant verbs --------------------------------------------------

    def claim(
        self,
        urn: str,
        mode: ClaimMode | str = ClaimMode.EXCLUSIVE,
        *,
        deadline_ms: int = 0,
        detach: bool = False,
    ) -> Lease:
        """Claim a resource. Returns a ``Lease`` that doubles as a context
        manager (canonical use in ``with client.claim(...) as lease:``).

        ``detach=True`` opts into the atomic disconnect-then-claim path
        (USBDEVFS_DISCONNECT_CLAIM on USB). The bound kernel driver —
        uvcvideo for a webcam, usbhid for a HID, etc. — is detached as
        part of the claim. Cap-gated by the ``detach_driver`` right; the
        default ``False`` preserves the broker's "never silently steal
        devices" stance (a plain claim against a kernel-driver-bound
        interface returns ``ConflictError``). Backends without a detach
        primitive (FakeBackend, PCI today) return ``UnsupportedError``
        when ``detach=True``."""
        mode_str = mode.value if isinstance(mode, ClaimMode) else mode
        resp = self._t.call(
            op="claim",
            urn=urn,
            mode=mode_str,
            cap_id=self._cap,
            holder=self._id,
            deadline_ms=deadline_ms,
            detach=detach,
        )
        return Lease(self, urn, int(resp["lease_id"]))

    def release(self, urn: str, lease: int, *, deadline_ms: int = 0) -> None:
        """Release a held lease. Total + idempotent for the caller (Part 6.3)."""
        self._t.call(
            op="release",
            urn=urn,
            lease_id=lease,
            deadline_ms=deadline_ms,
        )

    def revoke(self, urn: str, *, deadline_ms: int = 0) -> None:
        """Forcibly end any held lease on ``urn`` and tear down the
        backend's kernel-side claim (Tier-0 #4, survivability §3.4).

        Unlike :py:meth:`release` (the holder ending their OWN
        lease), `revoke` is broker-initiated: the caller takes
        the device AWAY from whoever currently holds it. This is
        the preemption/policy primitive — the same path that
        powers fairness across tenants in Family A.

        Mechanism:
          * The backend's kernel-side claim is torn down
            (USBDEVFS_RELEASEINTERFACE + close on USB; IOMMU
            mapping teardown on VFIO when that lands).
          * The broker's lease engine moves the holder's lease
            into the revoked set; future :py:meth:`raw` /
            :py:meth:`release` calls return
            :class:`RevokedError`.
          * The holder's dup'd raw-channel fd keeps existing
            but reads/writes through it return ENODEV — the
            kernel-side binding is gone. "The handle is a
            straw; revocation pulls what it drinks from."

        Cap-gated by the ``steal`` right (taking a device AWAY
        from another holder is stricter authority than
        :py:meth:`claim`). Raises ``NotPermittedError`` when the
        cap lacks ``steal``, ``UnsupportedError`` when the
        routed backend has no kernel-side claim to tear down
        (fake; PCI without VFIO), ``NotFoundError`` for an
        unrouted URN.

        Idempotent: revoke on a URN with no held claim succeeds
        silently (the post-condition "nobody holds this URN" is
        already true).
        """
        self._t.call(
            op="revoke",
            urn=urn,
            cap_id=self._cap,
            deadline_ms=deadline_ms,
        )

    # ---- Capability delegation -------------------------------------------

    def attenuate(
        self,
        *,
        scope: str | None = None,
        rights: RightNameList | None = None,
        mode_ceiling: ClaimMode | str | None = None,
    ) -> Client:
        """Mint a strictly-weaker sub-capability from this connection's cap
        and return a child ``Client`` that presents it on every op
        (R-CAP-ATTEN-WIRE / Part 4.4).

        The child SHARES this client's connection (same socket, same peer),
        so the sub-cap's connection binding matches and it can be used
        immediately. The child does NOT own the socket — closing it leaves
        this client's connection intact; the socket is torn down once, by
        this (owning) client.

        Each narrowing is optional; an omitted axis is inherited from the
        parent unchanged. Every present axis can only SHRINK authority — the
        broker refuses any widen, so ``attenuate`` can never escalate:

          * ``scope`` — a glob that must be ⊆ the parent's scope, e.g.
            ``"hw:/usb/**"``. The child's enumeration (``list`` /
            ``dump_all`` / ``inspect*`` / ``dump_chain`` / ``list_leases``)
            is then filtered to that scope (Tier-1 #11 S3 isolation): the
            child cannot even observe the existence of resources outside
            its grant.
          * ``rights`` — a list of right names (e.g. ``["observe"]``) that
            must be ⊆ the parent's rights. The empty list drops every
            right.
          * ``mode_ceiling`` — the strongest claim mode the child may
            request (``"observe"`` / ``"shared_rw"`` / ``"exclusive"``),
            which must be ≤ the parent's ceiling.

        Requires the parent cap to carry the ``grant`` right (the
        connection's root cap does). Raises ``NotPermittedError`` if the
        parent lacks ``grant``, the peer binding doesn't match, or a
        narrowing would widen; ``OverloadedError`` if the cap table is at
        capacity (back off + retry).

        Example::

            with Client.connect(path) as root:
                usb = root.attenuate(scope="hw:/usb/**", rights=["observe"])
                for urn in usb.list("hw:/**"):   # only hw:/usb/* URNs
                    ...
        """
        req: dict[str, Any] = {"op": "attenuate", "cap_id": self._cap}
        if scope is not None:
            req["scope"] = scope
        if rights is not None:
            req["rights"] = list(rights)
        if mode_ceiling is not None:
            req["mode_ceiling"] = (
                mode_ceiling.value if isinstance(mode_ceiling, ClaimMode) else mode_ceiling
            )
        resp = self._t.call(**req)
        sub_cap = int(resp["cap_id"])
        return Client(
            self._t,
            sub_cap,
            self._id,
            socket_path=self._socket_path,
            owns_transport=False,
        )

    # ---- Recovery (Tier-1 #12) -------------------------------------------

    def recover(self, urn: str, *, deadline_ms: int = 0) -> str:
        """Run the broker's reset-escalation ladder on a quarantined URN to
        recover it back to the ``Free`` pool (Tier-1 #12, survivability
        §2.5): ``FLR → (verify) → bus reset → (verify) → quarantine``, each
        rung deadline-bounded and verified by the backend.

        Returns the outcome:

          * ``"not_quarantined"`` — the URN was healthy; recovery is a
            no-op (a device in service is never force-reset).
          * ``"recovered_flr"`` — a function-level reset succeeded + verified;
            the resource is claimable again.
          * ``"recovered_bus"`` — FLR failed, the bus-level rung succeeded +
            verified; claimable again.
          * ``"still_quarantined"`` — both rungs failed; the device needs a
            power-cycle or operator intervention and stays quarantined.

        Gated by the ``steal`` right (a forceful hardware repair, the same
        operator-class authority as :py:meth:`revoke`); raises
        ``NotPermittedError`` when the cap lacks it. The ladder always
        terminates — the broker is never blocked by a frozen device,
        because each rung is abandonable at its deadline.
        """
        resp = self._t.call(
            op="recover",
            urn=urn,
            cap_id=self._cap,
            deadline_ms=deadline_ms,
        )
        return str(resp.get("outcome", ""))

    def reconcile_after_restart(
        self, *, scope: str = "hw:/**", deadline_ms: int = 0
    ) -> dict[str, Any]:
        """Trigger the broker's restart-recovery reconcile (Tier-1 #8,
        R-T1-8-RESTART-WIRING). The broker re-derives truth from the two
        authoritative sources — what each worker's backend enumerates as
        present in hardware, and the live-client lease table — and quarantines
        every resource that is present-but-unclaimed
        (``unclaimed_after_restart``): never assumed free (it may be dirty),
        never assumed owned (the owner is gone). Recover them with
        :py:meth:`recover`.

        ``scope`` (a glob, default the whole ``hw:/**`` namespace) bounds which
        workers are reconciled. Returns ``{ok, quarantined, scanned,
        truncated}``.

        **Trigger only on an actual restart.** The broker has no
        self-restart-detection persistence, so this is operator/supervisor-
        driven; run against a *healthy* broker it would quarantine every idle
        device. Gated by the ``steal`` right over ``scope`` (the same
        operator-class authority as :py:meth:`recover`/:py:meth:`revoke`) —
        raises ``NotPermittedError`` when the cap lacks authority over the
        requested scope.
        """
        return self._t.call(
            op="reconcile",
            scope=scope,
            cap_id=self._cap,
            deadline_ms=deadline_ms,
        )

    def irq_scan(self, *, deadline_ms: int = 0) -> dict[str, Any]:
        """Trigger the broker's interrupt-rate containment scan (Tier-2
        defense-in-depth, survivability D7). The broker samples every granted
        device's interrupt counter and, for any device whose rate exceeds the
        ceiling, revokes the grant (below the fd) and quarantines it — a runaway
        interrupt flood is a use-path DoS the broker contains by withdrawing the
        grant it issued. Recover a contained device with :py:meth:`recover`.

        Returns ``{ok, scanned, contained}``. Steal-gated on ``hw:/**`` (the same
        operator-class authority as :py:meth:`reconcile_after_restart`) — raises
        ``NotPermittedError`` for a narrower cap. Operator/supervisor-triggered
        (the broker has no background poller); per-device counter attribution is
        substrate-specific, so ``contained`` stays 0 until the real
        ``/proc/interrupts`` reader is wired (the mechanism + policy are live).
        """
        return self._t.call(op="irq_scan", cap_id=self._cap, deadline_ms=deadline_ms)

    # ---- RawAccessor verb ------------------------------------------------

    def raw(self, urn: str, lease: int, *, deadline_ms: int = 0) -> dict[str, Any]:
        """Obtain the raw direct channel (G4 — broker absent from the use
        path). On a future wire format this returns an fd via SCM_RIGHTS;
        the skeleton's line-JSON protocol returns a placeholder dict so the
        round-trip shape is testable."""
        return self._t.call(
            op="raw",
            urn=urn,
            lease_id=lease,
            cap_id=self._cap,
            caller=self._id,
            deadline_ms=deadline_ms,
        )

    # ---- Shared-memory data plane (Part 5.3) -----------------------------

    def open_shm_channel(self) -> ShmChannel:
        """Open a shared-memory data-plane channel. The broker allocates a
        private region, hosts a lock-free SPSC ring in it, and passes the
        region's fd via ``SCM_RIGHTS``. Returns a :class:`ShmChannel` the
        client pushes into; the broker drains it (see
        :meth:`drain_shm_channel`). The channel is reclaimed broker-side
        when this connection closes.

        Raises ``OverloadedError`` if the region pool is at its S5 ceiling.
        """
        resp = self._t.call(op="open_shm_channel", cap_id=self._cap)
        fd = resp.get("fd")
        if not isinstance(fd, int) or fd < 0:
            raise MgfHrbError("open_shm_channel: broker did not pass a region fd")
        return ShmChannel(fd, resp)

    def drain_shm_channel(self, channel: int) -> dict[str, Any]:
        """Ask the broker to drain everything currently in the channel's
        ring (consumer side, via the asymmetric-trust pop). Returns the
        broker's accounting: ``drained`` (count), ``checksum``,
        ``last_seq``, ``dropped``.

        In push-mode (after :meth:`start_shm_drain`) this returns the
        background drain thread's *accumulated* stats instead of pulling —
        the same keys, so a caller can poll progress without caring which
        mode the channel is in."""
        return self._t.call(op="drain_shm_channel", cap_id=self._cap, channel=channel)

    def start_shm_drain(self, channel: int) -> dict[str, Any]:
        """Switch a channel to push-mode (Part 5.3 slice 4): the broker
        spawns a background thread that drains the ring continuously, so a
        streaming producer needn't issue a :meth:`drain_shm_channel` RPC per
        batch. After this, :meth:`drain_shm_channel` reports the thread's
        accumulated stats. The thread is torn down automatically when the
        channel (or the connection) closes. Returns ``{ok, draining,
        channel}``."""
        return self._t.call(op="start_shm_drain", cap_id=self._cap, channel=channel)

    # ---- Causal tracing (Part 13 §5.6.3) ---------------------------------

    def correlation_scope(self, correlation_id: str) -> _CorrelationScope:
        """Within the `with` block, every wire op this client issues
        carries `correlation_id` (Part 13 §5.6.3). The broker echoes
        the id verbatim in every response — both success and error —
        so a consumer can thread a logical operation through multiple
        ops (e.g. claim → raw → release) and assemble the trace.

        Example::

            with client.correlation_scope("vmgr-claim-001") as _:
                with client.claim(urn, mode) as lease:
                    fd = lease.raw()
                # ... 'release' response also carries correlation_id

        Allowed charset (enforced broker-side): ASCII alphanumeric +
        ``-`` / ``_`` / ``.``; max 128 bytes. Out-of-charset or
        oversized values raise ``BadRequestError`` from the first
        op the scope reaches.

        Nesting works — inner scopes shadow outer; restored on exit.
        Calls that explicitly pass ``correlation_id=...`` (when the
        SDK eventually grows the kwarg per op) win over the scope.
        """
        return _CorrelationScope(self._t, correlation_id)

    # ---- Lifecycle -------------------------------------------------------

    def close(self) -> None:
        # Tear down monitor_fd first — its background thread holds a
        # second connection that needs explicit cleanup. Then close
        # the main transport.
        self._close_monitor_fd()
        # A child view (from `attenuate()`) shares the parent's transport
        # and must not close it — only the owning Client tears down the
        # socket.
        if self._owns_transport:
            self._t.close()

    def __enter__(self) -> Self:
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> None:
        self.close()

    # ---- Deferred surfaces -----------------------------------------------
    #
    # `monitor_fd` (return an fd that becomes readable on event arrival,
    # for Qt/Gtk/select-loop integration) is still a Tier-1 hardening
    # item — it needs the broker's accept loop to expose a per-client
    # fd that signals event-readiness rather than wakes on the polled
    # `drain_events` call. The polling `monitor()` above is the working
    # Tier-0 substitute.

    def monitor_fd(
        self,
        scope: str = "hw:/**",
        *,
        poll_interval_s: float = 0.5,
    ) -> int:
        """Return a pipe fd that becomes readable when watch events
        arrive. The third layer of the SDK-01 event API (paired with
        :py:meth:`monitor` and :py:meth:`amonitor`).

        Designed for Qt/Gtk/asyncio integrations that take an fd:

        .. code-block:: python

            fd = client.monitor_fd("hw:/usb/**")
            notifier = QSocketNotifier(fd, QSocketNotifier.Type.Read)
            notifier.activated.connect(self._on_event_fd_readable)

            def _on_event_fd_readable(self) -> None:
                # Each readable chunk is one or more newline-terminated
                # JSON events matching the dicts `monitor()` yields.
                data = os.read(fd, 4096)
                for line in data.splitlines():
                    event = json.loads(line)
                    self._dispatch(event)

        **Implementation.** A daemon thread holds its own dedicated
        :class:`Client` connection (Part 7.1 multi-client broker —
        the broker now spawns a thread per accepted client, so a
        second connection from the same process is granted alongside
        the main one without blocking). The thread polls
        ``drain_events`` on ``poll_interval_s`` and writes each event
        as a newline-terminated JSON line to a self-pipe; the returned
        int is the read end. The thread, its connection, and both
        pipe ends are torn down by :py:meth:`close` — a ``with``
        block cleans up automatically.

        Only one monitor_fd may be active per Client at a time —
        calling twice without an intervening :py:meth:`close` raises
        ``RuntimeError``. A typical GUI wants exactly one event
        stream; the limit keeps resource accounting predictable.

        Requires :py:meth:`connect` to have stored the
        ``socket_path``; raises ``ValueError`` otherwise.
        """
        if self._socket_path is None:
            raise ValueError(
                "monitor_fd() needs a socket_path — construct the Client via "
                "Client.connect(path) so the monitor thread can open its own "
                "connection."
            )
        if self._monitor_fd_resources is not None:
            raise RuntimeError(
                "monitor_fd() is already active on this Client; close the "
                "Client (or call _close_monitor_fd()) before starting another"
            )
        import os

        # Open a second connection — the multi-client broker (Part 7.1)
        # grants it alongside the main connection. SO_PEERCRED's bind
        # is idempotent for the same process, so this succeeds without
        # operator interaction.
        monitor_client = Client.connect(self._socket_path, client_id=self._id)
        read_fd, write_fd = os.pipe()
        stop_event = threading.Event()

        def _pump() -> None:
            """The polling loop running in the daemon thread.

            Uses a dedicated connection so the main Client's wire
            stays uncontended (the SpinMutex in `_Transport.call`
            would serialize otherwise; still correct, but every
            main-thread call would briefly wait on the polling
            roundtrip).
            """
            try:
                while not stop_event.is_set():
                    try:
                        events = monitor_client.drain_events(scope)
                    except (MgfHrbError, OSError):
                        # Broker died or wire broken → close write side
                        # so the reader sees EOF + exit cleanly.
                        break
                    if events:
                        for ev in events:
                            payload = (json.dumps(ev) + "\n").encode()
                            try:
                                os.write(write_fd, payload)
                            except OSError:
                                # Reader closed the pipe → we exit.
                                return
                    else:
                        # Interruptible wait so close() doesn't block
                        # on a full poll interval.
                        if stop_event.wait(poll_interval_s):
                            return
            finally:
                try:
                    os.close(write_fd)
                except OSError:
                    pass
                monitor_client.close()

        thread = threading.Thread(
            target=_pump,
            name=f"mgf-hrb-monitor-fd[{scope}]",
            daemon=True,
        )
        thread.start()
        self._monitor_fd_resources = _MonitorFdResources(
            thread=thread,
            stop_event=stop_event,
            read_fd=read_fd,
            write_fd=write_fd,
        )
        return read_fd

    def _close_monitor_fd(self) -> None:
        """Tear down the monitor_fd resources, if active. Idempotent.
        Called from :py:meth:`close`; consumers usually don't call
        directly."""
        if self._monitor_fd_resources is None:
            return
        import os

        r = self._monitor_fd_resources
        self._monitor_fd_resources = None
        # Signal the thread to stop, then close the read end so any
        # caller blocked on `os.read(fd)` returns 0 (EOF).
        r.stop_event.set()
        try:
            os.close(r.read_fd)
        except OSError:
            pass
        # The thread closes write_fd + monitor_client itself before
        # exiting. Bounded join: never block close() forever on a
        # misbehaving thread.
        r.thread.join(timeout=2.0)

    async def amonitor(
        self,
        scope: str = "hw:/**",
        *,
        poll_interval_s: float = 0.5,
    ) -> AsyncIterator[dict[str, Any]]:
        """Async iterator over watch events — the second layer of the
        SDK-01 event API (paired with :py:meth:`monitor` and
        :py:meth:`monitor_fd`). For asyncio / qasync consumers.

        .. code-block:: python

            async with Client.connect(path) as c:
                async for event in c.amonitor("hw:/usb/**"):
                    handle(event)

        **Implementation.** Wraps :py:meth:`drain_events` via
        :py:func:`asyncio.to_thread` so the blocking socket I/O
        doesn't stall the event loop. Between empty drains we
        ``asyncio.sleep(poll_interval_s)`` — same wait strategy as
        the sync :py:meth:`monitor`. The Transport's lock keeps
        concurrent SDK calls from other coroutines safe.
        """
        import asyncio

        while True:
            events = await asyncio.to_thread(self.drain_events, scope)
            for ev in events:
                yield ev
            if not events:
                await asyncio.sleep(poll_interval_s)
