"""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** wire-speaking call takes an
explicit keyword-only ``deadline_ms`` argument (the SDK forwards it to
the broker so the survivability contract holds end-to-end — D-029) and
bounds its own wait for the transport lock and the socket.

**Deadline resolution (SDK-10).** ``deadline_ms=None`` (the default on
every verb) means "use this Client's ``default_deadline_ms``"; an
explicit int wins over the client default; ``0`` means *explicitly
unbounded*. ``Client.connect(..., default_deadline_ms=0)`` keeps the
SDK's historical unbounded behaviour, so a consumer that never touches
either parameter sees byte-identical 0.1.9 behaviour.

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) consume a REAL broker-side
subscription as of 0.1.11 (M-B): each opens its own connection,
registers a server-side filter on it, and long-polls that subscription.
This paragraph said "require a broker-side event stream that doesn't
exist yet" from 0.1.0 through 0.1.10; the stream exists now.
"""

from __future__ import annotations

import json
import logging
import socket
import struct
import threading
from contextlib import suppress
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, OverloadedError, UnavailableError, 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"]

# ---------------------------------------------------------------------------
# M-B B3 — the long-poll monitor's constants.
# ---------------------------------------------------------------------------

# The broker's ceiling on a single `drain_events` long poll
# (`subscription.MAX_WAIT_MS`). Asking for more is REFUSED at the
# broker's trust gate rather than clamped, so the SDK refuses it here
# too — a client that believes it armed a ten-minute wait and got 30 s
# would mis-time its own retry loop, and finding that out on the wire is
# strictly later than finding it out at the call.
_MAX_WAIT_MS = 30_000

# The monitor family's default long poll. Five seconds is short enough
# that a broker restart is noticed promptly and long enough that a quiet
# bench costs 12 round trips a minute instead of 120 (0.5 s polling).
_DEFAULT_WAIT_MS = 5_000

# B3-AM1 — the grace between a long poll's `wait_ms` and the client-side
# deadline that bounds the same call.
#
# The broker IGNORES `deadline_ms` on `drain_events` (`DrainEventsReq`
# has no such field — `request.zig`), so this invariant is enforceable
# ONLY client-side. It has to be: a socket-phase timeout POISONS the
# transport (F-A1-01), so a deadline that lands mid-wait converts a
# healthy long poll into a dead connection — every later call on it
# refused with `UnavailableError`. The failure is not "one drain came
# back empty"; it is "this monitor is over".
#
# Two seconds covers the round trip plus a contended scheduler, and it
# is the same floor M-A A1 chose for the polling pump.
_LONG_POLL_GRACE_MS = 2_000

# The subscription id the monitor family registers. Fixed rather than
# random because each monitor owns its OWN connection and subscription
# ids are per-connection, so it cannot collide — and a fixed id is what
# an operator reads in `state_snapshot` output when they ask which
# subscriptions exist.
_MONITOR_SUB_ID = "mgf-hrb-sdk-monitor"

# The reconnect gap's `source` (D-024 crash-only). Distinct from A1's
# `monitor_pump_error`: THIS one says "the stream survived, but the ring
# it was reading died — rescan"; that one says "the stream is over".
_RECONNECT_GAP_SOURCE = "monitor_reconnect"

# How long the ONE reconnect may keep trying, and how it paces itself.
# Two seconds covers a broker restart with margin (SIGTERM → listening
# again measured at ~200-500 ms on this bench) and is short enough that
# a broker which is simply GONE becomes a terminal gap promptly rather
# than leaving the consumer in silence. See `_EventStream.reconnect` for
# why a single immediate attempt is not enough.
_RECONNECT_BUDGET_MS = 2_000
_RECONNECT_BACKOFF_START_MS = 50
_RECONNECT_BACKOFF_CAP_MS = 400

# The deadline on the stream's OWN setup call (the `subscribe`), which
# has nothing to do with how long a poll may wait. Deliberately NOT
# `wait_ms + grace`: `monitor_fd` opens its stream on the CALLER's
# thread so a broker that refuses is an exception the caller can act on
# — and a caller that asked for a 30 s poll must not therefore wait 32 s
# to find out the broker is wedged. Matches `connect`'s own
# `hello_deadline_ms` default, so the whole setup path is bounded by the
# same number twice.
_OPEN_DEADLINE_MS = 2_000

# Back-off for a drain the broker SHED (`Overloaded`). Not a reconnect
# and not a gap: the stream is healthy, the broker's per-uid rate
# limiter is simply full (`admission.rlAllow` — 2048-token burst,
# 1000/s refill, shared by every connection this uid holds). Refill is
# 1 token/ms, so 50 ms buys ~50 ops of headroom; the cap keeps a
# permanently-busy broker from turning the pump into a spin.
_OVERLOAD_BACKOFF_START_MS = 50
_OVERLOAD_BACKOFF_CAP_MS = 250


def _check_wait_ms(wait_ms: int) -> None:
    """Refuse a `wait_ms` the broker would refuse, before the wire."""
    if wait_ms < 0:
        raise ValueError(f"wait_ms must be >= 0 (0 = answer immediately); got {wait_ms}")
    if wait_ms > _MAX_WAIT_MS:
        raise ValueError(
            f"wait_ms={wait_ms} exceeds the broker's ceiling of {_MAX_WAIT_MS}ms "
            "(subscription.MAX_WAIT_MS). The broker refuses an over-cap wait "
            "rather than silently clamping it, so a caller who believes they "
            "armed a longer wait would mis-time their own retry loop."
        )


def _check_long_poll_deadline(wait_ms: int, effective_deadline_ms: int, *, where: str) -> None:
    """B3-AM1 — the `deadline_ms > wait_ms` invariant, enforced.

    A positive deadline that is not comfortably longer than the wait is
    refused HERE, at the API boundary, before a byte is sent. The reason
    is not tidiness: the client-side deadline bounds a socket read, and a
    socket read that gives up while the broker is still legitimately
    waiting poisons the transport (F-A1-01) — the reply is real and
    arrives later, and the SDK cannot tell it apart from the next call's.
    So a mis-sized deadline does not produce a late answer; it produces a
    dead connection.

    `deadline_ms=0` (explicitly unbounded) is NOT refused here — an
    unbounded call cannot time out, so it cannot poison. The monitor
    family refuses it separately, for a different reason (an unbounded
    pump goes silent forever on a wedged broker)."""
    if wait_ms <= 0 or effective_deadline_ms <= 0:
        return
    floor = wait_ms + _LONG_POLL_GRACE_MS
    if effective_deadline_ms < floor:
        raise ValueError(
            f"{where}: deadline_ms={effective_deadline_ms} is shorter than "
            f"wait_ms={wait_ms} + {_LONG_POLL_GRACE_MS}ms grace (>= {floor} "
            "required, B3-AM1). A long poll answers AT wait_ms; a deadline inside that "
            "window fires mid-wait, and a socket-phase timeout poisons this "
            "connection permanently (every later call raises UnavailableError). "
            "Raise deadline_ms, or lower wait_ms."
        )


