# 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) -> list[dict]` | non-blocking event drain | 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) pulls every backend-buffered event whose worker scope intersects `scope` AND fans them out to every active subscription. 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"`. |
| `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":[…]}}`. 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:/**", *, poll_interval_s=0.5) -> Iterator[dict]` | streaming events (sync) | Layer 1 of the three-layer event API (FEEDBACK SDK-01). Polls `drain_events` on a timer; yields each event as a dict. Each internal drain is bounded — see **The monitor pump** below. |
| `amonitor(scope="hw:/**", *, poll_interval_s=0.5) -> AsyncIterator[dict]` | streaming events (async) | Layer 2 — for asyncio / qasync consumers. Wraps `drain_events` with `asyncio.to_thread` + `asyncio.sleep`; same event shape and the same bound as `monitor()`. (FEEDBACK SDK-01.) |
| `monitor_fd(scope="hw:/**", *, poll_interval_s=0.5) -> int` | streaming events (fd) | Layer 3 — returns the read end of a self-pipe. A daemon thread on its own connection polls `drain_events` and writes each event as a newline-terminated JSON line. For Qt's `QSocketNotifier`, Gtk's `GLib.io_add_watch`, raw `select.poll()`. `Client.close()` tears down the thread + pipes. Only one active per Client. See **The monitor pump** below for the bound and the death contract. (FEEDBACK SDK-01.) |
| **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 pump (`monitor` / `amonitor` / `monitor_fd`)

The pump's internal `drain_events` is **never unbounded**. Its deadline
is `max(poll_interval_s * 1000 + 2000, deadline_ms or the client
default)` — an explicit `0` does NOT unbound it, because a wedged broker
would then wedge the event stream into total silence: no events, no
error, no EOF.

For `monitor_fd`, when the pump dies of a broker error (`MgfHrbError`,
including that `TimeoutError`) or an `OSError`, it writes exactly one
final line to the pipe 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.

### 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.
