# PUBLIC_API — `mgf.hrb`

> **Master-HEAD banner (REL-03):** reflects CURRENT main/release; verify your pin via `pip show mgf-hrb` + `mgf.hrb.__version__` (mirrors pyproject) + PUBLIC_API.json. Generated from `__all__`, CI-pinned.


> **What this is.** The DOC-08 contract surface, in two halves that
> cover different things and neither of which covers the other alone
> (DOC-04):
>
> * [`PUBLIC_API.json`](PUBLIC_API.json) is **generated from each
>   module's `__all__` in CI and pinned** by
>   `tests/conformance/test_public_api_pins.py`. It is authoritative for
>   **names** — which types and functions are exported.
> * **This file** is hand-written and is authoritative for **method-level
>   surface + semantics** — `Client`'s verbs and their signatures, which
>   `__all__` cannot see at all. A gate built on `grep`ping this file for
>   a *type* name should read the JSON instead; a gate that needs a
>   *verb* has only this file, so this file must not drift.
>
> **What is NOT listed in either file is NOT public.** Module names
> starting with a single underscore (`_client.py`, the `cli/_*.py`
> subcommands) are internal-only (AP-14).
>
> **Stability tier (AP-11).** `mgf-hrb` is published; the current release
> is the one named by `mgf.hrb.__version__` (mirrored in
> `PUBLIC_API.json`'s `version` and `pyproject.toml` — CI pins all
> three). The tier is **`experimental` throughout `0.1.x`**: names and
> behavioural contracts may still change between MINORs, and the wire
> protocol is versioned together with the SDK. Pin a specific version of
> `mgf-hrb` in `pyproject.toml` if you depend on this surface. From
> `0.1.10` onward, additions follow AP-04 deprecation discipline where
> practical (announce → one MINOR → remove); see **Stability
> commitment** at the foot of this file.

---

## `mgf.hrb` — the SDK package

### Entry point

| Name | Kind | Notes |
|---|---|---|
| `Client` | class | The synchronous broker client (`_client.py`). Construct with `Client.connect(path)`; context-managed close (DP-13). |
| `Lease` | class | The handle returned by `Client.claim(...)`; doubles as a context manager. Carries `urn`, `lease_id`, and `release()` / `raw()` methods. Typed-exported so consumers can annotate `def hold_one(...) -> Lease` without reaching into `mgf.hrb._client` (FEEDBACK SDK-04). |
| `ShmChannel` | class | The client side of the Part 5.3 shared-memory data plane, returned by `Client.open_shm_channel()`. Wraps the SCM_RIGHTS-passed region fd + its `mmap`; validates the in-region ring header; `push(item: int) -> bool` is the SPSC producer half (the broker drains). Context-managed `close()`. |
| `__version__` | str | Package version. |

#### `Client` — the read + write verbs

The synchronous client exposes one method per wire op. Every method
raises a typed `MgfHrbError` subclass on broker-side failure (EH-02);
`Client` itself works as a context manager (`with Client.connect(...)
as c:`).

**Every verb below takes a keyword-only `deadline_ms: int | None = None`**
— see **Deadlines** immediately after the table. It is omitted from the
signatures in the table only to keep them readable.