def _deprecated_poll_interval(poll_interval_s: float | None, *, surface: str) -> None:
    """AP-04 — `poll_interval_s` is accepted and does nothing (0.1.11).

    The parameter named a wait STRATEGY the SDK no longer uses: the
    monitor family long-polls the broker (`wait_ms`) instead of sleeping
    between non-blocking drains. Kept for one minor so no consumer breaks
    on the upgrade; removed in 0.2.0."""
    if poll_interval_s is None:
        return
    import warnings

    warnings.warn(
        f"{surface}(poll_interval_s=...) is deprecated and has NO EFFECT since "
        "0.1.11: the monitor family long-polls the broker instead of sleeping "
        "between drains, so there is no interval to set. Pass wait_ms= to "
        "choose how long each poll may block (default "
        f"{_DEFAULT_WAIT_MS}ms, broker ceiling {_MAX_WAIT_MS}ms). "
        "Accepted for one more minor; removed in 0.2.0.",
        DeprecationWarning,
        # F-B3-06 — 4, not 3. The chain is
        #   caller → monitor()/amonitor()/monitor_fd()
        #          → _open_event_stream() → here,
        # so `stacklevel=1` is this function and 4 is the CONSUMER's own
        # line. At 3 the warning pointed inside `_client.py`, which is
        # the one place a consumer cannot act on and the one file where
        # `-W error::DeprecationWarning` output is useless: it named the
        # SDK as the caller of its own deprecated parameter.
        stacklevel=4,
    )


def _write_line(fd: int, payload: bytes) -> bool:
    """Write one complete newline-terminated line to ``fd``.

    Returns ``True`` when the whole payload reached the pipe, ``False``
    when the reader is gone (EPIPE) or the fd is unusable — the pump's
    signal to stop.

    The loop over short writes is what makes ``monitor_fd``'s contract
    structural rather than incidental: a consumer parses the pipe line
    by line, so a half-written event followed by the terminal
    ``monitor_pump_error`` gap would produce one corrupt JSON record and
    hide the failure. ``os.pipe()`` fds are blocking, so POSIX already
    promises a full write — the loop makes the promise ours.
    """
    import os as _os

    view = memoryview(payload)
    while view:
        try:
            written = _os.write(fd, view)
        except OSError:
            return False
        if written <= 0:
            return False
        view = view[written:]
    return True


class _EventStream:
    """ONE event path, shared by `monitor` / `amonitor` / `monitor_fd`
    (M-B B3 — the chunk's first item, and the reason SDK-08 closes).

    **Its own connection, its own subscription.** Subscriptions are keyed
    to the CONNECTION broker-side (`server.zig` — the peer key), which is
    what made SDK-08 unfixable in place: `monitor_fd`'s pump ran on a
    second connection and could therefore never read a subscription
    registered on the main one. The dissolution is structural rather than
    clever — the stream subscribes on the connection it drains, so the
    two are aligned by construction and there is no way to hold it wrong.

    Giving `monitor()` and `amonitor()` their own connection too is not
    symmetry for its own sake. `_Transport.call` holds the transport lock
    for the WHOLE round trip, and a long poll's round trip is `wait_ms`
    long — so a monitor sharing the caller's `Client` would hold that
    lock for five seconds at a time and every other call on the Client
    would queue behind it (M-A A1 closed exactly this amplifier; a
    5 s hold would re-open it, and any concurrent call carrying a
    deadline under 5 s would fail on the lock rather than on the broker).
    Measured: 1000 ms-deadline `list()` during a shared-transport long
    poll → `TimeoutError` naming the lock. Cost of the fix: one extra
    connection per monitor, which is what `monitor_fd` has always spent.

    **Crash-only reconnect (D-024).** A drain failure is not retried in
    place; the ring it was reading is gone, so the stream reconnects,
    re-subscribes, and tells the consumer to rescan by way of a `gap`.
    No de-duplication is attempted and none is claimed — the gap IS the
    claim. One reconnect per period of progress: a second failure with no
    successful drain in between is terminal, which is what stops a broker
    that accepts and immediately drops from becoming a tight reconnect
    loop that emits gaps forever.
    """

    __slots__ = (
        "_client",
        "_client_id",
        "_deadline_ms",
        "_made_progress",
        "_overload_backoff_s",
        "_predicate",
        "_scope",
        "_socket_path",
        "_sub_id",
        "_wait_ms",
    )

    def __init__(
        self,
        *,
        socket_path: str,
        client_id: int,
        scope: str,
        predicate: dict[str, Any] | None,
        wait_ms: int,
        deadline_ms: int,
        sub_id: str = _MONITOR_SUB_ID,
    ) -> None:
        self._socket_path = socket_path
        self._client_id = client_id
        self._scope = scope
        self._predicate = predicate
        self._wait_ms = wait_ms
        self._deadline_ms = deadline_ms
        self._sub_id = sub_id
        self._client: Client | None = None
        self._overload_backoff_s = _OVERLOAD_BACKOFF_START_MS / 1000.0
        # The subscribe that opens the stream is itself progress: it
        # proves the broker answered. Without it, a stream whose FIRST
        # drain fails would refuse to reconnect at all.
        self._made_progress = True

    # -- lifecycle ---------------------------------------------------------

    def open(self) -> Client:
        """Connect + subscribe, or return the live connection. Raises
        whatever `connect`/`subscribe` raise — the caller decides whether
        that surfaces at the call site (`monitor_fd`, which opens eagerly
        so a broken broker is a raise and not an fd that only ever
        delivers a gap) or at first iteration (`monitor`/`amonitor`,
        whose contract has always been that the iterator raises)."""
        live = self._client
        if live is not None:
            return live
        client = Client.connect(
            self._socket_path,
            client_id=self._client_id,
            default_deadline_ms=self._deadline_ms,
        )
        try:
            client.subscribe(
                self._sub_id,
                self._scope,
                predicate=self._predicate,
                deadline_ms=_OPEN_DEADLINE_MS,
            )
        except BaseException:
            # DP-13 — a subscribe that fails must not strand the
            # connection it was going to use.
            client.close()
            raise
        self._client = client
        return client

    def drain(self) -> EventList:
        """One long poll. Returns the events (possibly empty — an empty
        answer at `wait_ms` is a SUCCESS, not a timeout). Raises
        `MgfHrbError` / `OSError` on a broken stream.

        **`Overloaded` is not a broken stream** — it is the broker's
        per-uid rate limiter saying "back off", and the SDK's own
        `OverloadedError` docs say to do exactly that. Treating it as a
        dead stream (the first shape of this code) was actively harmful
        and the stress suite caught it: the reconnect burns a connection
        and a subscribe — two MORE ops into the bucket that just shed
        one — emits a `gap` that did not happen, and kills the ring the
        stream was reading, converting transient back-pressure into real
        event loss. Measured on the 10-monitor storm: every one of the
        ten pumps reconnected, each announcing a gap.

        So: back off on the SAME connection, no gap, no reconnect. The
        backoff resets on the first successful drain.

        **The backoff has no TOTAL budget, deliberately** (accepted
        bound, B3 court). A shed drain means the broker is alive and
        answering — there is nothing to fail over TO, and no other party
        to hand the problem to, so "give up after N seconds" would turn a
        recoverable condition into a dead monitor for no gain. It is
        bounded in the two dimensions that can actually run away: RATE
        (the backoff doubles to a 250 ms cap, so a permanently-throttled
        broker is asked four times a second, not in a spin) and VISIBILITY
        (one WARNING per shed, naming the wait, so an operator watching
        logs sees back-pressure rather than silence). What a consumer
        never gets from this path is a false event stream: no gap is
        emitted, so nothing claims a discontinuity that did not
        happen."""
        import time

        client = self.open()
        while True:
            try:
                events = client.drain_events(
                    subscription_id=self._sub_id,
                    wait_ms=self._wait_ms,
                    deadline_ms=self._deadline_ms,
                )
            except OverloadedError as exc:
                # No total budget, by design — see the docstring's last
                # paragraph for why, and for the two bounds that replace
                # it (rate, and visibility).
                logger.warning(
                    "monitor drain shed by broker back-pressure (%s); backing off %.0fms",
                    exc,
                    self._overload_backoff_s * 1000,
                    extra={"scope": self._scope, "sub_id": self._sub_id},
                )
                time.sleep(self._overload_backoff_s)
                self._overload_backoff_s = min(
                    self._overload_backoff_s * 2, _OVERLOAD_BACKOFF_CAP_MS / 1000.0
                )
                continue
            self._made_progress = True
            self._overload_backoff_s = _OVERLOAD_BACKOFF_START_MS / 1000.0
            return events

    @property
    def can_reconnect(self) -> bool:
        """Whether a failure here is worth ONE recovery, or is the end.

        False once a reconnected stream has failed again without a
        single successful drain in between. That is the rule that keeps
        a broker which accepts and immediately drops from turning this
        into a spin — reconnecting, gapping, dying, reconnecting, for as
        long as the consumer keeps reading.

        Checked by the CALLER, before it announces a reconnect: a
        `monitor_reconnect` gap must mean a reconnect is actually being
        attempted, or the word is a lie. When this is False the caller
        re-raises the broker's own error, which is more use to the
        consumer than a synthetic one about retry policy."""
        return self._made_progress

    def reconnect(self) -> None:
        """Tear the connection down and build a NEW one, re-subscribing.

        ONE recovery, with a finite window — not one syscall.

        A single immediate `connect()` was the first shape of this
        method and it is worthless against the case it exists for:
        measured against the real broker, a kill + restart under an
        active monitor fails the retry 100% of the time, because the
        drain notices the EOF within a millisecond of the broker's death
        and the replacement is not listening yet (~200-500 ms later on
        this bench). A recovery attempt that cannot survive a restart is
        not a recovery attempt.

        So the window is `_RECONNECT_BUDGET_MS`, with backoff, and it is
        still ONE recovery: when the budget lapses the stream is
        terminal, and the anti-spin rule below means a stream that comes
        back and immediately dies again does not get a second one.

        Raises the last failure when the window lapses — the caller's
        terminal path takes over. Callers gate on
        :py:attr:`can_reconnect` first."""
        self._made_progress = False
        self.close()

        import time

        deadline = time.monotonic() + _RECONNECT_BUDGET_MS / 1000.0
        backoff_s = _RECONNECT_BACKOFF_START_MS / 1000.0
        while True:
            try:
                self.open()
            except (MgfHrbError, OSError):
                # EH-07 — narrow, and re-raised below when the budget is
                # gone. A failed attempt leaves nothing open: `open()`
                # closes the connection it could not subscribe on.
                if time.monotonic() + backoff_s >= deadline:
                    raise
                time.sleep(backoff_s)
                backoff_s = min(backoff_s * 2, _RECONNECT_BACKOFF_CAP_MS / 1000.0)
                continue
            return

    @staticmethod
    def _shutdown(client: Client | None) -> None:
        """Shut a stream connection's socket down, best-effort.

        Takes the client as an ARGUMENT rather than reading
        `self._client`, and that is the whole point (F-B3-01). The first
        shape of this code had `close()` null the attribute and then call
        `self.interrupt()`, whose own `if client is None: return` guard
        fired every time — so the interrupt was dead code on the
        `close()` path, and only `_close_monitor_fd`'s DIRECT
        `stream.interrupt()` call kept the fd shape fast. `monitor` and
        `amonitor` had no such caller and waited out the poll: the court
        measured `amonitor` teardown at 0.60 / 4.60 / 14.60 s for
        `wait_ms` of 1000 / 5000 / 15000. Passing the client explicitly
        makes the guard unable to eat the call."""
        if client is None:
            return
        with suppress(OSError):
            client._t._sock.shutdown(socket.SHUT_RDWR)

    def interrupt(self) -> None:
        """Unblock a drain that is parked in its long poll, from another
        thread. Best-effort: shutting the socket down makes the blocked
        `recv` return at once, which is what lets `Client.close()` reclaim
        a pump thread in milliseconds instead of waiting out `wait_ms`."""
        self._shutdown(self._client)

    def close(self) -> None:
        """Close the stream's connection. Idempotent. Interrupts first so
        a drain blocked in its long poll on another thread returns instead
        of reading from a socket this thread is closing underneath it.

        The interrupt matters most where there is no other caller to do
        it: `amonitor`'s drain runs on an `asyncio.to_thread` worker, and
        a cancelled task's `finally` reaches here while that worker is
        still parked in the broker's wait. Without the shutdown, the
        worker holds a default-executor thread until the poll lapses, and
        `asyncio.run` — which joins the default executor on the way out —
        blocks for the remainder of `wait_ms`."""
        client = self._client
        self._client = None
        if client is None:
            return
        self._shutdown(client)
        with suppress(OSError, MgfHrbError):
            client.close()

    # -- the gap this stream speaks ----------------------------------------

    def reconnect_gap(self, exc: BaseException) -> dict[str, Any]:
        """The line/event a consumer gets when the ring died.

        `gap` is already every consumer's "you missed things, rescan"
        signal, so a reconnect arrives on a path that already exists — no
        new vocabulary, no consumer change. `source` distinguishes it
        from the netlink and queue-overflow gaps the broker itself
        emits."""
        return {
            "kind": "gap",
            "source": _RECONNECT_GAP_SOURCE,
            "error": f"{type(exc).__name__}: {exc}",
        }