| Method | Purpose | Notes |
|---|---|---|
| `connect(path, *, client_id=1, hello_deadline_ms=2000, default_deadline_ms=0)` | classmethod | Open a Unix-socket connection to the broker at `path` and complete the hello handshake. The broker writes a hello line on accept; this method blocks reading it and then returns. `hello_deadline_ms` (SDK-06) bounds that recv — a peer that accepts but never writes the hello raises `MgfHrbError` instead of hanging. `default_deadline_ms` (SDK-10) is the deadline every verb uses when its own is `None`; `0` = unbounded, which is the pre-0.1.10 behaviour. See **Handshake** below for the wire format. |
| `close()` | method | Close the socket, and tear down an active `monitor_fd` pump first. The `with`-form is preferred (DP-13). |
| **Read verbs** | | |
| `list(urn_glob) -> list[str]` | enumerate | URNs matching the trailing-`**` glob (e.g. `"hw:/usb/**"`). Filtered server-side. |
| `inspect(urn) -> ResourceDetail` | per-URN peek (typed) | Returns a frozen `ResourceDetail` dataclass with `urn`, `family`, `kind`, `fidelity`, and a backend-specific `attrs` tuple (USB: `idVendor`/`idProduct`/`serial`/`bInterfaceClass`; PCI: `vendor`/`device`/`bdf`). Use `detail.attr("idVendor")` for single-key lookup. Read-only — does NOT claim. FEEDBACK SDK-02 typed half. |
| `inspect_raw(urn) -> dict` | escape hatch | The un-projected wire dict — preserved for the CLI info renderer + forward-compat readers. |
| `inspect_full(urn, depth="all") -> InspectFullResult` | sysfs subtree walk (Part 13 §5.5.1) | Walks one URN's sysfs directory. `depth` is `"shallow"` (that directory only) or `"all"` (recursive, bounded by MAX_DEPTH / MAX_ENTRIES / MAX_FILE_BYTES). Symlinks are NEVER followed — they render as `{"_symlink": "<target>"}`; non-UTF-8 attributes render as `{"_binary": true}`. Carries an honest `truncated` flag. `UnsupportedError` on backends without the op (fake, by design). |
| `inspect_full_raw(urn, depth="all") -> dict` | escape hatch | The un-projected `inspect_full` wire dict. |
| `dump_all(scope="hw:/**") -> DumpAllResult` | bulk inspect (Part 13 §5.5.3) | "Everything sysfs knows about every device in this scope", one round trip — the shape a GUI's initial render wants. Carries `items` (a tuple of `ResourceDetail`), `count`, and an honest `truncated` flag when the response buffer filled first (re-issue against a narrower scope). |
| `dump_chain(urn) -> DumpChainResult` | parent-chain walk (Part 13 §5.5.2) | Leaf-first tuple of `ChainEntry` rungs up to the substrate root (USB: interface → device → hubs → root_hub → controller). Intermediate rungs are TOPOLOGICAL LABELS, not enumerable resources — only the leaf's URN appears in `list()`. |
| `dump_chain_raw(urn) -> dict` | escape hatch | The un-projected `dump_chain` wire dict. |
| `capabilities() -> list[BackendCapability]` | broker introspection | Per-worker record carrying `scope_prefix`, `backend_name`, `resource_count`, and the forward-compatible `backend_version` / `mode` / `mode_reason` / `ops` fields (FEEDBACK SDK-05). |
| `capabilities_raw() -> list[dict]` | escape hatch | The un-projected wire dicts — preserved for the CLI doctor's per-worker ops grid render which reads fields the typed shape doesn't promise. |
| `predicate_grammar_version() -> str` | subscribe-grammar version (Part 13 §7.3) | The broker's predicate-grammar semver (`"1.0.0"` for the v1 grammar). Query it BEFORE sending a non-trivial `subscribe` so you can downgrade unknown features instead of discovering `BadRequest` at runtime. Brokers that don't report it return `"0.0.0"`. |
| `driver_info(name) -> DriverInfo` | kernel-driver interface (Part 13 §5.2.2) | Load state + bound-device count read live from `/sys/module/` and `/sys/bus/<bus>/drivers/`, a best-effort sample bound device, and the curated registry fields (`None` until sub-phase 3B). Honest emptiness: a driver absent from this host returns `module_loaded=False, bound_count=0` rather than raising. `BadRequestError` for names outside `[A-Za-z0-9_-]`. |
| `audit_tail(n=100) -> AuditTailResult` | audit ring read (Part 13 §5.6.4) | Up to `n` most-recent broker audit records, newest first, each with timing, outcome, optional URN + err code, and any `correlation_id` you passed. Gated by the `audit_read` right; the connection's cap rides automatically. Broker caps `n` at the ring capacity (4096). |
| `state_snapshot() -> StateSnapshot` | broker counters (Part 13 §5.6.2) | Uptime, op count, ops/sec, active connections / leases / subscriptions, cap-table size, audit-ring fill, RSS, and a per-worker `WorkerSnapshot` tuple. Cheap — all in broker memory, no I/O. Gated by `audit_read`. |
| `list_leases(scope="hw:/**") -> list[LeaseRecord]` | who-holds-what view | Returns one `LeaseRecord` per live lease whose URN matches `scope` — INCLUDING leases held by other tenants (FEEDBACK SDK-03; multi-operator UX). Pure read. |
| **Event verbs** | | |
| `drain_events(scope="hw:/**", *, subscription_id=None, wait_ms=0) -> list[dict]` | event drain, optionally blocking | Two modes (Part 13 §5.4.2). **Subscriber mode** (`subscription_id` set) drains THAT subscription's bounded queue; events were already predicate-filtered at fan-out, so `scope` is ignored. **Legacy mode** (default) drains the connection's own hidden subscription, auto-registered on first use with `scope` as its filter — "every event since your last drain", per client, no longer raced against other operators. One-shot; pair with `monitor()` for streaming. Event `kind` is `"added"` / `"removed"` / `"driver_bound"` / … ; a buffer overrun arrives as `{"kind":"gap","source":"netlink_uevent",…}` and a subscription-queue overrun as `source="subscription_overflow"`. **`wait_ms`** (M-B) makes the drain a LONG POLL: `0` (default) answers immediately, as every pre-M-B client did; positive blocks broker-side up to that long for the first event, and an empty list at the deadline is a successful answer. Ceiling 30 000 ms — over-cap is refused, not clamped. A positive `wait_ms` requires `deadline_ms >= wait_ms + 2000`; see **The long-poll deadline invariant** below. |
| `subscribe(sub_id, scope="hw:/**", *, predicate=None) -> Subscription` | server-side event filter (Part 13 §5.4.2) | Registers a per-connection bounded queue (default 64 entries; the granted cap is on `Subscription.queue_max`) fed by events matching `scope` AND `predicate`. Gap events ALWAYS pass, whatever the predicate — gap-honest. `sub_id` is caller-chosen and unique per connection (`BadRequest` on a duplicate). Subscriptions die with the connection. Predicate grammar v1: operators `{"all_of":[…]}` / `{"any_of":[…]}` / `{"not":{…}}`; terms `{"kind_in":[…]}` / `{"urn_glob":"…"}` / `{"attr_eq":{"key":…,"value":…}}` / `{"attr_in":{"key":…,"values":[…]}}` — build these with the **Predicate builders** below rather than by hand. Consume via `drain_events(subscription_id=sub.id)`. |
| `unsubscribe(sub_id)` | tear down a subscription | `NotFoundError` when no such subscription exists on this connection — idempotency is the caller's job. A clean `close()` reaps them all anyway; explicit unsubscribe is for a long-lived client whose watches come and go. |
| `monitor(scope="hw:/**", *, predicate=None, wait_ms=5000) -> Iterator[dict]` | streaming events (sync) | Layer 1 of the three-layer event API (FEEDBACK SDK-01). Opens its OWN connection, registers a server-side subscription on it, and long-polls that subscription; yields each event as a dict. Requires a Client built by `connect()` (it needs the socket path) — `ValueError` otherwise. See **The monitor path** below for the connection rule, the predicate default, the deadline invariant and the reconnect contract. |
| `amonitor(scope="hw:/**", *, predicate=None, wait_ms=5000) -> AsyncIterator[dict]` | streaming events (async) | Layer 2 — for asyncio / qasync consumers. The same path as `monitor()`, with the blocking long poll on `asyncio.to_thread` so the event loop stays free. There is no `asyncio.sleep` between drains any more: the loop is idle inside the broker's wait. (FEEDBACK SDK-01.) |
| `monitor_fd(scope="hw:/**", *, predicate=None, wait_ms=5000) -> int` | streaming events (fd) | Layer 3 — returns the read end of a self-pipe. A daemon thread on its own connection long-polls its own subscription and writes each event as a newline-terminated JSON line. For Qt's `QSocketNotifier`, Gtk's `GLib.io_add_watch`, raw `select.poll()`. The connection + subscription are opened before this returns, so a broker that is down raises here. `Client.close()` tears down the thread + pipes — and interrupts the long poll, so teardown is immediate rather than "up to `wait_ms` from now". Only one active per Client. (FEEDBACK SDK-01 / SDK-08.) |
| **Write verbs** | | |
| `claim(urn, mode=ClaimMode.EXCLUSIVE, *, detach=False) -> Lease` | grant a lease | Returns a context-managed `Lease`. `detach=True` opts into the atomic disconnect-then-claim path (USBDEVFS_DISCONNECT_CLAIM); cap-gated by `detach_driver`. Contention is always fail-fast (`ConflictError`) — `deadline_ms` bounds the broker's hardware-acquire phase instead, and a trip QUARANTINES the URN. Read **`deadline_ms` on the wire** below before bounding this verb. |
| `release(urn, lease)` | release a held lease | Total + idempotent (Part 6.3) — releasing an unknown lease is a no-op. |
| `revoke(urn)` | preempt another holder (Tier-0 #4) | Forcibly ends any held lease on `urn` and tears down the backend's kernel-side claim; the holder's later `raw`/`release` return `RevokedError` and their dup'd fd returns ENODEV. Cap-gated by `steal`. Idempotent — revoking an unheld URN succeeds silently. |
| `raw(urn, lease) -> dict` | direct hardware channel | When the backend has an fd-shaped raw channel (USBDEVFS), the returned dict carries `fd` as a usable file descriptor (SCM_RIGHTS-passed). The fd outlives the broker's copy (Part 7.4 — survivability dividend, G4). |
| `control(urn, verb, value, *, cap_id=0) -> ControlOutcome` | typed device-state mutation | Operator-visible verbs (`set_authorized`, `set_autosuspend`, `set_port_power`, `bind_driver`, `unbind_driver` — USB-worker §5.10). Returns a `ControlOutcome` with the post-write `generation`, `applied_value`, `queue_depth` and `superseded_by` disclosure. Backends without writable device state raise `UnsupportedError` (G2); an unwritable sysfs file raises `NotPermittedError` carrying the recovery hint. |
| `attenuate(*, scope=None, rights=None, mode_ceiling=None) -> Client` | mint a weaker sub-capability (Part 4.4) | Returns a child `Client` presenting a strictly-weaker cap on the SAME connection (so the sub-cap's peer binding matches). Each axis is optional and can only SHRINK — the broker refuses any widen. The child does NOT own the socket, and it inherits the parent's `default_deadline_ms`. Requires the `grant` right. |
| `open_shm_channel() -> ShmChannel` | open shm data plane | Allocates a private memfd-backed region, hosts an SPSC ring in it, and SCM_RIGHTS-passes the fd; returns a `ShmChannel` to push into (Part 5.3). Reclaimed broker-side on disconnect. `OverloadedError` at the S5 region ceiling. |
| `drain_shm_channel(channel) -> dict` | drain shm data plane | Asks the broker to drain the channel's ring (consumer side, asymmetric-trust pop); returns `{drained, checksum, last_seq, dropped}`. In push-mode (after `start_shm_drain`) returns the background drainer's accumulated stats instead. |
| `start_shm_drain(channel) -> dict` | push-mode shm data plane | Switches 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 needs no per-batch drain RPC. `drain_shm_channel` then reports that thread's stats. Thread torn down on channel/connection close. Returns `{ok, draining, channel}`. |
| `recover(urn) -> str` | reset-escalation ladder (Tier-1 #12) | Runs `FLR → (verify) → bus reset → (verify) → quarantine` on a quarantined URN. Returns `"not_quarantined"` / `"recovered_flr"` / `"recovered_bus"` / `"still_quarantined"`. Cap-gated by `steal`. Always terminates — every rung is abandonable at its deadline. |
| `reconcile_after_restart(*, scope="hw:/**") -> dict` | restart recovery (Tier-1 #8) | Triggers the broker's restart reconcile: re-derives truth from hardware (present devices) + live leases, quarantining every present-but-unclaimed orphan (`unclaimed_after_restart`). Returns `{ok, quarantined, scanned, truncated}`. Steal-gated on `scope`; **trigger only after an actual restart** (operator/supervisor-driven — no self-restart-detection yet). Recover quarantined devices with `recover`. |
| `irq_scan() -> dict` | interrupt-rate containment (Tier-2, D7) | Sweeps every granted device's interrupt counter and revoke+quarantines any flooder (a runaway-IRQ use-path DoS the broker contains by withdrawing the grant). Returns `{ok, scanned, contained}`. Steal-gated on `hw:/**`; operator/supervisor-triggered. Counts are read live from `/proc/interrupts` per PCI device (`/sys/.../msi_irqs`); a USB interface shares the host-controller IRQ so it isn't individually attributable. Recover a contained device with `recover`. |
| **Diagnostics + tracing** | | |
| `doctor(*, socket_path=None) -> list[DoctorFinding]` | broker-precondition diagnostics | Runs the same check suite as the operator CLI (FEEDBACK TOOLS-02). Each finding carries a stable `code` (e.g. `SOCKET_REACHABLE`, `IOMMU_PRESENT`), four-way `severity`, and operator-facing prose. `socket_path` defaults to `Client.socket_path`. Never raises on a broker failure — it reports one as a `FAIL` finding, including a blown `deadline_ms`. |
| `correlation_scope(correlation_id) -> ContextManager` | causal tracing (Part 13 §5.6.3) | Inside the `with` block every wire op this client issues carries `correlation_id`, and the broker echoes it verbatim in both success and error responses — so a logical operation spanning claim → raw → release assembles into one trace. Nest-safe (inner shadows outer, restored on exit). Charset `[A-Za-z0-9._-]`, max 128 bytes; violations raise `BadRequestError` from the first op in the scope. |
| **Properties** | | |
| `socket_path` | str \| None | The path the Client connected to (set by `Client.connect`); used as the default for `doctor()`. `None` for Clients built directly via `__init__` (test fakes). |
| `default_deadline_ms` | int | The deadline applied to any call whose own `deadline_ms` is `None`. `0` = unbounded (the SDK default). |

`Lease` mirrors the same rule: `Lease.release(deadline_ms=None)` and
`Lease.raw(deadline_ms=None)`, both resolving against the owning
Client's default.

#### Deadlines (SDK-10 — since 0.1.10)

Every method above that speaks the wire takes a keyword-only
`deadline_ms: int | None = None`:

| Value | Meaning |
|---|---|
| `None` (default) | Use this Client's `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 (D-029). Exceeding it raises `mgf.hrb.TimeoutError`. |
| `0` | Explicitly unbounded. The back-compat escape; overrides a positive client default. |

`Client.connect(path, default_deadline_ms=0)` sets the client-level
policy. **The default is `0`**, so a consumer that passes neither
parameter behaves exactly as it did on 0.1.9 — including the bytes on
the wire, since a zero deadline is simply not sent on the verbs that
never carried it.

Two properties worth knowing before you pick a number:

- **The deadline bounds the transport lock too.** `Client` serializes
  wire access with an internal lock held for the whole round trip.
  Before 0.1.10 one unbounded op could stall every other call on the
  same Client — including calls that had passed a deadline. A call that
  cannot get the lock in time now raises `TimeoutError` with a message
  naming the transport lock, so "the broker is slow" and "another call
  on this Client is holding the wire" are distinguishable.
- **It bounds each blocking phase, not their sum.** Worst case, a call
  that waits nearly the whole deadline for the lock and then times out
  on the socket takes ~2× `deadline_ms` (measured: 1.95×). Size a
  supervising watchdog accordingly.
- **A trip is terminal for the connection.** The wire carries no request
  ids, so a call that abandons its read cannot tell a late reply from
  its own — and a slow broker's reply *does* arrive, into the socket
  buffer. Rather than risk handing the next call the previous call's
  answer, a timed-out call marks the transport unusable: every
  subsequent call raises `UnavailableError` naming the desync, until you
  `close()` and `connect()` again. This applies only to a timeout that
  touched the socket. A `TimeoutError` from losing the race for the
  transport lock sent nothing and is freely retryable on the same
  client; so is a `Timeout` the *broker* returned, which is a reply like
  any other.

#### `deadline_ms` on the wire — what the broker does with it

Two things a caller should know before choosing a number, because the
SDK-side and broker-side meanings are not the same:

- **On `claim`, `release` and `raw` only, `deadline_ms` must be
  ≤ 3 600 000 (1 hour).** Those three validate against the broker's
  `MAX_DEADLINE_MS` and reject anything larger with `BadRequestError` —
  rejected, not clamped. **The ceiling is not global:** `revoke`,
  `recover`, `reconcile_after_restart` and `irq_scan` also carry
  `deadline_ms` and accept any non-negative value (verified against a
  live broker: 3 600 001 is `BadRequest` on the first three and accepted
  by the other four). Every other verb's `deadline_ms` is a client-side
  bound the broker ignores entirely, so no ceiling applies there either.
- **`claim` does not use it to wait for contention.** A claim that
  conflicts with a current holder — or that loses a fairness
  reservation — fails immediately with `ConflictError`, with or without
  a deadline. `deadline_ms` bounds the broker's *hardware acquire*
  phase, which runs after the lease is reserved: **and a trip there
  quarantines the URN.** The resource moves to `quarantined`
  (`op_timeout`) and every later `claim` returns `UnavailableError`
  until someone calls `recover(urn)`. So a short deadline on `claim` is
  not a cheap "give up early" — it is a request to quarantine the device
  if the acquire is slow. A legitimate acquire can take up to ~5 s on
  the helper-IPC path, so anything below that is asking for
  false-positive quarantines. Leave `claim` unbounded (`deadline_ms=0`),
  or bound it well above the acquire cost.

#### The monitor path (`monitor` / `amonitor` / `monitor_fd`)

All three shapes are ONE path since 0.1.11 (M-B B3, closing `SDK-08`):
each opens its own connection, registers a server-side subscription on
that connection, and long-polls it. Five rules govern it.

**1. Its own connection, always.** Subscriptions are keyed to the
connection broker-side, so subscribing on the connection you drain is
what makes server-side filtering reachable from the fd path at all —
that was `SDK-08`'s obstacle and it dissolves by construction. It is
also a liveness requirement rather than a preference: `_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 your `Client` would
park that lock for five seconds at a time and every other call would
queue behind it. Cost: one extra connection per monitor — what
`monitor_fd` has always spent, now spent by all three. Consequence: a
`Client` built without a socket path (a raw `_Transport`) cannot
monitor, and says so with `ValueError`.

**2. The default filter is `scope`, not a predicate.** With no
`predicate`, the SDK sends none, and the subtree filtering is carried by
the subscribe's `scope`. This is deliberate and it is not the same as
sending `{"urn_glob": scope}`: a `gap` event carries no URN, so a
`urn_glob` PREDICATE evaluates false for it and drops **every gap** —
while the broker applies `scope` gap-honestly, exactly so that "you
missed things" survives any filter (D-022). Pass `predicate=` when you
want a real filter; build it with the **Predicate builders** below.

**3. The long-poll deadline invariant (B3-AM1).** Each internal
`drain_events` carries `deadline_ms = wait_ms + 2000` — raised by the
client-level default if that is larger, never lowered. Two refusals
guard it, both `ValueError` before any wire traffic:

* `deadline_ms=0` (unbounded) is **refused**. It is the escape hatch
  everywhere else; here it would let a wedged broker wedge the event
  stream into total silence — no events, no error, no EOF.
* `deadline_ms` below `wait_ms + 2000` is **refused**. The broker
  ignores `deadline_ms` on `drain_events` entirely (its request struct
  has no such field), so this relationship is enforceable only
  client-side — and getting it wrong is not "a poll that ends early":
  a socket-phase timeout POISONS the connection (see **Deadlines**),
  so every later call on it is refused with `UnavailableError`. A long
  poll that completes AT `wait_ms` is healthy and poisons nothing.

The same invariant applies to a hand-rolled `drain_events(wait_ms=…)`.

The one thing that is deliberately NOT bounded on this path is the
retry after back-pressure: a drain the broker sheds with `Overloaded`
backs off on the same connection — doubling to a 250 ms cap, one WARNING
per shed, no gap and no reconnect — and keeps trying indefinitely,
because a shed drain means the broker is alive and answering and there
is nothing to fail over to. A monitor therefore survives a busy broker;
it does not survive a wedged one, which is what the deadline above is
for. The two rules are a pair: the deadline bounds silence, the backoff
declines to bound back-pressure.

**4. Crash-only reconnect (D-024).** On a drain failure the stream emits

```json
{"kind": "gap", "source": "monitor_reconnect", "error": "<type: message>"}
```

— yielded as an ordinary event by `monitor`/`amonitor`, written as a
line by `monitor_fd` — and then reconnects and re-subscribes ONCE. The
gap goes out BEFORE the reconnect is attempted, so the consumer's rescan
runs while the SDK is still dialling. "Once" is a bounded recovery
window (≈2 s with backoff), not a single syscall: a broker restart takes
a few hundred milliseconds and a single immediate attempt would fail
100% of the time. **No de-duplication is performed or claimed** — the
broker's ring did not survive, so `gap` means "resynchronise from the
source", never "here is what you missed". A stream that comes back and
fails again before one successful drain does NOT get a second reconnect;
that is what stops an accept-then-drop broker from becoming a
gap-emitting spin.

**5. Honest death.** When the reconnect fails too, `monitor_fd` writes
exactly one final line before closing the write end:

```json
{"kind": "gap", "source": "monitor_pump_error", "error": "<message>"}
```

`gap` is already the "you missed things — rescan" signal every consumer
handles, so a dead pump arrives on a path that already exists instead of
as a bare EOF indistinguishable from a clean shutdown. **A clean
`Client.close()` emits no gap** — that is what gives the gap its
meaning. The line is always complete, valid JSON and can never
interleave with a partial event line. `monitor()` and `amonitor()` have
no pipe, so they surface the same failure the direct way: the typed
exception propagates out of the iterator.

**6. What a monitor costs the broker, and for how long.** One monitor =
one connection + one subscription. Closing it is immediate on YOUR side —
the SDK shuts the stream's socket down, so `Client.close()` returns in
about a millisecond even with a 5 s poll in flight, and a cancelled
`amonitor` lets go of its worker thread just as fast. **The broker-side
slot is not immediate.** A drain parked in its long poll is waiting on
the subscription registry, not on the socket, so it cannot see the
connection die: the broker's thread — and with it the connection's
admission slot and its subscriptions — is released only when the wait
lapses. Measured hold: up to the full `wait_ms` (14.7 s at
`wait_ms=15000`).

The arithmetic that follows is worth doing before you write a loop that
opens and closes monitors. Two numbers fix it, and neither is a
wall-clock: the per-tenant ceiling is **32 live connections per uid**
(`MAX_CONN_PER_UID`; the aggregate is 128), and a closed monitor's slot
is **recycled one `wait_ms` after its close**. So the sustainable churn
rate is 32 cycles per `wait_ms` — about six open/close cycles a second at
the 5 s default, 32 per thirty seconds at the ceiling — regardless of how
many monitors exist at once.

How *fast* you reach the ceiling from cold is not a property of the
broker at all: it is how quickly your loop issues connects. Measured on
one bench at 0.016–0.7 s for the first 31–34 cycles depending on what
else the loop did between them, which is why the useful number to hold
onto is the ceiling and the recycle interval, not a duration. Consequences
worth pricing:

* A long-lived monitor costs nothing extra — this is a churn cost, not a
  steady-state one.
* If you reconnect monitors on a retry loop, back off; the natural
  remedy for a monitor that looks stuck (close it, open another) is also
  what consumes the next slot.
* Lower `wait_ms` if you must churn: it is the numerator's denominator.
  A `wait_ms=500` monitor releases its slot in half a second.
* The refusal currently arrives as `MgfHrbError("unexpected broker
  greeting: {'ok': False, 'err': 'TooBusy'}")` rather than
  `OverloadedError` — filed as `SDK-16`. Until that lands, match on the
  connection ceiling by expecting a `connect` failure, not by expecting a
  type.

Both facts are broker-side and filed as `SDK-15` (the waiter should
observe connection death) and `SDK-16`. They are documented here rather
than worked around because no client-side action can shorten the hold.

**Deprecated:** `poll_interval_s` is accepted by all three, does
nothing, and emits a `DeprecationWarning` (AP-04). It named a wait
strategy the SDK no longer uses — there is no interval to set when the
broker holds the request until an event exists. Removed in 0.2.0; pass
`wait_ms` instead.

#### Predicate builders (`mgf.hrb.predicates`, re-exported from `mgf.hrb`)

Typed builders for the v1 subscription-predicate grammar, so consumers
never hand-write the AST:

```python
from mgf.hrb import all_of, kind_in, not_, urn_glob

client.monitor(
    "hw:/usb/**",
    predicate=all_of(
        kind_in("added", "removed", "ownership_changed"),
        not_(urn_glob("hw:/usb/000/**")),
    ),
)
```

| Name | Emits | Notes |
|---|---|---|
| `kind_in(*kinds) -> dict` | `{"kind_in": [...]}` | Values must be in `EVENT_KINDS` — the grammar is CLOSED and a typo is a `BadRequest` at subscribe time. Refused here with the valid set in the message. |
| `urn_glob(glob) -> dict` | `{"urn_glob": "..."}` | Trailing-`**` glob, same model as `list()`. **Does not match gap events** (they carry no URN) — see rule 2 above. |
| `attr_eq(key, value) -> dict` | `{"attr_eq": {"key":…, "value":…}}` | `kind` / `urn` / `driver` are answered from the event itself; other keys cost a broker-side device-attr lookup per event. |
| `attr_in(key, *values) -> dict` | `{"attr_in": {"key":…, "values":[…]}}` | |
| `all_of(*terms) -> dict` | `{"all_of": [...]}` | |
| `any_of(*terms) -> dict` | `{"any_of": [...]}` | For a set of kinds prefer one `kind_in` with several values — same meaning, one node against the broker's 64-node budget. |
| `not_(term) -> dict` | `{"not": {...}}` | Trailing underscore because `not` is a Python keyword; the wire key is the bare `not`. |
| `EVENT_KINDS` | `frozenset[str]` | The closed set `kind_in` accepts: `added`, `removed`, `state_changed`, `ownership_changed`, `faulted`, `gap`, `driver_bound`, `driver_unbound`. |
| `PREDICATE_GRAMMAR_VERSION` | `str` | The grammar these builders emit (`"1.0.0"`). Compare against `Client.predicate_grammar_version()`, which reports what the BROKER parses. |

Every builder returns a plain `dict` — the wire shape — so a predicate
can be logged, diffed, or stored in a config file, and a node this
module does not know about can still be nested inside `all_of(...)`.

Two families of mistake are refused with `ValueError` at the call rather
than at the subscribe:

* **Empty arrays** (`kind_in()`, `all_of()`, `any_of()`,
  `attr_in(key)`). Broker law N2: an empty `all_of` is vacuously TRUE
  and the others vacuously FALSE, so the caller gets a subscription that
  fires on everything or on nothing, forever. To match everything, omit
  the predicate.
* **Multi-key nodes** (`all_of({"kind_in": [...], "urn_glob": "..."})`).
  Every node carries exactly one operator or term; two in one dict is
  the grammar's way of writing nothing, and `all_of` is how "both" is
  spelled.

Bounds the builders deliberately do NOT mirror: the broker's 64-node
tree budget and 256-byte string cap. Those are capacity limits versioned
with the broker rather than grammar laws, they arrive as a typed
`BadRequestError` that names the problem, and mirroring them would mean
an older SDK refusing a tree a newer broker accepts.

### Handshake (Part 12 §3.3 — Tier-0 #2; SDK-06)

The broker writes exactly one line on `accept`, before any client byte:

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

`cap_id` is a CSPRNG-drawn root capability id (~2^63 entropy — closes
the U3 forgery vector). `Client.connect` reads this line, binds the
id to the connection, and presents it on every subsequent cap-checked
op. A connection that doesn't speak the handshake cannot guess a
working `cap_id`.

**Consumer-side test fakes MUST volunteer this line on `accept`.**
The SDK's `Client.connect` blocks reading it before returning; a
fake that waits for a client request before responding will deadlock
the test. `hello_deadline_ms` (default `2000`) bounds the wait —
the SDK raises `MgfHrbError("broker hello not received within Xms")`
instead of hanging. Reference fake shape:

```python
def serve_one_client(conn: socket.socket, cap_id: int = 42) -> None:
    # Volunteer the hello immediately on accept.
    conn.sendall(json.dumps(
        {"ok": True, "hello": "mgf-hrb", "cap_id": cap_id}
    ).encode() + b"\n")
    # Then enter the normal request-response loop.
    ...
```

### Exception hierarchy (EH-01)

Root: `MgfHrbError`. Every wire-level `Err` code translates to a typed
subclass at the SDK's transport seam (EH-02).

| Name | Wire code | Meaning |
|---|---|---|
| `MgfHrbError` | (root) | Anything went wrong inside the broker. |
| `NotFoundError` | `NotFound` | The named resource doesn't exist. |
| `NotPermittedError` | `NotPermitted` | Capability or holder check refused. |
| `ConflictError` | `Conflict` | Claim-mode incompatibility. |
| `UnsupportedError` | `Unsupported` | Backend honestly cannot (G2). |
| `HardwareFaultError` | `HardwareFault` | Device-level failure. |
| `InvalidTokenError` | `InvalidToken` | Token/lease/cap not recognised. |
| `BadRequestError` | `BadRequest` | Trust gate refused the request. |
| `TimeoutError` | `Timeout` | Op exceeded its deadline (S2). Also raised CLIENT-side, without a broker round trip, when `deadline_ms` elapses waiting for the transport lock or the response (SDK-10). Shadows the builtin — import it as `from mgf.hrb.exceptions import TimeoutError as HrbTimeoutError` if that matters in your module. |
| `OverloadedError` | `Overloaded` | Per-tenant / aggregate bound hit (S5). |
| `RevokedError` | `Revoked` | Lease revoked; further use will fault. |
| `UnavailableError` | `Unavailable` | The URN is quarantined (Part 2.7.1) — a prior op timed out, or a reset post-condition couldn't be verified, so the broker won't grant it to a new claimant. Does not time-decay: clear it with `recover(urn)` or operator action. |

### Diagnostics surface (FEEDBACK TOOLS-02)

| Name | Kind | Notes |
|---|---|---|
| `DoctorFinding` | frozen dataclass | `code` (stable id, e.g. `"SOCKET_REACHABLE"`), `severity`, `category`, `summary`, `detail`, `why`, `remediation`. Codes are stable across SDK MINORs; renderers may match on them. |
| `DoctorSeverity` | enum | `OK` / `WARN` / `FAIL` / `DEFERRED`. `DEFERRED` names a check the engine knows about but cannot yet run (e.g. udev-rule check before the rule template ships). |
| `DoctorCategory` | enum | `SUBSTRATE` / `HARDWARE` / `BROKER` — grouping for the operator-facing report. |
| `mgf.hrb.diagnostics` | submodule | The shared engine: `run_all(socket_path, *, client=None, deadline_ms=None)` plus the individual checks — `check_socket` / `check_pci_sysfs` / `check_usb_sysfs` / `check_usbfs_devnode` / `check_usbfs_openable` / `check_vfio_device` / `check_iommu_groups` / `check_udev_rule` / `check_backend_capabilities`. Same engine the operator CLI uses — no duplicate source of truth. |
| `check_usbfs_devnode` | function | Is `/dev/bus/usb` populated? Distinct from `USB_SYSFS_READABLE`: sysfs is the *enumeration* surface, usbfs is the *claim/raw* surface, and a host with one but not the other scans fine and then returns `NotFound` on every claim. |
| `check_usbfs_openable` | function | Can THIS process `open(O_RDWR)` a USB device node? The very first kernel call the claim path makes, so an `EACCES` here is exactly the missing-udev-rule failure operators hit. |

### Domain types

Frozen dataclasses across boundaries (DP-03). Mirror `hrb-code/src/model.zig`.

| Name | Kind | Notes |
|---|---|---|
| `ClaimMode` | enum | `OBSERVE` / `SHARED_RW` / `EXCLUSIVE` (Part 3.3 — Family A only; see "Claim-mode lattice" below). |
| `ControlVerb` | enum | `SET_AUTHORIZED` / `SET_AUTOSUSPEND` / `SET_PORT_POWER` / `BIND_DRIVER` / `UNBIND_DRIVER` — typed verbs for `Client.control()` (USB-worker §5.10). |
| `LeaseRecord` | frozen dataclass | One row in `Client.list_leases()`: `urn`, `lease_id`, `mode`, `holder`, `granted_at_ms` (FEEDBACK SDK-03). |
| `BackendCapability` | frozen dataclass | One row in `Client.capabilities()`: `scope_prefix`, `backend_name`, `resource_count`, and the forward-compatible `backend_version` / `mode` / `mode_reason` / `ops` (FEEDBACK SDK-05). Carries class constant `KNOWN_OPS: tuple[str, ...]` (the canonical kabi-seam op-name list) + `ops_dict()` method (flattens storage to `dict[str, bool]`). Recommended idiom for a stable display grid: `[cap.ops_dict().get(op, False) for op in cap.KNOWN_OPS]`. (A3 — presentation-layer plan.) |
| `AuditOutcomeSummary` | frozen dataclass | Typed return shape of `AuditTailResult.outcome_summary()`: `(ok, err, other, newest_error)` tally. Frozen / hashable so consumers can cache as dict keys or Qt model rows. (A2 — presentation-layer plan.) |
| `ResourceDetail` | frozen dataclass | Return of `Client.inspect()`: `urn`, `family`, `kind`, `fidelity`, `attrs` (sorted `(key, value)` tuple), forward-compat `parent` / `state`, and the SDK-11 honesty pair `kind_raw` / `family_raw`. Includes an `attr(key)` accessor for single-key lookup. (FEEDBACK SDK-02 typed half.) See **Honest enums** below. |
| `Family` | enum | `claimable` / `apportionable` / `unknown` (Part 2.1). |
| `Kind` | enum | Open registry of hardware kinds: `pcie_function` / `usb_interface` / `cpu_set` / `memory_region` / `unknown`. Additive by design — **do not assume this list is exhaustive**. |
| `FieldFidelity` | enum | `live` / `cached` / `inferred` / `unknown` (G2). |
| `IdentityClass` | enum | URN stability ladder (D-010). |
| `Resource` | dataclass | Minimum descriptor the broker reports. |
| `ControlOutcome` | frozen dataclass | Return of `Client.control()`: post-write `generation`, `applied_value`, `queue_depth`, and an optional `superseded_by` (Part 13 §5.1.2). Concurrent writers against the same `(urn, verb)` FIFO-serialize broker-side; the disclosure says where your write landed in that order. |
| `SupersededBy` | frozen dataclass | The disclosure inside `ControlOutcome.superseded_by`: `generation`, `cap_id`, `at_ms` — who overwrote you, and when. |
| `DumpAllResult` | frozen dataclass | Return of `Client.dump_all()`: `scope`, `items` (tuple of `ResourceDetail`), `count`, `truncated`. |
| `InspectFullResult` | frozen dataclass | Return of `Client.inspect_full()`: `urn`, `fidelity`, `captured_at_ms`, `tree`, `truncated`. |
| `InspectTree` | type alias | `Mapping[str, InspectNode]` — one sysfs directory inside an `InspectFullResult.tree`. |
| `InspectNode` | type alias | What a tree key maps to: a `str` attribute value, a nested directory dict, `{"_symlink": "<target>"}`, or `{"_binary": true}`. Modelled as `Any` so consumers need no runtime union check per key; the broker contract pins the four shapes. |
| `DumpChainResult` | frozen dataclass | Return of `Client.dump_chain()`: `urn`, `fidelity`, `captured_at_ms`, `chain` (tuple of `ChainEntry`), `truncated`. |
| `ChainEntry` | frozen dataclass | One rung: `urn`, `level` (`interface` / `device` / `hub` / `root_hub` / `controller` / `bridge` / `root_complex` — a closed, wire-pinned grammar), and a `summary` whose CONTENTS are diagnostic, not wire-pinned. |
| `DriverInfo` | frozen dataclass | Return of `Client.driver_info()`: `name`, `module`, `module_loaded`, `bound_count`, `sample_bound`, `sysfs_attrs`, plus the curated `ioctls` / `char_devices` / `uevent_actions` / `registry_version` (all `None` in v1) and `fidelity`. |
| `AuditRecord` | frozen dataclass | One row of `audit_tail`: `seq`, `ts_ms`, `duration_us`, `op`, `outcome`, `peer_uid`, `peer_pid`, `cap_id`, optional `urn` / `err` / `correlation_id`. |
| `AuditTailResult` | frozen dataclass | Return of `Client.audit_tail()`: `records` (newest first), `ring_capacity`, `earliest_ts_ms`. Carries `outcome_summary()`. |
| `StateSnapshot` | frozen dataclass | Return of `Client.state_snapshot()`: `ts_ms`, `uptime_ms`, `broker_version`, `ops_total`, `ops_per_second_1m`, `active_connections` / `_leases` / `_subscriptions`, `cap_table_size`, audit-ring fill + drops, `memory_rss_bytes`, `workers` (tuple of `WorkerSnapshot`), `fidelity`. |
| `WorkerSnapshot` | frozen dataclass | One worker inside a `StateSnapshot`: `scope_prefix`, `backend`, `mode`, `url_count`. |
| `Subscription` | frozen dataclass | Return of `Client.subscribe()`: `id` (pass it as `drain_events(subscription_id=…)`) and `queue_max`, the bounded queue size the broker actually granted. |

#### Honest enums (SDK-11 — since 0.1.10)

`Kind` is an **open registry** — the broker may know a kind this SDK
does not. When it sends one, `ResourceDetail.from_wire` reports
`Kind.UNKNOWN` and preserves the wire string on `kind_raw`; `family` /
`family_raw` work identically. Before 0.1.10 the fallbacks were
`Kind.PCIE_FUNCTION` and `Family.CLAIMABLE`, so a new hardware kind
would have rendered as a PCIe function in every consumer at once.

`*_raw is None` covers two different facts: the enum matched (including a
broker that itself says `"unknown"`, which IS a member), **or** the key
was absent from the wire row — an older broker that never sent it, so
there is no string to preserve. The one-directional reading is the useful
one: `kind_raw is not None` means precisely "the broker named a value
this SDK does not know". The intended render:

```python
label = f"{d.kind}" if d.kind_raw is None else f"{d.kind} ({d.kind_raw})"
```

#### Claim-mode lattice (Family A — FEEDBACK DOC-01)

`ClaimMode` is the Family-A compatibility lattice (Part 3.3 of the
design). The three modes name *what kind of access the caller wants*
and the lattice settles *what coexists with what* on a single resource:

| Mode | Wire value | Coexists with | Typical use |
|---|---|---|---|
| `ClaimMode.OBSERVE` | `"observe"` | Anything (observers never conflict) | Read-only watching: a monitor, a doctor, a diagnostic dashboard. |
| `ClaimMode.SHARED_RW` | `"shared_rw"` | Other `SHARED_RW` holders + `OBSERVE` | Cooperative shared access (rare on Family-A hardware; mostly Family-B). |
| `ClaimMode.EXCLUSIVE` | `"exclusive"` | Only `OBSERVE` | The canonical "I own this device" — VFIO passthrough, USB-interface claim, GPU compute reservation. |

The `Client.claim()` default is `EXCLUSIVE` — the safest assumption.
A common consumer error is guessing `SHARED` (wrong) when the intent
is `SHARED_RW` (right); these are `StrEnum` values, so importing
`ClaimMode.SHARED_RW` is the type-safe path.

Family B (apportionable — CPU shares, memory) replaces the lattice
with a **reservation-strength axis** (`Guaranteed` / `BestEffort` /
`Burstable`) that lives outside `ClaimMode`. The two families share
the URN namespace but not the mode vocabulary (Part 2.1 / Part 10).

### Typed configuration (CF-01)

| Name | Kind | Notes |
|---|---|---|
| `HrbSettings` | class | Subclass of `mgf.common.config.BaseAppSettings`. Carries `socket_path` (default `/tmp/mgf-hrb.sock`) and `default_deadline_ms` (default `5000`). Env-var prefix `MGF_HRB_`; precedence CLI > env > YAML > defaults (CF-02). Also importable as `mgf.hrb.config.HrbSettings`. |

### Identity-continuity comparator (FEEDBACK `MODEL-01`)

Python mirror of `hrb-code/src/identity.zig`; same algorithm + weights.

| Name | Kind | Notes |
|---|---|---|
| `Snapshot` | dataclass | Captured identity at a point in time. |
| `Confidence` | enum (Int) | `DIFFERENT` < `AMBIGUOUS` < `LIKELY_SAME` < `SAME`. |
| `ComparatorOpts` | dataclass | Per-call tunables; defaults match the iFig validation. |
| `Contribution` | dataclass | One scored signal. |
| `ScoreResult` | dataclass | The comparator's full reasoning. |
| `same_likely` | function | Lightweight: → `Confidence`. |
| `same_likely_explained` | function | Full reasoning: → `ScoreResult`. |
| `MAX_PORT_DEPTH` | int constant | `8` — mirrors `identity.zig`. A `Snapshot` built with a deeper port chain drops it rather than truncating (G2: refuse to lie, don't half-tell). |
| `MAX_SERIAL_BYTES` | int constant | `64` — mirrors `identity.zig`. An oversized serial reports as "no serial", not as a prefix. |
| `now_ms` | function | `() -> int` — monotonic milliseconds (matches `identity.zig`'s `nowMs`). Use it for `Snapshot.captured_at_ms` when capturing live; tests pass fixed values for determinism. |
| `snapshot_from_sysfs` | function | I/O helper (`mgf.hrb.sysfs`): reads sysfs USB-device attrs and returns a `Snapshot` ready for the comparator. Closes the SDK-07 "every consumer rewrites 30 lines of sysfs parsing" gap. Stdlib-only; pure I/O isolated from `identity.py`'s pure-logic invariant. |

### Computed properties on existing dataclasses (A2 — presentation-layer plan)

Convenience accessors that delegate to ``mgf.hrb.fmt``. The
medium-agnostic formatting decisions (uptime as ``"Hh Mm Ss"``,
RSS as binary prefix, audit-record duration with cascading units)
live in one place; CLI / Qt / future TUI / web consumers read the
same properties and never drift.

| Property | On | Returns | Notes |
|---|---|---|---|
| `uptime_human` | `StateSnapshot` | str | E.g. ``"1h 2m 5s"`` — delegates to ``fmt.format_uptime(self.uptime_ms)``. |
| `memory_rss_human` | `StateSnapshot` | str | E.g. ``"42.3 MiB"`` — binary prefix (KiB/MiB/GiB) to match Linux memory conventions. |
| `duration_human` | `AuditRecord` | str | E.g. ``"4.5ms"``, ``"1.2s"``, ``"5m12s"`` — cascading units; one decimal in the sub-minute bands. |

| Method | On | Returns | Notes |
|---|---|---|---|
| `outcome_summary()` | `AuditTailResult` | `AuditOutcomeSummary` | Tallies records by outcome bucket and picks the newest err. Exploits the broker's newest-first ordering — no full-list sort needed. |
| `ops_dict()` | `BackendCapability` | `dict[str, bool]` | Flattens the storage tuple-of-pairs to the flat dict shape consumers actually want. Returns a fresh dict per call (mutation OK). |

### Presentation-layer formatting helpers (`mgf.hrb.fmt` — A1, PR-06 reference impl)

Medium-agnostic primitives every UI consumer needs. Pure
functions, primitive-in / str-out, no I/O, no state, no logging.
Stdlib-only — CI-fenced by `pyproject.toml`'s `PR-06` import-linter
contract. The reference implementation of the upcoming Magogi
Foundation rule PR-06 (presentation-layer separation; see
`mgf-standard/docs/standards/PRESENTATION_LAYER.md`).

| Name | Kind | Signature | Notes |
|---|---|---|---|
| `format_uptime` | function | `(uptime_ms: int) -> str` | ``"Hh Mm Ss"`` / ``"Mm Ss"`` / ``"Ss"``. Truncates to whole seconds. Defensive on negative / non-int (returns ``"0s"``). |
| `format_duration` | function | `(duration_us: int) -> str` | Cascading units ``"Nµs"`` / ``"X.Yms"`` / ``"X.Ys"`` / ``"MmSs"``. Picks largest unit ≤4 integer digits. |
| `format_rss` | function | `(num_bytes: int) -> str` | Binary prefix ``"N B"`` / ``"X.Y KiB"`` / ``"X.Y MiB"`` / ``"X.Y GiB"`` / ``"X.Y TiB"``. Deliberately binary not decimal (matches Linux memory reporting). |
| `format_severity_glyph` | function | `(severity: Severity \| str) -> str` | Canonical 5-character glyph ``"  OK "`` / ``" WARN"`` / ``" FAIL"`` / ``" ... "``. Unknown values render ``"  ?  "``. Accepts enum OR string. |

---

## `mgf.hrb.cli` — the embedded CLI (DEPRECATED)

> **Deprecated (DOC-05).** The operator CLI has moved to **hrb-app**:
> `hrb-app cli scan` / `info` / `doctor` / `explain`. `mgf.hrb.cli` is
> RETAINED for one release cycle as a migration banner — invoking
> `mgf-hrb` prints the pointer, raises `DeprecationWarning`, and
> delegates to the old implementations. After `0.1.x` both the console
> script and the private subcommand modules are removed and `mgf-hrb`
> shrinks back to a pure SDK. Do not build on this surface.

Reachable as the `mgf-hrb` console script (declared in `pyproject.toml`),
or via `python -m mgf.hrb.cli`. Public surface is intentionally minimal —
the CLI is *behaviour*, not an importable library.

| Name | Kind | Notes |
|---|---|---|
| `main` | function | Entry point used by the console script. |
| `build_parser` | function | Argparse parser — exposed for testing the CLI's shape without invoking subcommands. |

The subcommand implementations (`_scan`, `_info`, `_doctor`) are
internal-only; consumers invoke them through the CLI, not by import.

---

## Stability commitment

`0.1.0` shipped on 2026-06-02; the surface is **published and in use**,
and the tier below is what `0.1.x` actually promises.

Throughout `0.1.x`:

- **Every name above is `experimental`** — it may move, rename, or be
  removed between MINORs. In practice the surface has only grown since
  `0.1.0`; treat `experimental` as "the maintainer is not yet bound",
  not as "expect churn".
- **The wire protocol** (the line-JSON broker dialect) is **also**
  `0.1.x`-tier. The SDK and the Zig server are versioned together; pin
  both, and read `mgf.hrb.__version__` rather than assuming.
- **Lease + raw channel semantics** (Part 3.2 / 3.8 — leases-bound-to-
  liveness, raw-channel-only-while-owner) are settled in the design and
  will not regress. These are the design's load-bearing claims.
- **Deprecations already announced** get the AP-04 cycle even pre-1.0:
  `mgf.hrb.cli` is the live example — banner + `DeprecationWarning`
  through `0.1.x`, removed after.

At `1.0` this file's tables become the compatibility contract and AP-04
applies to everything in them: announce → still works for ≥1 MINOR →
remove, with `DeprecationWarning` on every deprecated name.