def _monitor_iter(stream: _EventStream) -> Iterator[dict[str, Any]]:
    """The sync half of the one event path — `Client.monitor`'s body.

    A module-level generator rather than a method body so `monitor()`
    itself is an ordinary function: its argument refusals (B3-AM1's
    deadline rule, an over-cap `wait_ms`, a Client with no socket path)
    fire at the CALL, not at the first `next()` three frames away in the
    consumer's loop.

    The gap is yielded BEFORE the reconnect is attempted: the consumer's
    rescan is the expensive part and it can start while the SDK is still
    dialling."""
    try:
        while True:
            try:
                events = stream.drain()
            except (MgfHrbError, OSError) as exc:
                if not stream.can_reconnect:
                    raise
                yield stream.reconnect_gap(exc)
                stream.reconnect()
                continue
            yield from events
    finally:
        # DP-13 — the consumer's `break` (or a GC'd generator) releases
        # the stream's connection deterministically.
        stream.close()


async def _amonitor_iter(stream: _EventStream) -> AsyncIterator[dict[str, Any]]:
    """The async half of the one event path — `Client.amonitor`'s body.

    Same loop, same reconnect discipline; the blocking long poll runs on
    a worker thread so the event loop stays free. Note that the loop is
    now IDLE between events rather than waking every `poll_interval_s`
    to issue a drain that usually returns nothing."""
    import asyncio

    try:
        while True:
            try:
                events = await asyncio.to_thread(stream.drain)
            except (MgfHrbError, OSError) as exc:
                if not stream.can_reconnect:
                    raise
                yield stream.reconnect_gap(exc)
                await asyncio.to_thread(stream.reconnect)
                continue
            for ev in events:
                yield ev
    finally:
        stream.close()


@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 holds its OWN dedicated `Client` on a SECOND connection
    (see `monitor_fd`), not the main Client's transport. This docstring
    claimed the opposite until 0.1.10 — it described the era when the
    broker's serial accept loop could not grant a second connection
    alongside the main one; Part 7.1's thread-per-client broker ended
    that, and `monitor_fd` has opened its own connection since.

    The distinction is load-bearing for the F-A1-01 poison: a pump drain
    that blows its deadline poisons the PUMP's transport only. The main
    Client is a different connection and keeps working — the consumer
    learns about it through the terminal `gap` line on the pipe, not
    through its own calls starting to fail.

    `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
    # M-B B3 — the pump's `_EventStream`, held here so `close()` can
    # INTERRUPT it. Since 0.1.11 the pump's wait is a broker-side long
    # poll, not a `stop_event.wait(interval)`, so setting the stop event
    # no longer wakes the thread: it is parked in a `recv` for up to
    # `wait_ms`. Shutting its socket down is what makes teardown
    # immediate instead of "up to five seconds after you asked".
    stream: _EventStream


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
        # F-A1-01 — the deadline's own hazard, and the reason a bounded
        # client is not automatically a correct one.
        #
        # The protocol is strictly request → response over ONE stream. A
        # call that gives up on its read does not un-send its request: a
        # broker that was merely SLOW (not wedged) replies after we
        # stopped waiting, and that reply sits in the socket buffer. The
        # next call on this transport would read the PREVIOUS call's
        # reply and return it as its own — a silent wrong answer, which
        # is worse than the hang the deadline was added to prevent.
        #
        # No in-band resynchronisation is available: the wire carries no
        # request ids, so a stale reply is indistinguishable from a fresh
        # one. A socket-phase timeout therefore POISONS the transport
        # (D-024, crash-only): every later call fails typed and fast, and
        # the consumer's existing reconnect path takes over. `None` =
        # healthy; a string = the reason, surfaced to the operator.
        self._poisoned: str | None = None

    def call(self, **req: Any) -> dict[str, Any]:
        # SDK-10 / M-A A1 — CLOSE THE LOCK AMPLIFIER.
        #
        # Pre-A1 this method took `self._lock` with an unbounded
        # `with self._lock:`. Because the lock is held for the WHOLE
        # round trip, one wedged *unbounded* op stalled every other call
        # on the same Client — including calls that carefully passed a
        # `deadline_ms`. The deadline bounded the socket; nothing bounded
        # the wait for the transport. That is the amplifier the SDK-10
        # paper names ("one unbounded op therefore does not hang alone").
        #
        # When a positive effective deadline is in force we bound the
        # lock wait too, and the timeout message NAMES the lock so an
        # operator can tell "the broker was slow" apart from "another
        # call on this Client held the transport". With no deadline the
        # acquire is blocking, exactly as before.
        #
        # The deadline bounds each blocking PHASE (lock acquisition, then
        # the socket round trip inside `_call_locked`) rather than their
        # sum: worst case, a call that waits almost the whole deadline for
        # the lock and then times out on the socket takes ~2x
        # `deadline_ms`. Deducting the lock wait from the socket budget
        # would make a call's broker timeout depend on unrelated
        # contention — a spurious-timeout failure mode strictly worse than
        # a bounded overshoot.
        #
        # The lock is acquired on exactly one path and released in a
        # `finally`, so it is released exactly once on every exit
        # (return, typed error, socket error, KeyboardInterrupt).
        op = req.get("op", "?")
        deadline_ms = req.get("deadline_ms") or 0
        # F-A1-01 — fail a poisoned transport BEFORE queueing for the
        # lock. Purely a liveness courtesy: a doomed call should not wait
        # behind an in-flight one to learn it is doomed. The AUTHORITATIVE
        # check is the one inside the lock below — this one races (the
        # in-flight call may poison the transport after we read the flag
        # and before we take the lock), and a check that races cannot be
        # the one that protects correctness.
        if self._poisoned is not None:
            raise self._poisoned_error(op)
        if deadline_ms > 0:
            if not self._lock.acquire(timeout=deadline_ms / 1000.0):
                logger.debug(
                    "wire op=%s timed out waiting for the transport lock (%dms)",
                    op,
                    deadline_ms,
                    extra={"op": op, "urn": req.get("urn"), "deadline_ms": deadline_ms},
                )
                raise HrbTimeoutError(
                    f"could not acquire the mgf-hrb transport lock for op={op!r} "
                    f"within {deadline_ms}ms — another call on this Client held "
                    "the transport (the client-side deadline bounds the lock "
                    "wait as well as the broker round trip)"
                )
        else:
            self._lock.acquire()
        try:
            # F-A1-01 — the authoritative poison check. Inside the lock,
            # so it is ordered against the call that poisoned: whoever
            # held the lock has finished, and a timed-out predecessor has
            # already set the flag. Checking only outside the lock would
            # leave exactly the window the finding is about — B reads
            # "healthy", blocks on the lock, A times out and abandons its
            # reply, B acquires and reads A's reply as its own.
            if self._poisoned is not None:
                raise self._poisoned_error(op)
            # 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.
            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
        finally:
            self._lock.release()

    def _poisoned_error(self, op: str) -> UnavailableError:
        """The typed refusal a poisoned transport answers with.

        `UnavailableError` rather than a fresh exception type on purpose:
        it already means "this exists but cannot serve you right now, and
        an explicit recovery step is required", every consumer's
        `except MgfHrbError` already covers it, and consumers that
        distinguish it already treat it as reconnect-or-recover. A new
        type would be invisible to every consumer written before 0.1.10 —
        the opposite of what a correctness fix needs."""
        return UnavailableError(
            f"mgf-hrb transport desynchronised by a timed-out call "
            f"({self._poisoned}); this connection can no longer be trusted to "
            f"match replies to requests, so op={op!r} was refused rather than "
            "answered with the previous call's reply. Reconnect "
            "(Client.close() + Client.connect(...)) to continue."
        )

    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
        except HrbTimeoutError as exc:
            # F-A1-01 — POISON, and only here.
            #
            # This handler covers exactly the three CLIENT-side socket
            # timeouts: the send that did not complete (a half-written
            # request line desynchronises the broker's parser), and the
            # two recv paths that gave up on a reply that may still be in
            # flight. In every one of them the stream's request/response
            # pairing has become unknowable.
            #
            # It deliberately does NOT cover:
            #   * a BROKER-returned `Timeout` — that arrives as an
            #     `ok:false` response parsed BELOW this try block, which
            #     means the reply WAS read and the stream is in sync;
            #   * the LOCK timeout — raised in `call` before this method
            #     is ever entered, so the socket was never touched.
            # Both share this exception type, so only the raise site can
            # tell them apart. That is why the flag is set here.
            self._poisoned = (
                f"op={req.get('op')!r} exceeded its {deadline_ms}ms client-side "
                "deadline mid-exchange"
            )
            logger.warning(
                "transport poisoned: %s (%s). Every later call on this "
                "connection is refused; reconnect to continue.",
                self._poisoned,
                exc,
                extra={
                    "op": req.get("op"),
                    "urn": req.get("urn"),
                    "deadline_ms": deadline_ms,
                },
            )
            raise
        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 | None = None) -> None:
        """Release this lease. Total + idempotent (Part 3.2): releasing an
        already-released lease is a no-op.

        ``deadline_ms=None`` (default) resolves to the owning Client's
        ``default_deadline_ms``; ``0`` is explicitly unbounded."""
        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 | None = None) -> 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).

    **Deadlines (DP-09 / SDK-10).** Every wire-speaking method accepts a
    keyword-only ``deadline_ms``:

      * ``None`` (the default everywhere) — use this Client's
        :py:attr:`default_deadline_ms`.
      * a positive int — bound THIS call: the wait for the transport
        lock, the send, and the response recv (and the broker's own
        self-cancellation, since the value rides the wire).
      * ``0`` — explicitly unbounded (the back-compat escape).

    The client default is itself ``0``, so a consumer that passes
    neither parameter gets 0.1.9's behaviour unchanged. A GUI opts the
    whole client in with
    ``Client.connect(path, default_deadline_ms=10_000)``.
    """

    # Declared at CLASS level as well as assigned in `__init__`, so a
    # Client built WITHOUT `__init__` still resolves the deadline rule
    # instead of raising AttributeError deep inside a verb. That shape is
    # not hypothetical: `tests/contract/test_wire_drain_events.py` uses
    # `Client.__new__(Client)` with a stub transport to exercise pure
    # projection without a socket, and consumer test fakes copy the
    # pattern. The class default is the SDK default — unbounded — so such
    # a Client behaves exactly as it did on 0.1.9.
    _default_deadline_ms: int = 0

    def __init__(
        self,
        transport: _Transport,
        cap_id: int,
        client_id: int,
        *,
        socket_path: str | None = None,
        owns_transport: bool = True,
        default_deadline_ms: int = 0,
    ) -> None:
        self._t = transport
        self._cap = cap_id
        self._id = client_id
        # SDK-10 — the client-level deadline policy. Applies to any verb
        # whose per-call `deadline_ms` is None. `0` (the default) is
        # "unbounded", which is exactly 0.1.9's behaviour.
        self._default_deadline_ms = default_deadline_ms
        # 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

    @property
    def default_deadline_ms(self) -> int:
        """The deadline (ms) applied to any call whose own
        ``deadline_ms`` is ``None``. ``0`` means unbounded — the SDK
        default, which keeps 0.1.9 behaviour for consumers that never
        set it (SDK-10)."""
        return self._default_deadline_ms

    def _deadline(self, deadline_ms: int | None) -> int:
        """Resolve a per-call ``deadline_ms`` against the client default.

        The single place the SDK-10 rule lives: per-call value when the
        caller gave one (including an explicit ``0`` = unbounded), else
        the client-level default."""
        return self._default_deadline_ms if deadline_ms is None else deadline_ms

    def _req(self, op: str, deadline_ms: int | None, **fields: Any) -> dict[str, Any]:
        """Build a request dict, attaching ``deadline_ms`` only when the
        effective deadline is positive.

        Omitting a zero deadline is deliberate: ``0`` already means
        "unbounded" to both halves of the seam, so a verb that did not
        carry the key in 0.1.9 still does not carry it when nobody asked
        for a bound — the wire stays byte-identical for untouched
        consumers. The verbs that DID always send ``deadline_ms`` in
        0.1.9 (``claim``/``release``/``revoke``/``raw``/``recover``/
        ``reconcile``/``irq_scan``/``list_leases``) keep sending it
        unconditionally, for the same byte-identity reason."""
        req: dict[str, Any] = {"op": op}
        req.update(fields)
        effective = self._deadline(deadline_ms)
        if effective > 0:
            req["deadline_ms"] = effective
        return req

    @classmethod
    def connect(
        cls,
        path: str,
        *,
        client_id: int = 1,
        hello_deadline_ms: int = 2000,
        default_deadline_ms: int = 0,
    ) -> 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.

        **Client-level deadline (SDK-10).** ``default_deadline_ms``
        (default ``0`` = unbounded) is the deadline every verb uses when
        its own ``deadline_ms`` is ``None``. The default preserves
        0.1.9's behaviour exactly; a consumer that wants a bounded
        client — a GUI that must never freeze on a wedged broker — sets
        it once here instead of threading a number through every call
        site. It applies to the transport-lock wait too, so one
        deliberately-unbounded op can no longer stall the bounded ones.
        """
        # 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,
            default_deadline_ms=default_deadline_ms,
        )

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

    def list(self, urn_glob: str, *, deadline_ms: int | None = None) -> 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(**self._req("list", deadline_ms, 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,
        wait_ms: int = 0,
        deadline_ms: int | 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'``.

        **Long poll (M-B B2/B3).** ``wait_ms=0`` (the default) answers
        immediately with whatever is queued — byte-identical to every
        pre-B2 client. A positive ``wait_ms`` asks the broker to BLOCK up
        to that long for the first event before answering; an empty
        ``events`` list at the deadline is a successful answer, not an
        error. The broker's ceiling is 30 000 ms and asking for more is
        refused rather than clamped (here, before the wire).

        **The deadline invariant (B3-AM1).** When ``wait_ms`` is
        positive, the effective ``deadline_ms`` must be at least
        ``wait_ms + 2000`` — a deadline that fires mid-wait does not
        merely cut the poll short, it POISONS this connection (the reply
        is in flight and the wire carries no request ids, so every later
        call is refused with ``UnavailableError``; see F-A1-01). The SDK
        refuses the combination with ``ValueError`` before sending
        anything. ``deadline_ms=0`` — explicitly unbounded — is allowed
        with a long poll: an unbounded call cannot time out, so it cannot
        poison.

        ``Client.monitor()`` (below) wraps this in an iterator and
        carries the invariant for you.
        """
        _check_wait_ms(wait_ms)
        _check_long_poll_deadline(wait_ms, self._deadline(deadline_ms), where="drain_events()")
        kwargs = self._req("drain_events", deadline_ms, scope=scope)
        if subscription_id is not None:
            kwargs["subscription_id"] = subscription_id
        if wait_ms > 0:
            # Omitted when zero: the wire stays byte-identical to 0.1.10
            # for every consumer that never asks for a long poll.
            kwargs["wait_ms"] = wait_ms
        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,
        deadline_ms: int | 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 = self._req("subscribe", deadline_ms, 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, *, deadline_ms: int | None = None) -> 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(**self._req("unsubscribe", deadline_ms, id=sub_id))

    def _monitor_deadline_ms(self, wait_ms: int, deadline_ms: int | None) -> int:
        """The deadline every monitor-family ``drain_events`` carries.

        Two rules, one number.

        **M-A A1 item 4 — never unbounded.** A monitor drain with no
        deadline against a wedged broker produces no events, no error and
        no EOF: the consumer's event stream simply stops, silently. So
        the floor is ``wait_ms + 2000`` and an explicit ``0``
        ("unbounded", the escape hatch everywhere else) is REFUSED here
        rather than honoured.

        **M-B B3-AM1 — never shorter than the wait.** The broker ignores
        ``deadline_ms`` on ``drain_events`` entirely (``DrainEventsReq``
        has no such field), so nothing server-side can enforce the
        relationship between the wait and the bound; it is enforceable
        only here. A deadline inside the wait window fires while the
        broker is still legitimately waiting, and a socket-phase timeout
        POISONS the transport (F-A1-01) — so the failure mode is not "a
        short poll" but "a dead monitor". A caller-supplied deadline
        below the floor is refused with ``ValueError`` before any wire
        traffic; one above it is honoured (a laxer bound is a choice, and
        a safe one).

        When the caller passes nothing, the client-level default can
        still RAISE the bound — but never lower it below the floor."""
        floor = wait_ms + _LONG_POLL_GRACE_MS
        if deadline_ms is None:
            return max(floor, self._default_deadline_ms)
        if deadline_ms == 0:
            raise ValueError(
                "deadline_ms=0 (unbounded) is refused by the monitor family: an "
                "unbounded drain against a wedged broker yields no events, no "
                f"error and no EOF. The bound is at least wait_ms + "
                f"{_LONG_POLL_GRACE_MS}ms (= {floor}ms here); pass that or more, "
                "or omit deadline_ms to get it."
            )
        if deadline_ms < floor:
            raise ValueError(
                f"deadline_ms={deadline_ms} is shorter than wait_ms={wait_ms} + "
                f"{_LONG_POLL_GRACE_MS}ms grace (>= {floor} required, B3-AM1). A "
                "long poll answers AT wait_ms, so a deadline inside that window "
                "fires mid-wait — and a socket-phase timeout poisons the "
                "connection permanently rather than merely ending the poll "
                "early. Raise deadline_ms, or lower wait_ms."
            )
        return deadline_ms

    def _open_event_stream(
        self,
        scope: str,
        *,
        predicate: dict[str, Any] | None,
        wait_ms: int,
        deadline_ms: int | None,
        poll_interval_s: float | None,
        surface: str,
    ) -> _EventStream:
        """Validate, then BUILD (not open) the one event path behind all
        three monitor shapes. Every refusal below happens before a socket
        exists, so a mis-sized call costs nothing and breaks nothing."""
        _deprecated_poll_interval(poll_interval_s, surface=surface)
        if self._socket_path is None:
            raise ValueError(
                f"{surface}() needs a socket_path — construct the Client via "
                "Client.connect(path) so the monitor can open its own "
                "connection. Since 0.1.11 every monitor shape subscribes on a "
                "dedicated connection: subscriptions are keyed to the "
                "connection broker-side, and a long poll would otherwise hold "
                "this Client's transport lock for wait_ms at a time."
            )
        _check_wait_ms(wait_ms)
        effective_deadline_ms = self._monitor_deadline_ms(wait_ms, deadline_ms)
        return _EventStream(
            socket_path=self._socket_path,
            client_id=self._id,
            scope=scope,
            predicate=predicate,
            wait_ms=wait_ms,
            deadline_ms=effective_deadline_ms,
        )

    def monitor(
        self,
        scope: str = "hw:/**",
        *,
        predicate: dict[str, Any] | None = None,
        wait_ms: int = _DEFAULT_WAIT_MS,
        poll_interval_s: float | None = None,
        deadline_ms: int | None = None,
    ) -> Iterator[dict[str, Any]]:
        """Iterator over watch events. Yields each event as it arrives.

        .. code-block:: python

            for event in client.monitor("hw:/usb/**"):
                handle(event)

        **The wait is the broker's (M-B B3).** The iterator registers a
        server-side subscription on its own connection and then long-polls
        it: each internal ``drain_events`` blocks broker-side for up to
        ``wait_ms`` (default 5 000, ceiling 30 000) and returns the
        instant an event lands. Latency is a round trip — measured at
        ~1 ms against a real broker — where the pre-0.1.11 iterator slept
        ``poll_interval_s`` between non-blocking drains and averaged half
        an interval behind the event.

        ``predicate`` is a server-side filter — see
        :py:meth:`subscribe` for the grammar and ``mgf.hrb``'s typed
        builders (:py:func:`~mgf.hrb.kind_in`,
        :py:func:`~mgf.hrb.urn_glob`, …) for building one. Omit it and
        the subtree filtering is carried by ``scope`` alone, which is
        deliberate: the broker applies scope gap-honestly (a ``gap``
        event has no URN, so a ``urn_glob`` PREDICATE would silently drop
        every one of them, and "you missed things" is the one message a
        monitor must never lose — D-022).

        **Its own connection.** Each monitor holds one dedicated
        connection, so a five-second long poll cannot park the transport
        lock of the ``Client`` you called it on. Requires
        :py:meth:`connect` to have stored the ``socket_path``; raises
        ``ValueError`` otherwise. The connection is released when the
        iterator is closed or garbage-collected — ``break`` out of the
        loop and it goes away.

        **A dead broker (D-024).** On a drain failure the iterator yields
        one ``{"kind":"gap","source":"monitor_reconnect","error":...}``
        and then reconnects + re-subscribes once; the gap is the
        instruction to rescan, because the broker's ring did not survive
        and no de-duplication is possible or claimed. If the reconnect
        fails — or if the reconnected stream fails again before a single
        successful drain — the error is RAISED out of the iterator, as it
        has been since 0.1.10.

        ``poll_interval_s`` is accepted, ignored, and warns
        (``DeprecationWarning``); it is removed in 0.2.0.
        """
        stream = self._open_event_stream(
            scope,
            predicate=predicate,
            wait_ms=wait_ms,
            deadline_ms=deadline_ms,
            poll_interval_s=poll_interval_s,
            surface="monitor",
        )
        return _monitor_iter(stream)

    def control(
        self,
        urn: str,
        verb: ControlVerb | str,
        value: str | bool,
        *,
        cap_id: int = 0,
        deadline_ms: int | None = None,
    ) -> 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).

        **``deadline_ms`` (SDK-10).** ``control`` is a *write*; until
        0.1.10 it was the one mutating verb a consumer could not bound,
        so a wedged broker turned an operator's "authorize this device"
        into a permanent freeze. It now takes the same keyword-only
        deadline as every other verb: the operator-facing "I will wait
        this long for the device-state change to be acknowledged".
        """
        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(
            **self._req(
                "control",
                deadline_ms,
                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, *, deadline_ms: int | None = None) -> 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, deadline_ms=deadline_ms))

    def inspect_raw(self, urn: str, *, deadline_ms: int | None = None) -> 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(**self._req("inspect", deadline_ms, urn=urn, cap_id=self._cap))
        result: ResourceDetailRaw = resp["resource"]
        return result

    def dump_all(
        self,
        scope: str = "hw:/**",
        *,
        deadline_ms: int | None = None,
    ) -> 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(**self._req("dump_all", deadline_ms, 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",
        *,
        deadline_ms: int | None = None,
    ) -> 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(
            **self._req("inspect_full", deadline_ms, 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",
        *,
        deadline_ms: int | None = None,
    ) -> 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(
            **self._req("inspect_full", deadline_ms, urn=urn, depth=depth, cap_id=self._cap)
        )

    def dump_chain(self, urn: str, *, deadline_ms: int | None = None) -> 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(**self._req("dump_chain", deadline_ms, 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, *, deadline_ms: int | None = None) -> 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(**self._req("dump_chain", deadline_ms, urn=urn, cap_id=self._cap))

    def capabilities(self, *, deadline_ms: int | None = None) -> 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(deadline_ms=deadline_ms)
        ]

    def capabilities_raw(self, *, deadline_ms: int | None = None) -> 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(**self._req("capabilities", deadline_ms))
        result: list[dict[str, Any]] = resp.get("backends", [])
        return result

    def driver_info(self, name: str, *, deadline_ms: int | None = None) -> 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(**self._req("driver_info", deadline_ms, 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, *, deadline_ms: int | None = None) -> 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(**self._req("audit_tail", deadline_ms, 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, *, deadline_ms: int | None = None) -> 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(**self._req("state_snapshot", deadline_ms, 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, *, deadline_ms: int | None = None) -> 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(**self._req("capabilities", deadline_ms))
        return str(resp.get("predicate_grammar_version", "0.0.0"))

    def list_leases(
        self,
        scope: str = "hw:/**",
        *,
        deadline_ms: int | None = None,
    ) -> 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=self._deadline(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,
        deadline_ms: int | 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.

        ``deadline_ms`` bounds that broker-side call (SDK-10). The local
        checks are filesystem reads and are not affected.
        """
        # 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, deadline_ms=self._deadline(deadline_ms))

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

    def claim(
        self,
        urn: str,
        mode: ClaimMode | str = ClaimMode.EXCLUSIVE,
        *,
        deadline_ms: int | None = None,
        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=self._deadline(deadline_ms),
            detach=detach,
        )
        return Lease(self, urn, int(resp["lease_id"]))

    def release(self, urn: str, lease: int, *, deadline_ms: int | None = None) -> 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=self._deadline(deadline_ms),
        )

    def revoke(self, urn: str, *, deadline_ms: int | None = None) -> 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=self._deadline(deadline_ms),
        )

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

    def attenuate(
        self,
        *,
        scope: str | None = None,
        rights: RightNameList | None = None,
        mode_ceiling: ClaimMode | str | None = None,
        deadline_ms: int | 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 = self._req("attenuate", deadline_ms, 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,
            # The child inherits the parent's deadline policy: it shares
            # the parent's transport, so a child that could still issue
            # unbounded calls would re-open the lock amplifier the
            # parent just closed.
            default_deadline_ms=self._default_deadline_ms,
        )

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

    def recover(self, urn: str, *, deadline_ms: int | None = None) -> 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=self._deadline(deadline_ms),
        )
        return str(resp.get("outcome", ""))

    def reconcile_after_restart(
        self, *, scope: str = "hw:/**", deadline_ms: int | None = None
    ) -> 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=self._deadline(deadline_ms),
        )

    def irq_scan(self, *, deadline_ms: int | None = None) -> 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=self._deadline(deadline_ms)
        )

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

    def raw(self, urn: str, lease: int, *, deadline_ms: int | None = None) -> 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=self._deadline(deadline_ms),
        )

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

    def open_shm_channel(self, *, deadline_ms: int | None = None) -> 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(**self._req("open_shm_channel", deadline_ms, 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, *, deadline_ms: int | None = None) -> 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(
            **self._req("drain_shm_channel", deadline_ms, cap_id=self._cap, channel=channel)
        )

    def start_shm_drain(self, channel: int, *, deadline_ms: int | None = None) -> 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(
            **self._req("start_shm_drain", deadline_ms, 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()

    # ---- The fd shape of the event path ----------------------------------
    #
    # Until 0.1.11 this section was headed "Deferred surfaces" and said
    # `monitor_fd` was "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." M-B did that work from the other end: the broker pumps its
    # own backends (B2) and a drain can WAIT (long poll), so the pump
    # thread is parked in a blocking read until an event exists rather
    # than waking on a timer to ask. Nothing is deferred here now.

    def monitor_fd(
        self,
        scope: str = "hw:/**",
        *,
        predicate: dict[str, Any] | None = None,
        wait_ms: int = _DEFAULT_WAIT_MS,
        poll_interval_s: float | None = None,
        deadline_ms: int | None = None,
    ) -> 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, registers a server-side subscription
        on it, and long-polls that subscription (see :py:meth:`monitor`
        for why the connection is dedicated and why the default filter
        is ``scope`` rather than a ``urn_glob`` predicate). Each event is
        written 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.

        Since 0.1.11 the thread spends its life BLOCKED in the broker's
        long poll instead of waking every ``poll_interval_s`` to ask a
        question whose answer is almost always "nothing". Measured
        event→fd-readable latency against a real broker: ~1 ms.

        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. The connection
        and the subscription are opened BEFORE this returns, so a broker
        that is down is an exception at the call site rather than an fd
        that only ever yields a gap.

        **Bounded pump (M-A A1 + B3-AM1).** The pump's ``drain_events``
        is never unbounded: its deadline is ``wait_ms + 2000`` (raised,
        never lowered, by the client default). ``deadline_ms=0`` does NOT
        make the pump unbounded — it is refused with ``ValueError``,
        because a wedged broker would otherwise wedge the whole event
        stream with no events, no error and no EOF. A caller-supplied
        deadline BELOW that floor is refused for the opposite reason: it
        would fire mid-wait and poison the pump's connection.

        **Honest death (M-A A1).** When the pump dies of a broker error
        (``MgfHrbError``, including the deadline's ``TimeoutError``) or
        an ``OSError``, it writes ONE final line before closing the
        write end::

            {"kind": "gap", "source": "monitor_pump_error", "error": "<str(exc)>"}

        then the reader sees EOF. ``gap`` is already the "you missed
        things, rescan" signal every consumer handles, so the failure
        arrives on the existing path instead of as a bare EOF that is
        indistinguishable from a clean shutdown. A clean
        :py:meth:`close` emits no gap — silence there means "you asked
        me to stop", and the two cases are now distinguishable.

        **Crash-only reconnect (M-B B3 / D-024).** Before that terminal
        line, the pump gets ONE reconnect. On a drain failure it writes::

            {"kind": "gap", "source": "monitor_reconnect", "error": "<type: msg>"}

        …and then re-connects and re-subscribes. On success the stream
        continues on the new connection: the consumer has already been
        told to rescan, which is the only correct response, because the
        broker's ring did not survive the restart and no event that fell
        into the gap can be recovered. **No de-duplication is performed
        or claimed** — a `gap` means "resynchronise from the source", not
        "here is what you missed". If the reconnect fails, or if the
        reconnected stream fails again before a single successful drain
        (the rule that keeps a broker which accepts-then-drops from
        becoming a gap-emitting spin), the terminal
        ``monitor_pump_error`` line and EOF follow as above.
        """
        stream = self._open_event_stream(
            scope,
            predicate=predicate,
            wait_ms=wait_ms,
            deadline_ms=deadline_ms,
            poll_interval_s=poll_interval_s,
            surface="monitor_fd",
        )
        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 the second connection + its subscription HERE, on the
        # caller's thread: a broker that refuses is an exception the
        # caller can act on, which is what this surface has always done.
        stream.open()
        read_fd, write_fd = os.pipe()
        stop_event = threading.Event()

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

            Uses a dedicated connection so the main Client's wire stays
            uncontended — mandatory now rather than merely polite: the
            drain below blocks for up to `wait_ms`, and on a shared
            transport that is `wait_ms` of held lock for every other
            call on the Client.
            """
            pump_error: BaseException | None = None
            try:
                while not stop_event.is_set():
                    try:
                        events = stream.drain()
                    except (MgfHrbError, OSError) as exc:
                        if stop_event.is_set():
                            # Our own `close()` interrupted the poll.
                            # That is not a failure and must not emit a
                            # gap — the whole point of the A1 contract is
                            # that silence means "you asked me to stop".
                            return
                        if not stream.can_reconnect:
                            # Already spent this stream's one recovery
                            # and it died again immediately. The
                            # terminal gap carries the BROKER's error,
                            # not a synthetic one about retry policy.
                            pump_error = exc
                            break
                        # D-024 — announce the discontinuity FIRST (the
                        # consumer's rescan is the slow part and can
                        # start while we dial), then reconnect once.
                        gap = (json.dumps(stream.reconnect_gap(exc)) + "\n").encode()
                        if not _write_line(write_fd, gap):
                            return
                        logger.warning(
                            "monitor_fd stream broke (%s: %s) — reconnecting once",
                            type(exc).__name__,
                            exc,
                            extra={"scope": scope, "exc_type": type(exc).__name__},
                        )
                        try:
                            stream.reconnect()
                        except (MgfHrbError, OSError) as reconnect_exc:
                            pump_error = reconnect_exc
                            break
                        continue
                    for ev in events:
                        payload = (json.dumps(ev) + "\n").encode()
                        if not _write_line(write_fd, payload):
                            # Reader closed the pipe → we exit. No
                            # gap line: there is nobody to read it.
                            return
            finally:
                if pump_error is not None:
                    # ONE final line, complete and self-contained. It
                    # cannot interleave with a partial event line:
                    # `_write_line` only returns after the whole
                    # previous payload reached the pipe, and this
                    # write is issued from the same (single) pump
                    # thread. Written BEFORE the write end closes, so
                    # the reader gets gap-then-EOF, in that order.
                    logger.warning(
                        "monitor_fd pump died: %s: %s",
                        type(pump_error).__name__,
                        pump_error,
                        extra={
                            "scope": scope,
                            "exc_type": type(pump_error).__name__,
                            "wait_ms": wait_ms,
                        },
                    )
                    _write_line(
                        write_fd,
                        (
                            json.dumps(
                                {
                                    "kind": "gap",
                                    "source": "monitor_pump_error",
                                    "error": str(pump_error),
                                }
                            )
                            + "\n"
                        ).encode(),
                    )
                try:
                    os.close(write_fd)
                except OSError:
                    pass
                stream.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,
            stream=stream,
        )
        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 interrupt its long poll. Order
        # matters: the pump checks `stop_event` the moment its drain
        # fails, and the interrupt is what makes that drain fail now
        # rather than up to `wait_ms` from now. Before B3 the pump slept
        # in `stop_event.wait(...)` and the flag alone was enough; a
        # broker-side long poll cannot be woken by a client-side flag.
        r.stop_event.set()
        r.stream.interrupt()
        try:
            os.close(r.read_fd)
        except OSError:
            pass
        # The thread closes write_fd + its stream itself before exiting.
        # Bounded join: never block close() forever on a misbehaving
        # thread.
        r.thread.join(timeout=2.0)

    def amonitor(
        self,
        scope: str = "hw:/**",
        *,
        predicate: dict[str, Any] | None = None,
        wait_ms: int = _DEFAULT_WAIT_MS,
        poll_interval_s: float | None = None,
        deadline_ms: int | None = None,
    ) -> 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.** The same event path as :py:meth:`monitor` —
        own connection, own subscription, long poll — with the blocking
        drain handed to :py:func:`asyncio.to_thread` so it never stalls
        the event loop. Since 0.1.11 there is no ``asyncio.sleep``
        between drains: the loop is idle inside the broker's wait rather
        than waking on a timer, so an event surfaces in a round trip
        instead of up to ``poll_interval_s`` later.

        Same predicate rule, same deadline floor (B3-AM1), and the same
        one-reconnect-with-a-gap discipline as :py:meth:`monitor`: the
        reconnect gap is YIELDED as an ordinary event and a terminal
        failure is raised out of the iterator.

        ``poll_interval_s`` is accepted, ignored, and warns
        (``DeprecationWarning``); it is removed in 0.2.0.
        """
        stream = self._open_event_stream(
            scope,
            predicate=predicate,
            wait_ms=wait_ms,
            deadline_ms=deadline_ms,
            poll_interval_s=poll_interval_s,
            surface="amonitor",
        )
        return _amonitor_iter(stream)
