# FEEDBACK — design-conversation log

> **Living document.** Same shape as the federation pattern in
> [`mgf-common/FEEDBACK.md`](../mgf-common/FEEDBACK.md). (The
> predecessor's `mgf-usb/FEEDBACK.md` link was removed 2026-05-27
> when the `mgf-usb` repository was deleted — see
> [`MIGRATION_FROM_MGF_USB.md`](MIGRATION_FROM_MGF_USB.md) for
> the supersession relationship.)
> Each entry is a uniform block: identifier, status badge, severity,
> structured frontmatter, then prose. Optimised for both human readers
> and AI agents.
>
> **The principle.** When you hit friction designing or implementing
> `mgf-hrb` — a missing primitive, a contract that doesn't quite fit a
> real bus, a design call you'd push back on — **file it here as a
> `<PREFIX>-NN` paper**, not as ad-hoc text. Triage produces an entry
> in [`DECISIONS.md`](DECISIONS.md), and the design itself improves.

---

## §1. Process

| Severity | Meaning |
|---|---|
| 🔴 **Blocker** | Public-contract or seam-shape decision; getting it wrong requires a breaking change later. |
| 🟡 **High** | Quality-of-life issue. Painful to live with; additive to add later. |
| 🟢 **Nice-to-have** | Real value, not urgent. |
| 💭 **Aspirational** | Long-term direction; not actionable today. |

| Status | Meaning |
|---|---|
| OPEN | Submitted, not yet triaged. |
| ACCEPTED | Maintainer agrees; not yet scheduled. |
| SCHEDULED | Assigned to a release/phase. |
| SHIPPED | Landed; one-line retrospective inline. |
| REJECTED | Kept (not deleted) so future visitors don't re-raise. |
| DEFERRED | Agreed in principle, picked up at the next roadmap review. |

### Area prefixes

| Prefix | Surface |
|---|---|
| `MODEL` | The resource algebra (`mgf-hrb-model`) — URN, fidelity, kinds, state |
| `CORE` | The lease engine and ownership truth |
| `CAP` | The capability engine |
| `KABI` | The kernel seam contract |
| `RT` | Transport, rings, fd-passing, liveness |
| `LINUX` | The Linux backend |
| `USB` | The `mgf-hrb-usb` worker (per-bus) |
| `PCI` | The `mgf-hrb-pcie` worker (per-bus) |
| `CPU` | The `mgf-hrb-cpu` worker (Family B) |
| `FACADE` | Router + supervisor |
| `SDK` | The Python SDK surface |
| `POLICY` | `mgf-hrb-policy` (meaning) |
| `TOOLS` | Reference clients (`mgf-hrb` doctor / reporter / mapper) |
| `DOC` | Documentation, recipes |
| `SEC` | Security posture (NOT vulnerabilities — those go to `SECURITY.md`) |
| `PERF` | Performance, latency budgets |
| `LOG` | Unified logging — the cross-language `LogRecord` contract (mgf-common OB-09/-10/-11). Often **outbound** to mgf-common, since mgf-hrb carries the Zig toolchain. |

### Entry template

```markdown
### `<AREA>-<NN>` — One-line title

| Field | Value |
|---|---|
| Status | OPEN / ACCEPTED / SCHEDULED / SHIPPED / REJECTED / DEFERRED |
| Severity | 🔴 / 🟡 / 🟢 / 💭 |
| Submitter | <Name or project> (commit `<sha>` if applicable) |
| Submitted | YYYY-MM-DD |
| Decision | pending / accepted / rejected (one line) |
| Decided on | YYYY-MM-DD or `—` |
| Scheduled for | release tag, or `—` |
| Shipped in | commit SHA + release tag, or `—` |

**Concern.** What's wrong or missing. One paragraph.

**Why it matters.** Why this affects real users / the design integrity.
One paragraph.

**Concrete evidence.** File paths, line numbers, real example where
the pain is observable today.

**Suggested shape.** Concrete proposal — code snippet, prose, or both.
Prefer "Approach A vs Approach B" framing when there is a real choice.

**Decision rationale.** *Filled when triaged.*

**Retrospective.** *Filled when shipped.*
```

---

## §2. Open feedback (active)

### `LOG-01` — mgf-common's Zig `LogRecord` reference emitter is written against pre-0.16 Zig (won't compile on the toolchain it's assigned to)

| Field | Value |
|---|---|
| Status | ADOPTED (mgf-common `37e2b01`) |
| Severity | 🟡 High |
| Submitter | mgf-hrb (commit pending; `/sync` to mgf-common 0.42.0) |
| Submitted | 2026-05-29 |
| Decision | adopted upstream |
| Decided on | 2026-05-30 |
| Scheduled for | — |
| Shipped in | mgf-common `37e2b01` (2026-05-30) |

**Context.** mgf-common 0.42.0 (unified logging, **OB-10**) ships a Zig reference emitter at `clients/zig/log_record.zig` and explicitly designates **mgf-hrb** as the federation member that compiles + pins it (`clients/README.md`: "⏳ in **mgf-hrb** CI (no Zig toolchain in mgf-common CI)"; "mgf-hrb vendors this module").

**Problem.** The upstream file does not compile under mgf-hrb's Zig **0.16** toolchain — its `toJsonlAlloc` + conformance test use the removed managed `std.ArrayList(u8).init(allocator)` / `.writer()` / `.toOwnedSlice()` API. Because mgf-common has no Zig toolchain, this never surfaced in mgf-common CI. So the artifact mgf-common designed as "compiled in mgf-hrb CI" could not actually be compiled as authored.

**What mgf-hrb did (this sync).** Vendored the emitter + golden fixture to `hrb-code/vendor/logrecord/` and ported the buffer plumbing to the Zig 0.16 `std.Io.Writer` API (`std.Io.Writer.Allocating` / `std.Io.Writer.fixed`). The **emit logic** (`writeJsonObject` + helpers, generic over `anytype`) is byte-for-byte identical to upstream; only the test/alloc harness changed. The vendored conformance test reproduces every golden fixture line and runs in mgf-hrb CI (`zig build test`).

**Recommendation (the outbound ask).** mgf-common should adopt the same 0.16 idiom in its canonical `clients/zig/log_record.zig` so the upstream source and the mgf-hrb vendored copy reconverge byte-for-byte, and so the file mgf-common publishes is the file that actually compiles where it's meant to. Pin the federation Zig toolchain version (0.16) in `clients/README.md` so future ports target it.

**Diff supplied (2026-05-30).** [`docs/outbound/LOG-01-mgf-common-zig016.patch`](docs/outbound/LOG-01-mgf-common-zig016.patch) — the two-hunk Zig port (`toJsonlAlloc` + the conformance test) plus the README Zig-0.16 pin, as a pure `git apply`-able diff against mgf-common `main`. **Verified**: against a pristine copy of mgf-common's `clients/zig/` tree the unpatched file fails to compile under Zig 0.16 (`std.ArrayList(u8) … has no member named 'init'`); with the patch applied it compiles and passes upstream's own conformance test ("reproduces every golden fixture line"). The emit logic is byte-for-byte unchanged. Provenance + apply instructions in [`docs/outbound/README.md`](docs/outbound/README.md). **Adopted by mgf-common in `37e2b01` (2026-05-30)** — the supervisor applied the patch, re-verified with Zig 0.16.0 (`zig test clients/zig/log_record.zig` → 1/1 OK), and committed it; the upstream and vendored copies now reconverge.

### `USB-01` — `mgf-hrb-usb` worker has no concrete design until now

| Field | Value |
|---|---|
| Status | ACCEPTED |
| Severity | 🔴 Blocker |
| Submitter | enrichment pass (drawn from `mgf-usb` v0.2.5 lessons) |
| Submitted | 2026-05-23 |
| Decision | accepted — write the worker design |
| Decided on | 2026-05-23 |
| Scheduled for | this enrichment pass |
| Shipped in | `hrb-design/mgf-hrb-usb-worker.md` |

**Concern.** The complete design names a `mgf-hrb-usb` worker in
several places (Part 7's process model, Part 9's family map, the URN
tree) but never *designs* it: there is no URN translation rule from
`(bus, port_chain, interface)` to a kabi URN, no choice of substrate
(sysfs read vs. usbfs ioctls vs. netlink events), no statement of how
composite USB devices (with `bDeviceClass=0x00 PER_INTERFACE`) are
enumerated as per-interface resources, and no specification of which
typed ops the USB kind exposes against `OpsTable`.

**Why it matters.** Without that shape, two things break:

1. `mgf-hrb-usb` cannot be coded — the seam ops have no mapping.
2. The wider design's claim that the kabi seam is *concrete enough to
   be a kernel spec* is unfalsifiable — without a second worker
   exercising it, the seam has only been pressure-tested against PCI.

**Concrete evidence.** `hrb-code/src/server.zig:34` initialises a
constellation with one PCI worker only; the `hw:/usb/...` URN appears
in design prose but not in any worker code or any seam-conformance
test.

The working `mgf-usb` library at the repo root encodes the answers —
in Python, against real Linux interfaces, on v0.2.5: backend chain,
composite-interface model, identity continuity, the
`mgf.usb.control.*` typed verbs (reset, bind/unbind per-interface,
set_authorized, set_port_power, set_autosuspend), event synthesis,
recipes. The HRB USB worker design should *port* those answers into
HRB's seam shape.

**Suggested shape.** A new design doc
`hrb-design/mgf-hrb-usb-worker.md` covering:

- URN translation: `hw:/usb/bus<N>/port-<chain>/if<K>` with the
  stability ladder cited explicitly (topological default; intrinsic
  via `serial_number` when present; synthetic-pinned for the rare
  no-serial passthrough case).
- Substrate matrix: sysfs (read), usbfs ioctls (acquire / reset /
  raw), netlink uevent socket (`watch`) — exactly the
  pyudev → libusb → lsusb fallback chain that `mgf-usb` learned,
  ported.
- Composite-device handling: enumerate emits one `Resource` per
  *interface*, not per device. The bus tree carries the device as a
  parent node.
- Identity continuity: a runtime comparator (cousin of `mgf-usb`'s
  `is_likely_same_device`) for the re-enumeration / DFU case.
- Per-interface typed ops: the `OpsTable` for `usb_interface` —
  `reset` (USBDEVFS_RESET), `bind_driver` / `unbind_driver`,
  `set_authorized`, `set_port_power` on the parent hub,
  `set_autosuspend`. Raw channel is the usbfs fd via SCM_RIGHTS.
- Survivability application: deadlines on every usbfs ioctl;
  `Quarantined` after a reset-unverified; the netlink overrun
  story.

**Decision rationale.** Accepted — written in this pass. The USB
worker is the next implementation increment after Phase-7 facade, and
the highest-leverage doc to add because every other open question
about a "real" bus depends on having an exemplar second worker beyond
PCI.

**Retrospective.** *Filed when shipped.*

---

### `MODEL-01` — Identity continuity across re-enumeration is not modelled

| Field | Value |
|---|---|
| Status | SHIPPED |
| Severity | 🟡 High |
| Submitter | enrichment pass |
| Submitted | 2026-05-23 |
| Decision | accepted — add comparator concept to the model |
| Decided on | 2026-05-23 |
| Scheduled for | this enrichment pass |
| Shipped in | `hrb-code/src/identity.zig` + `identity_test.zig` (commit subject: "Ship MODEL-01: runtime identity-continuity comparator (Snapshot + sameLikely)") |

**Concern.** Part 2.4 names the URN stability ladder (topological,
intrinsic, synthetic-pinned) and asserts honest identity-class
reporting. But during a runtime *re-enumeration* — a device drops off
the bus and comes back with a different `pid` and a vanished
`serial_number` (the canonical iFig DFU case mgf-usb solves) — the
model has no notion of "is this the same physical unit?" An observer
or a passthrough plan tracking the device across the transition has no
contract to lean on.

**Why it matters.** Real device passthrough flows (the vm-vmanager-app
pattern: pass a device, wait for the VM to release it, detach the
right plan even if other devices come and go) need the comparator.
Three consumers above the SDK would each reinvent the same heuristic
with slightly different bugs — exactly the mgf-usb experience.

**Concrete evidence.** `mgf-usb/src/mgf/usb/identity.py` ships
`snapshot_device` + `is_likely_same_device` returning a confidence tier
(`SAME` / `LIKELY_SAME` / `AMBIGUOUS` / `DIFFERENT`); the algorithm and
its hard case (iFig DFU: PID flips, serial vanishes, port stable) are
spec'd in `mgf-usb/docs/design/IDENTITY_HEURISTIC.md`. HRB's Part 2.4
recognises the *static* stability ladder; it does not yet have the
*runtime* comparator.

**Suggested shape.** Two additions:

1. A `Snapshot` type at the model layer — `(urn,
   identity_class, intrinsic_id?, parent_chain, captured_at)` —
   capturable from any `Resource`, backend-free, survives the device
   disappearing from the bus.
2. A `same_likely(snapshot, candidate) -> Confidence` comparator, in
   the model (algorithm) but tunable per-bus (the USB worker carries
   its own weights for `(vid, pid, port_chain, serial_number)`). The
   exact algorithm follows mgf-usb's per-dimension scoring.

**Decision rationale.** Accepted. The static ladder + the runtime
comparator together close the identity story. Modelled at the algebra
level (Part 2) so every bus can use it; weighted at the worker level
so the USB-specific signal mix lives where the signals are known.

**Retrospective.** Shipped as `mgf-hrb-model`'s `identity.zig`: the
`Snapshot` type captures a device's identity at a moment in time
(URN, identity-class, vid, pid, optional serial, bus + port-chain,
captured-at), and `sameLikely(a, b, opts)` returns
`{SAME, LIKELY_SAME, AMBIGUOUS, DIFFERENT}` per the weight table in
`hrb-design/mgf-hrb-usb-worker.md` §4.3. Test suite pins three real
sequences: iFig Recovery↔DFU = LIKELY_SAME (score 11), Normal→Recovery
with serial intact = SAME (15), identical = SAME (13); plus boundary
cases for serial-asymmetric, port-mismatch, debounce window, and the
"different iFig appeared mid-restore" case the recipe documents. The
`sameLikelyExplained` form returns the scored signals for tooling +
flaky-test root-causing. Per-bus weight tuning still lives in each
worker (D-014's mechanism/meaning split) — USB's defaults match the
table; PCI's will differ when it lands.

---

### `DOC-01` — `ClaimMode` lattice is undocumented in README + PUBLIC_API; consumers guess wrong names

| Field | Value |
|---|---|
| Status | SHIPPED |
| Severity | 🟢 Nice-to-have |
| Submitter | hrb-app (commit `622ab94`, Phase 2 chunk H) |
| Submitted | 2026-05-24 |
| Decision | accepted as proposed |
| Decided on | 2026-05-24 |
| Scheduled for | — |
| Shipped in | (this commit) |

**Concern.** `ClaimMode` has three members — `OBSERVE`, `SHARED_RW`, `EXCLUSIVE` (`mgf-hrb/src/mgf/hrb/domain.py`). The README's "Smallest useful program" sketches `c.list("hw:/**")` and mentions claiming but never enumerates the lattice. `PUBLIC_API.md`'s "Domain types" row lists `ClaimMode` but doesn't show the member names. Consumers reading either doc will guess. We guessed `ClaimMode.SHARED` (the obvious shortest name); first claim attempt died with `AttributeError: type object 'ClaimMode' has no attribute 'SHARED'. Did you mean: 'SHARED_RW'?`

Adjacent: the README example at `README.md:64-72` references `c.info(urn)` — a method that doesn't exist on `Client` (already filed as SDK-02; this paper is supplemental evidence that the README has accumulated drift overall).

**Why it matters.** Two costs:

1. **First-claim friction.** Every new consumer of `Client.claim()` hits the same `AttributeError` on first try. The fix is documentation, not code; the only reason to file rather than just patch the README is that the same fix should land alongside SDK-02's README correction so a single docs-pass closes both.
2. **Operator-facing implications.** `SHARED_RW` is the correct name because there's an implicit `SHARED_RO` axis (the `OBSERVE` mode IS read-only). Documenting the lattice forces the writer to clarify the semantic difference between `OBSERVE` and `SHARED_RW` — which is exactly the kind of thing GUI consumers need to surface in their UI (we currently render the menu items as "Observe (read-only)", "Claim (shared RW)", "Claim (exclusive)" — best-effort prose; an authoritative one-line semantic per mode in PUBLIC_API.md would let every consumer mirror the same labels).

**Concrete evidence.**

- `mgf-hrb/src/mgf/hrb/domain.py` — `class ClaimMode(StrEnum)` with `OBSERVE`, `SHARED_RW`, `EXCLUSIVE` members; class docstring says "Family A (Part 3.3)" and "Family B uses reservation-strength" but no per-member semantic.
- `mgf-hrb/README.md` — search for "ClaimMode" or "EXCLUSIVE" / "SHARED" / "OBSERVE": no matches.
- `mgf-hrb/PUBLIC_API.md` — `ClaimMode` named in the Domain Types table; member names absent.
- `hrb-app/src/hrb_app/ui/qt/main_window.py:235-245` — our menu labels: "Claim (exclusive)" / "Claim (shared RW)" / "Observe (read-only)". These are best-guesses; an authoritative source would let us copy.

**Suggested shape.** Two paragraphs in `PUBLIC_API.md` under "Domain types" (the right place — README should reference but not duplicate):

```markdown
### `ClaimMode` (StrEnum)

| Member       | Semantic                                                            |
|--------------|---------------------------------------------------------------------|
| `OBSERVE`    | Read-only access; does not exclude other claimants. Surfaces metadata + identity snapshots; no writes. |
| `SHARED_RW`  | Read/write access shared with other tenants that also hold a `SHARED_RW` claim. Used for cooperative-write scenarios (e.g., …). |
| `EXCLUSIVE`  | Sole writer; the broker refuses any concurrent claim of any mode for the same URN. |

The lattice is Family A (Part 3.3 of the design); Family B uses
a reservation-strength axis instead — see [identity.py](src/mgf/hrb/identity.py).
```

Plus one line in the README's "Smallest useful program" example showing a `claim` call with a named mode so the names appear at least once in the surface that new consumers actually read.

**Decision rationale.** *Filled when triaged.*

**Retrospective.** *Filled when shipped.*

---

### `TOOLS-02` — `mgf-hrb doctor` checks are CLI-only; no `Client.doctor()` surface for GUI/operator dashboards

| Field | Value |
|---|---|
| Status | SHIPPED |
| Severity | 🟡 High |
| Submitter | hrb-app (commit `f7b4390`, Phase 1.1 chunk D) |
| Submitted | 2026-05-24 |
| Decision | accepted — refactor-first (recommended path); engine landed in Python, not as a Zig wire op, since every check answers a CALLER-side question (which process can read /sys, which user is in plugdev, …) — see retrospective. |
| Decided on | 2026-05-24 |
| Scheduled for | — |
| Shipped in | (this commit) |

**Retrospective (2026-05-24).** Shipped as a Python `mgf.hrb.diagnostics` module rather than a Zig wire op. The paper's suggested wire-op shape (`op="doctor"` runs Zig-side checks) turned out to be the wrong layer: of the seven checks the engine runs, six are CALLER-perspective questions (can THIS process reach the socket, can THIS user read sysfs, does THIS host have IOMMU populated) that the broker can't answer. Only `BACKEND_CAPABILITIES` needs broker introspection, and that already had a wire op (`op="capabilities"`). So the cleanest shape is a Python engine the CLI and SDK both consume — the *engine* lives in one place, both surfaces render from the same `DoctorFinding` stream, and the deferred-by-design rows (udev rule, etc.) are honest at one site. `Client.doctor(socket_path=None) -> list[DoctorFinding]` returns the same findings the CLI prints; `mgf.hrb.diagnostics.run_all(socket_path, *, client=None)` is the lower-level entry point for callers without a connection. Each finding carries a stable `code` (`SOCKET_REACHABLE`, `IOMMU_PRESENT`, `BACKEND_CAPABILITIES`, etc.) so GUI consumers can pattern-match without parsing prose.

**Concern.** The `mgf-hrb doctor` CLI command (`cli/doctor.py`-shaped) runs broker preconditions — socket reachable, IOMMU presence, sysfs mounted, backends discovered — and prints structured findings to stdout. The same checks are not exposed on `Client` (no `Client.doctor()`, no `Client.diagnostics()`, no `DoctorFinding` type). A GUI consumer that wants a "doctor" pane has three options:

1. Shell out to `mgf-hrb doctor` and parse stdout (fragile; contradicts the brokerage premise — the GUI should talk to the broker, not bypass it).
2. Re-implement the checks in the GUI (duplicates logic that lives in `cli/doctor.py`; drifts over time).
3. Render only what `Client.capabilities()` returns (covers backend discovery but misses IOMMU/sysfs/socket checks).

hrb-app's Phase 1.1 "capabilities pane" picked (3) and is explicitly labelled "doctor-lite" in our research note and CHANGELOG. That's honest, but it ships strictly less than the CLI offers. Any operator GUI hits the same gap.

**Why it matters.** A doctor pane is one of the two reasons a GUI exists at all (the other being the device tree + claim/release UX). It's the surface that tells the operator "the broker is healthy / something is wrong / here is what is wrong" before they start work. Without a `Client.doctor()` surface, that pane either ships with strictly degraded content (capabilities only — no precondition checks) or with a CLI-shell-out workaround that breaks the "consumers talk to the broker via Client" architectural rule.

The Zig side already runs these checks (the doctor CLI command invokes them); the gap is purely the wire op + SDK verb.

**Concrete evidence.**

- `mgf-hrb/src/mgf/hrb/cli/` — `mgf-hrb doctor` subcommand exists and prints findings.
- `grep -nE "def doctor|def diagnose|def check" mgf-hrb/src/mgf/hrb/_client.py` returns no matches.
- `hrb-app/docs/inprogress/PRESENTATION_LAYER_RESEARCH.md` §1 — "Doctor diagnostics (broker preconditions: socket, IOMMU, sysfs)" listed as a Phase 2 surface; row is currently fulfilled only by the capabilities-pane stand-in (`hrb-app/docs/inprogress/CHANGELOG.md` Phase 1.1 entry calls it "doctor-lite").

**Suggested shape.** Add a single read verb that returns the same findings the CLI prints, just structured:

```python
# mgf/hrb/_client.py — Client method
def doctor(self, *, deadline_ms: int = 0) -> list[DoctorFinding]: ...

@dataclass(frozen=True, slots=True)
class DoctorFinding:
    code: str                # stable identifier, e.g. "IOMMU_PRESENT" / "SOCKET_REACHABLE"
    severity: Literal["ok", "warn", "fail"]
    summary: str             # one-line human-readable
    detail: str | None = None  # multi-line, optional
    remediation: str | None = None
```

Wire op `op="doctor"` runs the same Zig-side check list; the CLI subcommand becomes a thin printer over the typed result (eliminating the duplicate-source-of-truth between CLI and any future GUI).

Two implementation orderings to consider:

- **(Refactor-first)** Pull the existing doctor checks out of the CLI module into a `doctor_engine.py` module the broker server invokes when `op="doctor"` arrives; CLI becomes the renderer. Cleaner long-term shape; cost is the refactor.
- **(Add-first)** Implement `Client.doctor()` end-to-end against a parallel check list, then deprecate the CLI's internal version in a follow-up. Faster to land; risks CLI/SDK drift during the bridging window.

Recommendation: (Refactor-first) because the drift cost compounds with every check added.

**Decision rationale.** *Filled when triaged.*

**Retrospective.** *Filled when shipped.*

---

### `TOOLS-01` — No `mgf-hrb doctor` analog yet

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | enrichment pass |
| Submitted | 2026-05-23 |
| Decision | accepted — schedule |
| Decided on | 2026-05-23 |
| Scheduled for | core checks shipped 2026-05-24; substrate-report rows shipped 2026-05-27 once USB worker Slice B claim path closed (R1) |
| Shipped in | (1) embedded CLI restructure (commit `05da7a1`) — `src/mgf/hrb/cli/_doctor.py`, `_scan.py`, `_info.py` behind the `mgf-hrb` console script. (2) R3 substrate rows (this commit) — `check_usbfs_devnode` + `check_usbfs_openable` in `src/mgf/hrb/diagnostics.py` add the per-worker substrate health that the paper called for. |

**Concern.** Operators bringing up `mgf-hrb` on a new host will hit a
predictable set of pre-conditions: no `/dev/vfio`, no IOMMU, kernel
without `vfio` modules, a missing udev rule, an SELinux/AppArmor block.
The current tooling is the broker server alone — when it doesn't work,
the operator has nothing to introspect.

**Why it matters.** mgf-usb's `mgf-usb doctor` is the most-praised
operator surface in that library and absorbs ~80% of bring-up
support cost. HRB will face the same shape with stricter privileges.

**Concrete evidence.** mgf-usb ships `mgf-usb doctor` reporting:
backend selected, why each alternative was skipped, capabilities,
udev rule installed?, user in `plugdev` group?, with exit-code
contract. HRB has none of this. `hrb-code/src/server.zig` prints a
hand-rolled `"mgf-hrb broker listening on …"`.

**Suggested shape.** `mgf-hrb doctor` (Zig binary, reuses the kabi /
facade init paths read-only):

- IOMMU present + active? (the S2/D9 check made operator-visible)
- `/dev/vfio` present + readable?
- Each worker's substrate health (PCI: `/sys/bus/pci/devices`
  readable; USB: `/sys/bus/usb/devices` + `/dev/bus/usb/*`)
- `mgf-hrb-usb.rules` udev rule installed?
- Caller member of the privileged group?
- Capability table seeded? (root cap mint succeeded)
- `BackendCapabilities` report (per-bus) — which seam ops are
  supported by the current substrate, at which latency tier

Plus `mgf-hrb scan` (enumerate via the facade, render the URN tree)
and `mgf-hrb info <urn>` (one-resource detail with fidelity tags
visible) to mirror mgf-usb's reference surface.

**Decision rationale.** Accepted, deferred until `mgf-hrb-usb` is in
code — `doctor` needs a second worker to be useful (PCI alone is
under-specified for the IOMMU-group story).

**Retrospective (2026-05-24).** Core operator surface landed earlier
than the deferral implied. The embedded-CLI restructure (commit
`05da7a1`) gave us a natural home for the three subcommands without
waiting on a separate `mgf-hrb-tools` sibling: `mgf-hrb scan`,
`mgf-hrb info <urn>`, and `mgf-hrb doctor` all live under
`src/mgf/hrb/cli/` behind the `mgf-hrb` console script. `_doctor.py`
runs the socket-reachable / sysfs-readable / IOMMU-group checks today
with the structured `Fix` rendering the paper called for. The
substrate-report rows (per-bus seam-op support + latency tier from
`BackendCapabilities`) remain deferred to when the USB worker codes —
they're still under-specified with PCI alone, as predicted. The
sub-package was named so it extracts cleanly later if `mgf-hrb-tools`
ever materialises as its own sibling.

**Closure (R3, 2026-05-27).** The deferred substrate-report rows
shipped with the USB worker's Slice B closure (R1, commit
`a07c1cf`). Two new checks in `src/mgf/hrb/diagnostics.py`:
`check_usbfs_devnode` (USBFS_DEVNODE_PRESENT) reports on the
`/dev/bus/usb` tree the worker opens during claim, distinct from
the existing `check_usb_sysfs` which only covers the enumeration
surface; `check_usbfs_openable` (USBFS_DEVNODE_OPENABLE) probes
`open(O_RDWR)` against the first device node so an operator
sees the EACCES → "install udev rule" remediation at bring-up
time rather than at first claim. The doctor code set went from
7 → 9 (additive); call-site test pins
(`test_gui_doctor_pane.py`, `test_e2e.py`, `test_client.py`)
were updated in lockstep. The udev-rule check itself
(`UDEV_RULE_INSTALLED`) remains DEFERRED — its template still
hasn't shipped from the worker packaging side; closure of that
row is now the only outstanding bit of `TOOLS-01`'s original
suggested shape and tracked in `ROADMAP.md` under R-USB-PKG
(packaging-gated, no rush).

---

### `SDK-06` — Hello-handshake protocol is undocumented in PUBLIC_API; test fakes hang silently on `Client.connect`

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | hrb-app (Phase 3 SDK-adoption pass; `/home/bassam/PycharmProjects/hrb_app`) |
| Submitted | 2026-05-25 |
| Decision | ACCEPTED 2026-05-26 — paper's suggested shape adopted verbatim |
| Decided on | 2026-05-26 |
| Scheduled for | mgf-hrb 0.1.x |
| Shipped in | commit `f9c82c6` (2026-05-26) |

**Concern.** The Tier-0 #2 hello handshake (commit `5003d5c`) made `Client.connect(path)` a *protocol* call, not just a socket-open: the broker MUST send `{"ok": true, "hello": "mgf-hrb", "cap_id": <int>}\n` immediately on accept, and `Client.connect` blocks reading that line before returning. This is correct security work (random cap-ids close U3 forgery — agreed). Two consumer-facing gaps surfaced when hrb-app re-ran its integration suite against the 0.39-era SDK:

1. **`Client.connect` has no client-side timeout.** A peer that completes TCP `accept` but never writes the hello line causes `connect()` to block indefinitely (specifically, in `_Transport._recv_line` waiting for the newline). The only timeout that fires today is whatever the OS imposes on `recv()` — none, by default. A misbehaving / partially-implemented / DoS-shaped broker can wedge any consumer.

2. **The hello wire format is undocumented in PUBLIC_API.md.** The `Client.connect` row at `PUBLIC_API.md:37` says: *"Open a Unix-socket connection to the broker at `path`. The first connection from this peer binds `cap_id` to the kernel-attested peer (Tier-0 #2 — SO_PEERCRED)."* No mention of the hello bytes. The signature shown still includes a `cap_id=1` kwarg (`connect(path, *, cap_id=1, client_id=1)`) that the actual code removed — `_client.py:299` is `def connect(cls, path: str, *, client_id: int = 1) -> Client:`. So the contract row is stale on two axes.

The docstring at `_client.py:300-310` does mention "hello message carrying that cap_id" and references Part 12 §3.3 — that's helpful for source-readers. PUBLIC_API.md is the surface most consumers will read first.

**Why it matters.** hrb-app's `tests/integration/test_launch.py` drives `main()` against an in-process `FakeBrokerServer` (reverse-engineered from the wire-protocol code in `_client.py:60-99`, called out in the file's docstring as "we'll hear about it via this test failing — that's the point"). When the SDK grew the handshake, the integration test stopped failing noisily — it just *hung* until the test runner timed out. The same shape will bite any of:

- Consumer-side test fakes (us, future GUI/web/CLI consumers writing their own).
- Tooling that connects from non-mgf-hrb processes (network bridges, telemetry shims).
- Anything that catches `socket.timeout` from a partially-completed broker accept.

**Concrete evidence.**

- `mgf-hrb/src/mgf/hrb/_client.py:298-345` — the new connect() reads `hello_line` via `_recv_line()` with no timeout. Compare to `claim()` at line 554 which accepts `deadline_ms` per the S2 deadlines contract.
- `mgf-hrb/PUBLIC_API.md:37` — outdated signature + no handshake mention.
- `hrb-app/tests/integration/fake_broker.py:115-141` — our fake's `_handle_connection` only writes a response *after* it reads a request. It never volunteers a hello. The test hangs on `Client.connect`'s recv before our fake even sees a byte. (We can fix our fake — see `Suggested shape` — but every future consumer with their own fake will hit the same wall.)
- `hrb-app` session 2026-05-25: full SDK adoption pass would have landed 12 ⏳→✅ rows, but the integration tests started hanging. ~10 minutes to bisect to `Client.connect`'s hello-read.

**Suggested shape.** Two changes, both small:

- **(Client-side timeout)** `Client.connect(path, *, client_id=1, hello_deadline_ms=2000)` — pass the deadline down to `_recv_line` via `socket.settimeout` for the hello phase only (reset after the hello completes). `2000ms` is generous; the broker-side hello write is unconditional and synchronous. If the deadline trips, raise `MgfHrbError("broker hello not received within Xms")` — at least the consumer sees a typed failure instead of a hung process. Backward-compatible (kwarg-only, defaulted).

- **(Documentation)** Update `PUBLIC_API.md`'s `connect` row to:
  - Drop the stale `cap_id=1` kwarg from the shown signature.
  - Add a one-paragraph "Handshake" subsection that names the hello line shape (`{"ok": true, "hello": "mgf-hrb", "cap_id": <int>}\n`) and references Part 12 §3.3 for the rationale. Operators writing test fakes can copy the exact bytes from one place.

Both can ship in a single small commit.

**Cost if deferred past v1.0.** Low for the timeout (the hang is only a real DoS in adversarial setups), but compounding for the docs gap — every new consumer's first integration test will hit the same hang. The two-line PUBLIC_API addition is the highest-impact-lowest-cost item on this list.

**Decision rationale.** Suggested shape adopted verbatim — both halves are small + backward-compatible, and the paper's submitter is the consumer that hit the hang. No design space to explore.

**Retrospective.** Shipped in `f9c82c6`. The fix landed exactly as the paper specified:

- `Client.connect(path, *, client_id=1, hello_deadline_ms=2000)` — kwarg-only, backward-compatible. `s.settimeout(hello_deadline_ms / 1000.0)` before the hello recv; restored to blocking mode after success so subsequent ops use their own `deadline_ms` (regression-pinned by `test_successful_hello_then_socket_returns_to_blocking`).
- Timeout surfaces as `MgfHrbError("broker hello not received within Xms (path=...); broker may be wedged or speak no hello")` with the underlying `TimeoutError` chained via `__cause__` per EH-03. The mechanism: J4's wrap converts socket `TimeoutError` (an `OSError` subclass since 3.10) to `MgfHrbError` at the recv site; the connect-site checks `__cause__` and surfaces the precise SDK-06 message instead of letting J4's generic "broker connection lost" text propagate.
- `PUBLIC_API.md`: dropped the stale `cap_id=1` kwarg from the connect() signature, added a new "Handshake (Part 12 §3.3 — Tier-0 #2; SDK-06)" subsection documenting the wire format + the test-fake requirement + a paste-able fake skeleton.
- 3 regression pins in `tests/unit/mgf/hrb/test_sdk06_hello_deadline.py`: silent peer trips the default 2000ms; 200ms kwarg honored (proves plumbing); socket back in blocking mode after a successful hello (catches a future forgotten `settimeout(None)`).

Cost delivered: ~50 LOC in `_client.py`, +3 tests, +1 PUBLIC_API subsection. The friction the paper described is now structurally impossible in the SDK; consumer-side test fakes that don't volunteer the hello get a typed failure within 2 seconds instead of hanging until pytest's timeout.

---

### `SDK-05` — `Client.capabilities()` returns `list[dict[str, Any]]`; every consumer reimplements the same typed projection

| Field | Value |
|---|---|
| Status | SHIPPED |
| Severity | 🟢 Nice-to-have |
| Submitter | hrb-app (commit `f7b4390`, Phase 1.1 chunk D) |
| Submitted | 2026-05-24 |
| Decision | accepted — typed projection at the SDK seam; raw dict shape preserved via `capabilities_raw()` escape hatch for callers that need un-projected fields. |
| Decided on | 2026-05-24 |
| Scheduled for | — |
| Shipped in | (this commit) — paired with SDK-03 |

**Concern.** `Client.capabilities()` is annotated as `CapabilitiesReport` (`_client.py:166`) but the alias resolves to `list[dict[str, Any]]`. Every consumer that wants to render this — the doctor-lite pane in hrb-app, `mgf-hrb doctor`'s `--capabilities` output, any future operator dashboard — has to (a) duplicate the field names (`scope_prefix`, `backend_name`, `resource_count`) from the docstring as constants, (b) coerce types defensively (`str(row.get(...))`), and (c) tolerate the SDK adding fields silently. The "first one to add a new field" problem already lives in the docstring: `_client.py:181` says "Future iterations will extend each entry with per-op support flags, operating mode (full / read_only / degraded_no_iommu / degraded_other), and declared latency tiers per op." When that ships, every consumer's projection layer breaks (or worse, silently drops the new fields).

**Why it matters.** `mgf-hrb` already has `Resource`, `ClaimMode`, `Snapshot` etc. as frozen dataclasses across the boundary (PUBLIC_API.md "Domain types" — DP-03 in the federation standards). `capabilities()` is the one read verb that returns a loose dict. It's an inconsistency in the SDK's own shape: typed everywhere else, untyped here. The cost is per-consumer-per-bump: when the SDK grows the row shape, every consumer audits its projection layer.

**Concrete evidence.**

- `mgf-hrb/src/mgf/hrb/_client.py:166-185` — `def capabilities(self) -> CapabilitiesReport: ...` where `CapabilitiesReport = list[dict[str, Any]]`.
- `hrb-app/src/hrb_app/core/broker.py:88-106` — `BrokerFacade.list_capabilities()` reads `row.get("scope_prefix", "")` / `row.get("backend_name", "")` / `int(row.get("resource_count", 0))` defensively. The field names are hard-coded from the SDK docstring; the type coercion is duplicated work.
- `hrb-app/src/hrb_app/core/events.py:14-30` — `CapabilityRow` frozen dataclass we had to define ourselves to keep DP-03 in the GUI seam.

**Suggested shape.** Promote the row to a public frozen dataclass:

```python
# mgf/hrb/domain.py (or a new mgf/hrb/capabilities.py)
@dataclass(frozen=True, slots=True)
class BackendCapability:
    scope_prefix: str
    backend_name: str
    resource_count: int
    # Forward-compatible: future fields land as Optional/default-bearing
    # so adding them doesn't break older consumers.
    operating_mode: str | None = None    # full / read_only / degraded_*
    supported_ops: tuple[str, ...] = ()  # per-op flags (claim, release, raw, …)
    latency_tiers: Mapping[str, str] | None = None

# mgf/hrb/_client.py — Client method
def capabilities(self) -> list[BackendCapability]: ...
```

Wire-side unchanged; only the projection layer in `_Transport.call` → `BackendCapability(...)` is new. Backward compatibility for consumers that already worked with dicts is trivial: the dataclass can grow a `__getitem__` shim during the deprecation window, or we ship the typed return AND keep `capabilities_raw()` returning `list[dict]` for one MINOR.

**Decision rationale.** *Filled when triaged.*

**Retrospective.** *Filled when shipped.*

---

### `SDK-04` — `Lease` is not exported from `mgf.hrb`'s public surface

| Field | Value |
|---|---|
| Status | SHIPPED |
| Severity | 🟢 Nice-to-have |
| Submitter | hrb-app (commit `622ab94`, Phase 2 chunk H) |
| Submitted | 2026-05-24 |
| Decision | accepted as proposed (one-line `__all__` fix) |
| Decided on | 2026-05-24 |
| Scheduled for | — |
| Shipped in | (this commit) |

**Concern.** `Client.claim(...)` returns a `Lease` and the type is documented in `PUBLIC_API.md` and the Client docstring. But `Lease` itself is not in `mgf.hrb.__init__.__all__` — it lives at `mgf.hrb._client.Lease` (an internal module, AP-14 says consumers MUST NOT import it). The result: a consumer that wants to type-annotate "I hold a Lease for later release" has three options:

1. Import from `mgf.hrb._client` (violates AP-14).
2. Type-erase to `Any` / `object` (loses strict typing).
3. Define a structural Protocol locally (works, but every consumer reimplements the same Protocol).

We picked (3) (`hrb-app/src/hrb_app/core/broker.py:34-49` defines `LeaseHandle`). It works, but the Protocol-per-consumer pattern doesn't scale — every consumer of `Client.claim()` will write the same five-line Protocol or quietly break AP-14.

**Why it matters.** `Lease` is part of the *return-type contract* of `Client.claim()`. Withholding the name from `__all__` while documenting it in PUBLIC_API.md is inconsistent: the docs say "this is the public type"; the package says "you can't import it." Consumers that try to type-check honestly hit this within five minutes of building anything that holds leases (lease pane, lease watcher, RAII wrapper). It's not blocking — workarounds exist — but it's a recurring papercut, and the fix is a one-line `__all__` addition.

**Concrete evidence.**

- `mgf-hrb/src/mgf/hrb/__init__.py:50-81` — `__all__` lists `Client`, `ClaimMode`, exception types, identity types, `Resource` — but no `Lease`.
- `mgf-hrb/PUBLIC_API.md` — search for "Lease": no mention in §"Entry point" though `Client.claim()` is documented and the return type is `Lease`.
- `hrb-app/src/hrb_app/core/broker.py:33-49` — the LeaseHandle Protocol workaround, with a comment explaining the federation-gap that prompted it. AP-14 explicitly cited.

**Suggested shape.** One-line change:

```python
# mgf/hrb/__init__.py
from mgf.hrb._client import Client, Lease   # add Lease

__all__ = [
    # ... existing entries ...
    "Lease",
]
```

When this lands, our LeaseHandle Protocol becomes a single-line alias (`LeaseHandle = mgf.hrb.Lease`); no call sites change.

(There's a small adjacent question — does `Lease` belong at `mgf.hrb._client.Lease` or `mgf.hrb.domain.Lease` for namespace hygiene? Either is fine for consumers as long as `mgf.hrb.Lease` works.)

**Decision rationale.** *Filled when triaged.*

**Retrospective.** *Filled when shipped.*

---

### `SDK-03` — `Client` has no `list_leases()` — GUI cannot show what other operators are holding

| Field | Value |
|---|---|
| Status | SHIPPED |
| Severity | 🟡 High |
| Submitter | hrb-app (commit pending; see `/home/bassam/PycharmProjects/hrb_app` Phase 1.1) |
| Submitted | 2026-05-24 |
| Decision | accepted — shipped the (Cheaper) variant from the paper: a single read verb + `LeaseRecord` frozen dataclass. The (More thorough) lease-change event stream lands naturally with SDK-01's event-API wave. |
| Decided on | 2026-05-24 |
| Scheduled for | — |
| Shipped in | (this commit) — paired with SDK-05 |

**Concern.** `mgf.hrb.Client` exposes `claim(urn, mode, ...) -> Lease` and `release(urn, lease_id, ...) -> None` but no read verb for "what leases currently exist." A GUI consumer (hrb-app's Phase 2 lease pane) needs to render the broker's full lease state — who holds what URN, in what claim mode, since when — so a human operator can see, before they click `claim`, whether the resource is already held by another tenant.

**Why it matters.** The federation's multi-operator UX rule (see `hrb-app/docs/inprogress/PRESENTATION_LAYER_RESEARCH.md` §0) says the broker is the single source of truth and the GUI is one of N concurrent clients. Without `list_leases()` the GUI can only show *its own* leases (the `Lease` objects it constructed), not the global state. The user is left to guess whether their pending `claim` will succeed; when it loses the race they see a generic `ConflictError` with no context. This is the exact UX gap multi-operator was supposed to close.

`hrb-app` currently works around it by inferring "this URN is busy" only after a `claim` returns `ConflictError`. That's a strictly worse UX than mgf-hrb's own CLI achieves — `mgf-hrb scan` *could* annotate held URNs if the SDK exposed the data — and locks GUI consumers out of the surface that would make the broker's multi-tenant model visible.

**Concrete evidence.** `mgf-hrb/src/mgf/hrb/_client.py:122-241` — `Client` class. Verbs present: `connect`, `list`, `capabilities`, `claim`, `release`, `raw`, `close`. Verbs absent for leases: any read accessor. The Zig side already maintains a lease table (it has to, to enforce capability + holder checks at `claim` time); the data exists, the wire op is missing.

**Suggested shape.** Two options, ordered impact-to-effort:

- **(Cheaper)** Add a single read verb:

  ```python
  def list_leases(self, scope: str = "hw:/**", *, deadline_ms: int = 0) -> list[LeaseRecord]: ...

  @dataclass(frozen=True, slots=True)
  class LeaseRecord:
      urn: str
      lease_id: int
      mode: ClaimMode
      holder: int          # client_id, matching Client._id
      granted_at_ms: int    # wire-side monotonic clock
  ```
  Wire op `op="list_leases"`, scope-filtered like `list()`. `LeaseRecord` is a public frozen dataclass (DP-03), additive to `__all__`. Reuses the same trust-gate path as the other read verbs.

- **(More thorough)** Pair with `SDK-01`: include lease-change events (`LeaseGranted` / `LeaseReleased` / `LeaseRevoked`) in the subscription stream so GUIs can update incrementally instead of polling `list_leases()` every cycle. Strictly additive to SDK-01's `Event` union; doesn't change the iterator shape.

(Cheaper) ships immediately and unblocks `hrb-app`'s lease pane. (More thorough) lands naturally as part of SDK-01's wave.

**Cost if deferred past v1.0.** Medium-high. The lease pane is the central reason a GUI exists at all (operator wants to see who holds what before claiming); without `list_leases()`, hrb-app's Phase 2 either ships with a strictly degraded UX or doesn't ship at all. Workarounds (polling `claim` with a tentative mode and immediate release) are racy and increase broker load.

**Decision rationale.** *(Filled when triaged.)*

**Retrospective.** *(Filled when shipped.)*

---

### `SDK-02` — `Client.info(urn)` is referenced in README but not implemented; `raw()` requires a held lease

| Field | Value |
|---|---|
| Status | SHIPPED |
| Severity | 🟢 Nice-to-have |
| Submitter | hrb-app (commit pending; see `/home/bassam/PycharmProjects/hrb_app` Phase 1.1) |
| Submitted | 2026-05-24 |
| Decision | accepted — chose option (Add `info()`, moderate), renamed to `inspect()`. README + PUBLIC_API updated. |
| Decided on | 2026-05-24 |
| Scheduled for | — |
| Shipped in | `inspect()` verb shipped in commit `1f44708` (USB #2 — 2026-05-24); README + PUBLIC_API docs updated in `bc89e0e`; typed `ResourceDetail` return + `inspect_raw()` escape hatch shipped in `43ef11b` (2026-05-25). |

**Retrospective addendum (2026-05-25, `43ef11b`).** Following the SDK-05 pattern, `Client.inspect()` now returns a typed `ResourceDetail` frozen dataclass with `urn` / `family` / `kind` / `fidelity` / `attrs` (sorted `(key, value)` tuple). Convenience accessor `detail.attr(key)` for single-key lookups; `dict(detail.attrs)` for full dict semantics. Forward-compat fields `parent` and `state` are declared optional so a newer broker can grow them without breaking older consumers. The raw dict shape is preserved on `Client.inspect_raw()` (consumed today by the CLI info renderer; available for any reader that needs un-projected fields). Defensive coercion at the seam (G2): unknown enum values fall back to per-field defaults, missing keys land on sensible defaults, non-string attr values are `str()`-coerced rather than raising. Pinned by 7 dedicated `ResourceDetail` tests in `tests/unit/mgf/hrb/test_domain.py` (plus 7 more rounding out coverage on `BackendCapability` and `LeaseRecord` retrospectively), 4 tests in `test_client.py` for the typed and raw paths, and an updated e2e check against the live broker.

**Concern.** `mgf-hrb/README.md` line 70 sketches a consumer pattern `c.info(urn)` ("Smallest useful program" section adjacent) but `mgf.hrb.Client` has no `info()` method. The nearest thing is `raw(urn, lease, *, deadline_ms=0) -> dict[str, Any]` (`_client.py:218`) which requires the caller to first `claim()` a lease — i.e., it's a write-shaped op, not a peek. A GUI selecting a row in the device tree wants to display metadata about the URN (vendor, driver bound, IOMMU group, present/absent) *without* claiming it; that's a read verb that doesn't exist.

**Why it matters.** Two distinct pain points:

1. **The README is wrong.** A consumer following the README writes `c.info(...)` and gets `AttributeError`. Whichever resolution is chosen below, the README needs to flip in lockstep.
2. **GUIs need a peek verb.** Without one, the per-device info pane (a standard sysadmin-UI feature) either (a) doesn't exist, (b) is implemented by claiming + immediately releasing, which is racy and pollutes the lease table, or (c) is implemented out-of-band by hrb-app shelling to system tools (lspci, lsusb) — which contradicts the brokerage premise.

**Concrete evidence.**

- `mgf-hrb/README.md:64-72` shows `c.info(urn)` adjacent to the Client example. `grep -n "def info\|def metadata\|def describe" mgf-hrb/src/mgf/hrb/_client.py` returns no matches.
- `hrb-app/docs/inprogress/PRESENTATION_LAYER_RESEARCH.md` §1 lists "Per-device info pane" as a Phase 2 surface; the table says source = `c.info(urn)`. That row is now load-bearing on this paper.

**Suggested shape.** Three options:

- **(Documentation-only, fastest)** Fix the README to use `raw()` with a sample `claim` + `release` and explicitly state "metadata-peek without claim is not currently supported; track SDK-02." Lowest effort; doesn't unlock the GUI pane.
- **(Add `info()`, moderate)** New read verb:
  ```python
  def info(self, urn: str, *, deadline_ms: int = 0) -> ResourceInfo: ...

  @dataclass(frozen=True, slots=True)
  class ResourceInfo:
      urn: str
      backend: str               # e.g., "mgf-hrb-linux"
      identity: Snapshot          # the existing identity-snapshot
      bound_driver: str | None
      iommu_group: int | None
      claimable: bool             # broker's current best guess (advisory)
      attrs: dict[str, str]       # backend-defined, opaque to SDK
  ```
  Wire op `op="info"`. The data is already what `mgf-hrb scan` renders in its per-row detail; the wire op just exposes the same data.
- **(Make `raw()` no-lease-friendly, narrow)** Allow `raw(urn, lease=0, ...)` to mean "no lease; metadata only" and have the broker return only the fields safe to expose without a capability. Narrower change but conflates "raw access" with "metadata peek" — semantically muddier than (Add info).

Recommendation: (Documentation-only) ships in the next release regardless; (Add `info()`) is the right long-term answer.

**Cost if deferred past v1.0.** Low if the README is fixed; rises sharply if both the README stays wrong AND `info()` is absent — every new consumer hits the same `AttributeError` and writes the same paper.

**Decision rationale.** *(Filled when triaged.)*

**Retrospective.** *(Filled when shipped.)*

---

### `SDK-01` — Three-layer event API for the Python SDK (fd / sync / async)

| Field | Value |
|---|---|
| Status | SHIPPED |
| Severity | 🟡 High |
| Submitter | enrichment pass |
| Submitted | 2026-05-23 |
| Decision | accepted; design folded 2026-05-23, code shipped 2026-05-25 |
| Decided on | 2026-05-23 |
| Scheduled for | — |
| Shipped in | Design refinement: `hrb-design/mgf-hrb-complete-design.md` Part 8.3. Code: (this commit) — `Client.monitor()` (sync) + `Client.amonitor()` (async iterator) + `Client.monitor_fd()` (pipe fd for Qt/Gtk/select). |

**Retrospective (2026-05-25).** Three layers shipped together over the existing `drain_events` polling primitive. `monitor()` (already existed) is the sync polling iterator. `amonitor()` wraps `drain_events` with `asyncio.to_thread` + `asyncio.sleep` — for asyncio / qasync consumers (the hrb-app GUI path). `monitor_fd()` spawns a daemon thread that polls drain_events and writes events as newline-terminated JSON lines to a self-pipe, returning the read end — for Qt's `QSocketNotifier`, Gtk's `Glib.IOAdd watch`, raw `select.poll()`, etc.

Prerequisite that fell out of the implementation: `_Transport.call` is now serialized by an internal lock, so multiple threads / coroutines can share a single `Client` instance safely. Caught a real broker constraint along the way — the serial-accept loop can't grant a second connection to the same process while the first is alive — so `monitor_fd` shares the main transport rather than opening its own (the lock keeps both threads correct; lock-hold is brief since `drain_events` is one roundtrip). When the broker grows a multi-client accept (a separate concurrency pass), the implementation can switch to a dedicated connection without changing the public API.

Lifecycle: `Client.close()` signals the monitor thread, joins it (bounded 2-second wait), and closes the pipe ends — a `with` block cleans up automatically. Only one monitor_fd per Client at a time; double-spawn raises `RuntimeError`. Pinned by 5 new unit tests in `tests/unit/mgf/hrb/test_client.py` (sync generator shape, async iterator shape + event yield, transport-lock serialization under concurrent threads, fd-pipe end-to-end via socketpair + double-spawn rejection), plus a live three-layer smoke verifying the shape against the real broker.

**Concern.** The SDK design (Part 8.3) says "iterator-based
subscriptions" without specifying the consumer shape. Real consumers
have three different loops: PySide6/Qt (wants an fd for
`QSocketNotifier`), sync CLI (wants a blocking iterator), asyncio
(wants an async iterator). Picking one locks the other two out — or
forces awkward shim code (`qasync`-style) at every consumer.

**Why it matters.** Locking events to asyncio alone is the mistake
mgf-usb avoided after the API-07 feedback paper. With HRB's planned
consumers spanning a VM manager (Qt), a recovery tool (sync), and a
hypothetical desktop monitor (asyncio), all three shapes are real.

**Concrete evidence.** mgf-usb v0.2.0's `monitor_fd()` / `monitor()` /
`amonitor()` all subscribe to one underlying source (see
`mgf-usb/docs/design/EVENT_SYNTHESIS.md`). Cost: three thin wrappers
over one stream. HRB's shm ring is already the underlying source; only
the wrapping is missing.

**Suggested shape.** Identical to mgf-usb's. The shm ring provides
`drain()` and an `fd` that becomes readable on the empty→non-empty
transition (via the futex word's eventfd, or a small wrapper). The
SDK ships:

```python
def monitor_fd() -> int: ...
def monitor() -> Iterator[Event]: ...
async def amonitor() -> AsyncIterator[Event]: ...
```

Plus `changed`-event synthesis from add+remove pairs in a debounce
window — `mgf-usb/docs/design/EVENT_SYNTHESIS.md` is the spec.

**Decision rationale.** Accepted. Costs three Python wrappers over
an already-built underlying source. Design folded into Part 8.3.

**Retrospective.** *(Filed when shipped.)*

---

### `LINUX-01` — IOMMU presence must be enforced, not assumed (S2/D9 made operator-visible)

| Field | Value |
|---|---|
| Status | ACCEPTED |
| Severity | 🟡 High |
| Submitter | enrichment pass (from `mgf-hrb-survivability-review.md` §2.6) |
| Submitted | 2026-05-23 |
| Decision | accepted — already in survivability roadmap (Tier-1 #7) |
| Decided on | 2026-05-23 |
| Scheduled for | Tier-1 hardening |
| Shipped in | — |

**Concern.** Stage 1 of the survivability review assumes an IOMMU
contains rogue DMA. Stage 2.6 says "assume is not enforce." On a host
with no IOMMU (or IOMMU off in BIOS / no `iommu=pt` boot param), a
DMA-capable passthrough would silently let a device DMA anywhere in
host memory. The current `mgf-hrb-linux` checks for `/dev/vfio` but
not for IOMMU configuration.

**Why it matters.** The single most consequential silent-failure mode
on the downward boundary. The whole "the IOMMU contains the device"
assumption hangs on this.

**Concrete evidence.** `hrb-code/src/linux_backend.zig:60-61` checks
only `/dev/vfio/vfio` existence. No verification of
`/sys/kernel/iommu_groups/` populated, no Intel-IOMMU/AMD-IOMMU dmesg
verification.

**Suggested shape.** At backend init, verify:

- `/sys/kernel/iommu_groups/` contains groups
- `iommu=` boot parameter (or kernel cmdline equivalent) shows the
  IOMMU on
- the target device's IOMMU group is populated and intact

If any check fails, the backend enters **declared degraded mode**: it
reports `Unsupported{reason=no_iommu}` for any DMA-capable acquire and
makes the degraded mode visible via `mgf-hrb doctor`. Observe and
non-DMA paths keep working. This converts the Stage-1 assumption into
a checked precondition.

**Decision rationale.** Accepted — already on the survivability
hardening Tier-1 list. This paper just makes it visible in the
feedback log so consumers can track its status.

**Retrospective.** *(Filed when shipped.)*

---

### `DOC-02` — Supersession relationship to `mgf-ubs` is not declared in any user-facing doc

| Field | Value |
|---|---|
| Status | ✅ SHIPPED (Approach A; C deferred) |
| Severity | 🟡 High |
| Submitter | iosRecovery (agent session, 2026-05-26) |
| Submitted | 2026-05-26 |
| Decision | ACCEPTED 2026-05-27 — Approach A (README pointer + MIGRATION doc + link fix) shipped; Approach C (`USB` prefix rename) deferred as too invasive for the existing paper IDs |
| Decided on | 2026-05-27 |
| Scheduled for | shipped this commit |
| Shipped in | 2026-05-27 (FEEDBACK §1 link fix + `MIGRATION_FROM_MGF_USB.md` + README pointer) |

**Concern.** `mgf-hrb` is replacing `mgf-ubs` (planning stage as of this filing), but no user-facing document in `mgf-hrb` says so. `README.md` describes the library on its own terms, `STARTHERE.md` introduces it as a fresh start, `CHANGELOG.md` has no supersession entry, and `FEEDBACK.md` §1 cross-links to `../FEEDBACK.md` calling it `mgf-usb` (note: `usb` not `ubs` — a typo on top of a stale name). The only breadcrumb a reader can find from "I have `mgf-ubs` in my pyproject, what now?" → "use `mgf-hrb`" is buried in `USB-01`'s "drawn from `mgf-usb` v0.2.5 lessons" framing, which presents the relationship as *inspiration*, not *replacement*. An external consumer of mgf-ubs has no way to discover, from mgf-hrb's docs alone, that the library they depend on has been deprecated in favour of this one.

**Why it matters.** The rename pattern is being established right now and the docs side can be sequenced before code ships. Getting it right on the first federation-sibling whole-name rename (`mgf_common` → `mgf-common` was a separator change; this is the first complete-rename event) sets the template every future rename inherits. The cost of declaring supersession in planning stage is one short markdown file plus a README badge; the cost of *not* declaring it is every mgf-ubs consumer eventually discovering the migration by accident — through CI break, expiring repository, or word of mouth — without an authoritative mapping of "old API → new API." That mapping is at its most accurate now, while both libraries' surfaces are fresh in the maintainer's mind; six months from now it will be archaeology.

**Concrete evidence.**
- `mgf-hrb/README.md` lines 1-7: header block names sibling `mgf-common` but does not mention `mgf-ubs` at all.
- `mgf-hrb/STARTHERE.md` (procedural how-to): no supersession statement.
- `mgf-hrb/CHANGELOG.md`: no "Replaces `mgf-ubs`" entry.
- `mgf-hrb/FEEDBACK.md:4`: `> Same shape as the federation pattern in [`mgf-usb/FEEDBACK.md`](../FEEDBACK.md)` — broken on two counts: (a) the predecessor's name appears to be `mgf-ubs` per the user's planning statement, but the link says `mgf-usb`; (b) the relative path `../FEEDBACK.md` resolves to `PycharmProjects/FEEDBACK.md` (the parent of all repos), which does not exist — the link is dead regardless of which name is correct.
- The `USB` area prefix in §1 of `FEEDBACK.md` (line 45) describes the `mgf-hrb-usb` *worker* (a subsystem of mgf-hrb), which is a different concept from the *project* `mgf-ubs`/`mgf-usb`. The name collision will compound confusion if not disambiguated.

**Suggested shape.**

- **Approach A — README badge + dedicated migration doc.** Add a one-line statement to `README.md` header block: *"Replaces: `mgf-ubs` (deprecated). See [`MIGRATION_FROM_UBS.md`](MIGRATION_FROM_UBS.md)."* Create the top-level `MIGRATION_FROM_UBS.md` with: (i) every mgf-ubs public symbol mapped to its mgf-hrb equivalent, (ii) the few cases where the shape changed (with rationale), (iii) the deprecation timeline (when mgf-ubs stops being supported). Update the supersession-bearing `CHANGELOG.md` entry to point at it. Fix the `mgf-usb` → `mgf-ubs` typo and the dead `../FEEDBACK.md` link in `FEEDBACK.md:4`.
- **Approach B — CHANGELOG-only.** Skip the dedicated doc; put the supersession statement plus symbol mapping inline in the first `CHANGELOG.md` entry that ships the replacement. Cheaper, but every future consumer has to find it in a release note rather than a top-level migration doc. Worth it only if the API surfaces overlap so closely that the mapping is one paragraph.
- **Approach C — Disambiguate the `USB` prefix while we're touching docs.** Rename the `USB` FEEDBACK area prefix (currently for the `mgf-hrb-usb` worker) to `WUSB` or `WBUS` or similar, so the project-name `mgf-ubs` / `mgf-usb` cannot be confused with the subsystem. Independent of A/B but worth bundling — the migration doc will have to say "mgf-ubs (the project)" vs "mgf-hrb-usb (the worker subsystem in mgf-hrb)" repeatedly, and a non-overloaded prefix removes a recurring footnote.

Recommend **A + C**. A is the substantive answer; C is a small ergonomic cleanup that prevents the project name and subsystem name from colliding throughout the migration doc.

**Feedback also routed to `mgf-common`.** PAPER-79 (sibling-rename recipe) addresses the federation-wide pattern this paper instantiates. If PAPER-79 ships first, this paper's checklist can collapse into "follow the federation recipe."

**Decision rationale (2026-05-27).** Approach **A** shipped now; Approach **C** (renaming the `USB` area prefix to `WUSB` or similar) **deferred** as too invasive for the existing paper IDs (USB-01/-02/-03 already shipped via this prefix — renaming would either break paper-ID stability OR require an alias table that introduces its own confusion). The migration doc explicitly disambiguates inline (`mgf-usb` = the predecessor PROJECT; `mgf-hrb-usb` = a SUBSYSTEM of this library) — readable without C.

**Predecessor name + repo status (resolved 2026-05-27).** The paper flagged an ambiguity between `mgf-usb` and `mgf-ubs`. The canonical name is **`mgf-usb`** (the link text in §1 was right; the submitter's `mgf-ubs` framing in the paper body was wrong). The maintainer confirmed at review AND additionally that the `mgf-usb` repository has been **deleted** — its lessons folded into this design, no codebase remains to migrate FROM in a mechanical sense. The migration doc was re-written to reflect this reality: the symbol-mapping table dropped (there is no `__all__` to enumerate), replaced by a "what was carried forward" table that names the substantive value lifted into `mgf-hrb` (the comparator, the sysfs helper, the worker design lessons).

**Retrospective.** Shipped 2026-05-27:

1. **`MIGRATION_FROM_MGF_USB.md`** (NEW, top-level — initial draft was `MIGRATION_FROM_UBS.md`, renamed once the canonical predecessor name was confirmed) — supersession statement at the doc's head; canonical mgf-usb/mgf-hrb/mgf-hrb-usb disambiguation table; "what was carried forward" table replacing the symbol-mapping table; adoption sequence keyed to USB-02's slices; "what this doc is NOT" section bounding expectations.
2. **`README.md` header block** — one-line supersession pointer naming the deleted-repo state explicitly per Approach A's literal wording.
3. **`FEEDBACK.md` §1 link fix** — replaced the dead `[mgf-usb/FEEDBACK.md](../FEEDBACK.md)` with a working `[mgf-common/FEEDBACK.md](../mgf-common/FEEDBACK.md)` and a parenthetical naming the repo-deletion fact + the migration doc. The predecessor was a real name (not a typo); the dead-relative-path was the only defect.
4. **Inline disambiguation in `MIGRATION_FROM_MGF_USB.md`** — every reference to "mgf-usb" clarifies "(the predecessor PROJECT)" on first use; every reference to "mgf-hrb-usb" clarifies "(the worker SUBSYSTEM in this library)" on first use. Approach C's structural rename deferred but the migration doc reads unambiguously without it.

**Decision-time correction.** An earlier intra-session draft of this retrospective stated the predecessor name was `mgf-ubs` (per the submitter's framing in the paper body) and that a symbol-mapping table was TBD pending predecessor codebase access. Both were wrong — the maintainer corrected the name to `mgf-usb` AND surfaced the repo-deletion fact, which dropped the symbol-mapping table entirely. The retrospective above reflects the corrected state. Lesson for future supersession papers: trust the link text the predecessor was actually using, not the paper body's prose framing if they disagree.

---

### `USB-02` — figrecovery adoption blocked on the unbuilt `mgf-hrb-usb` worker; what's the minimal slice that unblocks adoption?

| Field | Value |
|---|---|
| Status | ACCEPTED (Slice A first) |
| Severity | 🔴 Blocker |
| Submitter | figrecovery (iosRecovery, commit `58695ea`, 2026-05-26) |
| Submitted | 2026-05-26 |
| Decision | ACCEPTED 2026-05-27 — Slice A (sysfs discovery only) ships first |
| Decided on | 2026-05-27 |
| Scheduled for | mgf-hrb 0.2.x (Slice A); Slice B + C subsequent minors |
| Shipped in | — (next session — see work_roadmap.md) |

**Concern.** [`USERS.md`](USERS.md) declares figrecovery as `mgf-hrb`'s designated consumer for the SDK + identity comparator + raw-channel path, driving [`recipes/track-a-device-across-a-mode-change.md`](recipes/track-a-device-across-a-mode-change.md). [`INDEX.md`](INDEX.md) "What's proven vs. what remains" lists `mgf-hrb-usb` as **designed but not coded**. Concretely this means today: `Client.list("hw:/usb/**")` returns empty against the live broker (only PCI is enumerated by the Linux backend), `Client.inspect("hw:/usb/...")` produces `NotFoundError`, and the broker's lease/raw-channel path is therefore unreachable for any USB consumer. The recipe is real and the comparator is shipped (MODEL-01 ✅), but the end-to-end adoption path the recipe describes — *find → snapshot → claim → raw → release across a mode change* — is gated on the USB worker. figrecovery cannot adopt the broker path today, only the comparator-in-isolation half.

This paper does not re-litigate `USB-01` (the worker design — ACCEPTED). It asks a different question: **given that the worker is large work and figrecovery has a real-world iFig restore flow waiting, what's the smallest slice of the worker that unblocks the recipe's §1–§4 pattern end-to-end against a real device?** Filing this so the slicing decision is recorded with a designated consumer's input rather than reasoned from the worker design alone.

**Why it matters.** figrecovery's current implementation hand-rolls device discovery, mode-transition handling, raw USB access via libirecovery/libusbmuxd, and identity continuity across re-enumeration — every one of the things `mgf-hrb` exists to provide. Each month the consumer-side workaround calcifies is a month of accreted code that becomes harder to retire later. Per the federation's principle 3 ("use the federation; do not reinvent it"), the right answer is for figrecovery to consume `mgf-hrb` as soon as the worker covers the iFig flow. The slicing matters because the *full* worker (all of §1–§9 of `hrb-design/mgf-hrb-usb-worker.md`: per-interface composite handling, all three substrates with fallback chain, every typed control verb, the udev-rule recipe, the doctor sub-checks, the netlink event source, the deadline/quarantine specialisations) is much more work than what figrecovery actually needs to retire its hand-rolled discovery + mode-tracking code.

**Concrete evidence.**
- `INDEX.md` "Remaining (hardware-gated or integration, not unproven design)" lists `mgf-hrb-usb` worker explicitly as not-yet-coded.
- Running the broker (`zig build && ./zig-out/bin/mgf-hrb-server`) and `examples/01_list_devices.py` against a live iFig: only seeded PCI URNs appear; no `hw:/usb/...` URNs even when an iFig is enumerated by the kernel.
- figrecovery's current spike runbook (`docs/inprogress/RUNBOOK_TAG_AND_SPIKE.md` §B.3) drives device enumeration via `./scripts/spike/s-b-list-devices.sh` (a shell script wrapping `idevice_id`, `irecovery -q`, `lsusb`) — exactly the loop that `Client.list("hw:/usb/**") → Client.inspect(urn)` should retire.
- figrecovery's mode-transition flow (§B.3 — Normal → Recovery → DFU) currently leans on libirecovery's USBDEVFS_CONTROL ioctls against a libusb-opened device handle — exactly the loop that `Client.claim(urn) → lease.raw()` should mediate.

**Suggested shape — three-stage slicing, smallest first.**

- **Slice A — Discovery + identity only (minimum viable for figrecovery's §S-B device-detection sweep).**
  - `kabi.list(scope="hw:/usb/**")` returns one URN per kernel-enumerated USB device + one per interface (`/dev/ifK`), populated from `/sys/bus/usb/devices/` (sysfs substrate only — the worker design's tier-1).
  - `kabi.inspect(urn)` returns `ResourceDetail` with `attrs` carrying `idVendor`/`idProduct`/`iSerialNumber` (when present)/`bus`/`port-chain`/`bInterfaceClass`. No claim, no raw, no control — pure read.
  - One sub-doctor check: `check_usb_sysfs` (already designed in `mgf.hrb.diagnostics`; ship its USB-substrate row).
  - **What this unlocks for figrecovery.** Retire `scripts/spike/s-b-list-devices.sh`. Drive the §1–§2 of the comparator recipe against real devices (find + snapshot from real `inspect`, not hand-typed test data).
  - Estimated surface: ~1 substrate (sysfs), no control path. Probably the smallest possible deliverable that gives figrecovery a working consumption.
- **Slice B — Claim + raw channel (unlocks the mediation half).**
  - `kabi.claim(urn, mode)` against a USB interface URN; the broker holds the kernel's USBDEVFS open-file via the usbfs substrate (tier-2). Returns a Lease.
  - `lease.raw()` returns an fd via SCM_RIGHTS — the consumer attaches it to libusb via `libusb_wrap_sys_device(ctx, fd)` (see DOC-03 below).
  - `kabi.release(urn, lease)` triggers safe-reset.
  - Atomic `detach_driver` is gated by `detach=True` flag (per design §5.10's `USBDEVFS_DISCONNECT_CLAIM`).
  - **What this unlocks for figrecovery.** Replace direct libusb-on-Linux opens with broker-mediated claims. Crash-safety (G3 ghost-free reclamation) kicks in: a `idevicerestore` crash mid-flash no longer leaves the device in a wedged state requiring `usbreset`.
  - **Pre-requisite for usability**: a libusb-shim recipe (filed separately as `DOC-03`); without it consumers cannot use the raw fd with existing libraries.
- **Slice C — Events + composite handling (unlocks mode-change continuity).**
  - The netlink substrate (tier-3) feeds `drain_events`/`monitor`; recipe §5 starts working (state_changed events instead of polling).
  - Per-interface composite handling (worker design §3) so DFU-mode interface renumbering is tracked correctly.
  - Identity-comparator-synthesizer (recipe §5) collapses `removed`+`added` to `state_changed` pairs.
  - **What this unlocks for figrecovery.** Recipe §1's polling loop (`_poll_until_dfu_appears`) becomes one `monitor()` await; the comparator runs inside the broker rather than at every consumer.

**Priority recommendation.** Ship **Slice A first** as a standalone release (worker covers sysfs discovery only — `mgf-hrb-usb` v0.0.1 inside `mgf-hrb` v0.2.0, say). figrecovery integrates against that surface immediately. **Slice B** is the second cut and needs `DOC-03` to ship alongside or no consumer can actually use the fd. **Slice C** is the polish round once a real consumer is exercising the path.

**Out of scope for this paper.** Whether `mgf-hrb-usb` lives in this repo or as a separate sibling repo — see `mgf-hrb-complete-design.md` Part 7.7 for the existing design call. Whether the worker should be its own OS process — designed in `mgf-hrb-privileged-core-hardening.md` Part 11, deferred per INDEX.md. Both are orthogonal to the slicing question above.

**Decision rationale (2026-05-27).** **Slice A first** as the submitter recommended. Three reasons:

1. **Smallest deliverable that produces visible figrecovery adoption.** Slice A retires `scripts/spike/s-b-list-devices.sh` (the consumer's hand-rolled enumeration sweep) by surfacing `Client.list("hw:/usb/**")` + `Client.inspect(urn)` against real devices. Every subsequent slice extends this; nothing requires it to grow before figrecovery sees value.
2. **Composes cleanly with SDK-07.** `snapshot_from_sysfs` (shipped today in `c88d5be`) reads exactly the same sysfs files Slice A would read on the broker side. The two-sided implementation lands the same data twice deliberately — the broker side is what mgf-hrb consumes via `Client.inspect`; the SDK-side helper is for any consumer that's pre-USB-worker or runs without a broker. Both stay correct via the federation's MODEL-01 conformance.
3. **Slices B and C have prerequisites that aren't ready.** Slice B needs DOC-03's recipe (now shipped) + a real libusb consumer in CI to stress the lease/raw path. Slice C (events + composite handling) depends on Slice B + the netlink substrate which has its own design work.

**Implementation tracker** lives in `~/.claude/projects/-home-bassam-PycharmProjects-mgf-hrb/memory/work_roadmap.md` under "Open queue — Slice A scope". Slice A surface: `kabi.list` returning sysfs-enumerated USB URNs (device + per-interface), `kabi.inspect` returning typed `attrs` for the standard USB descriptor fields, `check_usb_sysfs` doctor row activated. NO claim path, NO raw channel, NO control verbs — those wait for Slice B.

**Retrospective.** *Filled when Slice A ships.*

---

### `SDK-07` — `Snapshot` cannot be constructed from a real device today; comparator's adoption is gated on field-gathering boilerplate every consumer must duplicate

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | figrecovery (iosRecovery, commit `58695ea`, 2026-05-26) |
| Submitted | 2026-05-26 |
| Decision | ACCEPTED 2026-05-26 — Approach A (single sysfs helper) shipped |
| Decided on | 2026-05-26 |
| Scheduled for | mgf-hrb 0.1.x |
| Shipped in | commit `c88d5be` (2026-05-26) |

**Concern.** `mgf.hrb.identity.Snapshot` is shipped (`MODEL-01` ✅), and the recipe's §1 is the canonical figrecovery pattern. But the recipe's §1 also reveals a friction: every `Snapshot` is hand-constructed:

```python
before = Snapshot(
    urn=starting_urn,
    identity_class=IdentityClass.INTRINSIC,
    vid=IFIG_VID,
    pid=RECOVERY_PID,
    serial_number="F4GS9PWWJCM7",
    bus=1,
    port_chain=(2, 3),
    captured_at_ms=now_ms(),
)
```

In practice the consumer has to gather each field itself — by parsing sysfs (`/sys/bus/usb/devices/`), by walking libusb's enumeration, by querying udev. Until the USB worker ships (USB-02 above), `Client.inspect()` cannot populate these fields either. So the comparator's adoption is gated on every consumer writing its own sysfs reader, even though figrecovery — the comparator's validated test consumer — needs the comparator today.

The comparator is the ONE part of mgf-hrb's value that doesn't need the broker, the worker, or even Linux uniqueness (it's a pure-data scoring function). Decoupling it from the missing surfaces means consumers can adopt the high-value piece NOW and migrate the rest as it ships.

**Why it matters.** Adoption discipline. The federation's principle 3 says "use the federation; do not reinvent it" — but a consumer that has to write 50 lines of sysfs parsing to build one `Snapshot` will, in practice, also write its own ad-hoc identity comparator alongside. The 50 lines are friction; the friction is what causes the reinvention. Closing that friction is small (`Snapshot.from_sysfs(path)` is a stdlib-only function: `read_text("idVendor")`, `read_text("idProduct")`, `read_text("serial")`, parse `port-chain` from devpath, capture `time.monotonic_ns() // 1_000_000`) and turns the comparator's adoption surface from "comparator + 50 lines of glue every consumer rewrites" into "one import." Same logic for pyudev / libusb constructors as alternative wire formats.

**Concrete evidence.**
- `recipes/track-a-device-across-a-mode-change.md` §1 shows the hand-constructed `Snapshot` calls as expected practice; the recipe even calls out the gap: *"per-resource detail comes from a future `client.inspect(urn)` call (designed in Part 8; not yet wired in the skeleton). Until then, downstream consumers that need vid/pid/serial drive the broker's `info` subcommand or correlate via sysfs."*
- figrecovery's current code does exactly this manual gathering (via libimobiledevice + `ideviceinfo`); a `Snapshot.from_sysfs` would let it switch to mgf-hrb's comparator while keeping the existing gathering layer.
- `mgf.hrb.identity` is stdlib-only (no Zig dep, no broker dep) — the helper is in the same dependency-cleanliness tier as the comparator itself.

**Suggested shape.**

- **Approach A — Single helper, sysfs.** Add `Snapshot.from_sysfs(syspath: str | Path, *, captured_at_ms: int | None = None) -> Snapshot` to `mgf.hrb.identity`. Read `idVendor`/`idProduct`/`serial` (as hex/str), parse `devpath` for port-chain (the `bus-N.M.K` suffix), pull bus from `busnum`, default timestamp to `now_ms()`. Pure stdlib. ~30 lines + tests.
- **Approach B — Multiple constructors.** A.1 `from_sysfs(syspath)`, A.2 `from_pyudev(device)` (gated on pyudev installed; optional extra), A.3 `from_libusb_device(dev)` (gated on libusb1 installed; optional extra). More surfaces, more deps. Land A as MVP; B's variants land per consumer demand.
- **Approach C — Companion module `mgf.hrb.identity.sources`.** Same helpers as B but grouped in a submodule (`from mgf.hrb.identity.sources import from_sysfs`). Cleaner namespace if the count grows; B's flat shape is fine until count exceeds ~3.

Recommend **A first** (sysfs is the substrate the worker itself will read; the helper is the same code path); B if a second consumer ships using a different gathering layer (libusb-based consumers — figrecovery itself uses libimobiledevice on top of libusb).

**Decision rationale.** Approach A first as the paper recommended — sysfs is the canonical substrate (the eventual USB worker reads the same files), no third-party deps, can land immediately. B / C wait for a second consumer with a different gathering layer to surface real demand.

**Retrospective.** Shipped in `c88d5be`. Decisions during implementation:

- **Module placement.** Paper suggested `Snapshot.from_sysfs(...)` (classmethod on the dataclass). `identity.py`'s docstring promises "PURE LOGIC — no I/O, no syscalls". Adding I/O to a classmethod would have either (a) violated the docstring invariant or (b) forced moving Snapshot itself out of the pure-logic module. Instead landed a new module `mgf.hrb.sysfs` exporting `snapshot_from_sysfs(syspath)`. Clean boundary: a hostile / sandboxed consumer keeps using the comparator without the sysfs reach (just don't import `mgf.hrb.sysfs`). One import vs `Snapshot.from_sysfs` is the same ergonomic cost.
- **URN synthesis.** The paper didn't specify what the helper should do with `urn`. Landed: synthesise from `busnum` + parsed `devpath` (`hw:/usb/bus<N>/port-<a>-<b>-<c>/dev`), with an explicit `urn=` kwarg override for callers that need interface-level URNs (`.../dev/if0`). When the USB worker ships per USB-02, the synthesised URNs will match the broker-emitted form (both read the same sysfs facts), so Snapshots captured pre-worker can be compared against Snapshots captured post-worker without re-stamping the URN.
- **IdentityClass discipline.** Matches `identity.zig` — INTRINSIC when a non-empty serial reads, TOPOLOGICAL when serial is absent or empty (the iFig DFU mode case where the descriptor slot exists but isn't filled).
- **devpath parsing tolerance.** Handles all observed shapes: dotted (`"1.4.2"`, modern kernels), dashed (`"1-4-2"`, legacy), root-hub (`"0"`), and bus-prefixed (`"3-1.4"` where the leading digit is the bus). Worst case (malformed token) falls back to `(0,)` rather than crashing.
- **Error discipline.** Required attrs missing → `FileNotFoundError` naming the file (the caller likely passed a non-USB sysfs path); corrupt hex / bad busnum → `ValueError` naming the bad fields (corrupt kernel state, hardware fault).

17 regression pins covering: happy path, missing/empty serial, every devpath shape, URN synthesis vs override, captured_at_ms default vs override, hex parsing, required-attr-missing, corrupt-hex, structural read-only-ness check, top-level export pin, end-to-end pair with `same_likely` across a simulated PID flip (the iFig DFU sequence the paper called out).

Cost delivered: ~190 LOC (`sysfs.py`) + 17 tests. figrecovery can now retire its hand-rolled sysfs parsing and consume mgf-hrb's comparator with one import.

---

### `DOC-03` — No recipe for handing a `lease.raw()` fd to libusb / libimobiledevice / libirecovery; the broker's claim path is unconsumable by existing USB libraries without it

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | figrecovery (iosRecovery, commit `58695ea`, 2026-05-26) |
| Submitted | 2026-05-26 |
| Decision | ACCEPTED 2026-05-26 — Approach A (recipe only) shipped; B + C deferred |
| Decided on | 2026-05-26 |
| Scheduled for | mgf-hrb 0.1.x |
| Shipped in | commit `c88d5be` (2026-05-26) |

**Concern.** Once Slice B of USB-02 ships, `Client.claim(urn).raw()` returns a `dict` carrying `fd` — an SCM_RIGHTS-passed file descriptor on the kernel's USBDEVFS open-file. For the broker's claim path to be *useful* to real consumers, that fd has to be consumable by the libraries those consumers already depend on. For figrecovery and any consumer in the iFig restore space, that means **libusb-1.0** (libimobiledevice, libusbmuxd, libirecovery, idevicerestore, tsschecker, futurerestore, palera1n are all libusb-1.0 callers underneath).

libusb has supported this exact handoff since v1.0.23 (2020) via `libusb_wrap_sys_device(ctx, fd)` — the fd-passing entry point that takes an existing kernel handle and returns a `libusb_device_handle*` without re-opening the device. But the pattern is not in `mgf-hrb`'s recipe corpus today. A consumer that gets `lease.raw()['fd']` and doesn't know about `libusb_wrap_sys_device` will (a) try to call `libusb_open()` on the same device, which fails because the broker holds the kernel open-file, (b) close the fd to "free" it before re-opening — which triggers the broker's revocation path and breaks the lease, or (c) give up and bypass the broker entirely. Three failure modes the recipe could close in one page.

**Why it matters.** Without this recipe, the broker is a beautiful design with no actual usable path for any consumer in the iFig / Android-fastboot / generic-USB-tool ecosystem — all of which are libusb-1.0 callers. `mgf-hrb`'s value proposition is "claim once, the broker steps out, you get full-speed direct hardware access" (the G4 survivability dividend). That promise only delivers if consumers know HOW to step the broker out without breaking the lease. The handoff is mechanical (one libusb call) but the *contract* — what the broker still owns vs. what the consumer now owns — needs to be written down in one place so it doesn't get re-discovered per consumer.

A concrete second consumer (planned per `USERS.md` — the "USB recovery tool" reference, Phase 4) will hit this same wall. Writing the recipe once, with the libusb shape, with figrecovery as the worked example, retires the wall for every libusb-1.0 consumer the federation expects to integrate.

**Concrete evidence.**
- `PUBLIC_API.md` lists `raw(urn, lease) -> dict` returning `{fd: int}` — but neither `examples/02_claim_and_use.py` nor `recipes/claim-a-device-for-vfio-passthrough.md` shows what to *do* with the fd for a USB device. (The VFIO recipe is fd-passing to QEMU, which is a `/dev/vfio/*` open-file shape; libusb's USBDEVFS shape is different.)
- `recipes/usb-event-monitor-with-role-interfaces.md` and `recipes/track-a-device-across-a-mode-change.md` cover discovery + identity, not claim+use.
- figrecovery depends on libirecovery (`scripts/bootstrap.sh` builds it). libirecovery's `irecv_open_with_ecid` calls `libusb_open` directly. To consume `mgf-hrb`'s claim path, figrecovery would need a libirecovery shim or a forked `irecv_open_from_fd(fd, ecid)` — and the recipe should explain that this is a *one-time* shim per library, not a per-consumer ask. (Whether to upstream the shim to libirecovery is a separate question; the recipe should at least name the pattern.)
- libusb-1.0.23+ ships `libusb_wrap_sys_device`; documented at <https://libusb.sourceforge.io/api-1.0/group__libusb__dev.html>. Distros on libusb < 1.0.23 (Ubuntu 18.04 and older; most modern hosts are fine) need a fallback note.

**Suggested shape.**

- **Approach A — A single recipe.** `recipes/use-a-broker-fd-with-libusb.md` covering: (1) the libusb_wrap_sys_device call shape; (2) what the broker still owns vs. the consumer (the broker owns the lease's liveness contract — the consumer must not close the fd until lease release, and must not call `libusb_close(handle)` with the closure-via-fd semantics that would race the broker's tracking; libusb has a separate `libusb_set_option(LIBUSB_OPTION_NO_DEVICE_DISCOVERY)` path that's relevant here); (3) the libirecovery / libimobiledevice shim pattern (a fork-or-PR pattern, with a worked example for figrecovery's libirecovery dependency); (4) failure modes + how to detect them (lease revocation events; ENODEV from libusb when the broker has reclaimed).
- **Approach B — Approach A plus a code helper.** Ship `mgf.hrb.contrib.libusb.wrap_lease(lease) -> libusb1.libusb_device_handle` in an optional `[libusb]` extra (gated on `libusb1` Python binding being present). One import, one call. Stays in `contrib` because it adds a third-party dep; doesn't pollute the core SDK's stdlib-only posture.
- **Approach C — Approach A plus a worked figrecovery reference.** A standalone `examples/04_libusb_claim_and_irecv.py` that wraps the lease's fd into libusb and opens libirecovery on top, using the iFig URN. Lives in `examples/`. Concrete enough that a consumer can copy-paste and adapt. Composes with A; doesn't need B.

Recommend **A + C now**; **B later** if/when consumer demand justifies the extra dep.

**Decision rationale.** Shipped Approach A (`recipes/use-a-broker-fd-with-libusb.md`) now — it's the load-bearing item. Approach C (worked example at `examples/04_libusb_claim_and_irecv.py`) deferred until USB-02's Slice B lands and a real iFig device is in the loop — pre-USB-worker, an example calling `lease.raw()` on a USB URN would just demonstrate `NotFoundError`. Approach B (`mgf.hrb.contrib.libusb.wrap_lease`) deferred until consumer demand justifies the third-party dep — the recipe says "one libusb call" and that's true.

**Retrospective.** Shipped in `c88d5be` (alongside SDK-07). The recipe lives at `recipes/use-a-broker-fd-with-libusb.md` (~200 lines). Seven sections covering:

1. **The pattern** — 8 lines of Python via `usb1.USBContext.wrapSysDevice(fd)`. The handoff itself is one call.
2. **Why `libusb_wrap_sys_device`, not `libusb_open`** — explains the broker-already-opened-it ownership conflict that makes the standard libusb path break.
3. **The ownership contract** — broker owns the lease's liveness + the kernel handle's *existence*; consumer owns ioctl/transfer USAGE; neither calls `close()`. Three concrete consequences with named symptoms.
4. **Failure modes table** — 5 named symptoms × cause × fix. Includes the cross-link to USB-03 (the daemon-coexistence question).
5. **Older libusb (<1.0.23) fallback** — direct USBDEVFS ioctls OR fence-via-OBSERVE-mode. Most consumers should upgrade; this is the escape valve.
6. **Worked libirecovery example** — sketch of `irecv_open_from_fd(fd, ecid)` shim that figrecovery (and every libimobiledevice-shaped consumer) needs. The shim is a one-time per-upstream-library cost; worth upstreaming.
7. **Cross-references** — to USB-02 Slice B (when the real USB raw lands), USB-03 (the usbmuxd question), sibling recipes, and the upstream libusb docs.

The recipe works against the FakeBackend today (which returns `lease.raw()` with a usable fd); lands against real USB hardware when USB-02 Slice B ships. The ownership contract section is the load-bearing piece — three consequences are spelled out concretely so future consumers don't re-discover them by breaking a lease.

---

### `USB-03` — Claim-mode lattice's interaction with non-mgf-hrb tenants (usbmuxd holding iFig devices in Normal mode) is undefined; blocks figrecovery's Normal-mode adoption

| Field | Value |
|---|---|
| Status | ACCEPTED (Approach A — refuse-on-conflict) |
| Severity | 🔴 Blocker |
| Submitter | figrecovery (iosRecovery, commit `58695ea`, 2026-05-26) |
| Submitted | 2026-05-26 |
| Decision | ACCEPTED 2026-05-27 — Approach A is the v0.x rule; documented today, implementation lands with USB-02 Slice B |
| Decided on | 2026-05-27 |
| Scheduled for | rule documented now (`c88d5be` follow-up); enforcement code lands with USB-02 Slice B (the USB worker's claim path) |
| Shipped in | rule: this commit; enforcement: TBD with Slice B |

**Concern.** The claim-mode lattice (`PUBLIC_API.md` §"Claim-mode lattice"; design Part 3.3) describes coexistence rules between `mgf-hrb`-mediated tenants: `OBSERVE` coexists with anything, `EXCLUSIVE` coexists only with `OBSERVE`, `SHARED_RW` coexists with other `SHARED_RW` + `OBSERVE`. But it doesn't say what happens when a **non-mgf-hrb daemon already holds the kernel open-file** on the same device.

For figrecovery this is not theoretical: Apple's `usbmuxd` (started by udev when an iFig device is plugged in, runs as a system service on every Linux distro that ships libimobiledevice) holds the iFig device's interface 0 in Normal mode. It's the only way libimobiledevice consumers talk to the device in Normal mode (the iTunes-style mux protocol runs through it). figrecovery's Normal-mode flow (`ideviceinfo`, `idevicepair`, `ideviceactivation`) uses usbmuxd. The flow then transitions Normal → Recovery → DFU; in Recovery/DFU mgf-hrb's claim path becomes useful (`libirecovery` opens the device directly, no mux). In Normal mode, the broker would either need to:

(a) refuse to claim the device because usbmuxd holds it (most honest, but blocks all Normal-mode discovery for any consumer);
(b) claim it `OBSERVE`-only in Normal mode (matches the lattice but requires the broker to detect usbmuxd's hold and refuse `EXCLUSIVE`); or
(c) sit *above* usbmuxd — delegate Normal-mode operations to it, take the kernel open-file directly in Recovery/DFU (the most useful for figrecovery, but architecturally surprising).

None of (a)/(b)/(c) are documented today. The lattice description treats the broker as the sole arbiter of device access, which is true for a from-scratch fleet but not true for any host running stock Linux with usbmuxd / bluetoothd / printer daemons / etc.

**Why it matters.** This is THE blocker for figrecovery's Normal-mode adoption. Even after the USB worker ships and `DOC-03` lands, if mgf-hrb's `EXCLUSIVE` claim refuses the iFig device because usbmuxd has it, figrecovery cannot drive the Normal-mode part of its restore flow through the broker. The flow has to split: "Normal-mode through usbmuxd directly, Recovery/DFU through mgf-hrb." That's workable but not the federation-discipline outcome. And the same question generalizes to *every* USB device class that has a system daemon: HID input (no daemon, fine), webcams (V4L2 path, fine), Android (no daemon, fine), iFig (usbmuxd), printers (cupsd in some configurations), security tokens (pcscd), Android-fastboot (no daemon, fine). The iFig case is the most common one consumers will hit.

Naming the rule once now — *before* the worker ships and consumers integrate against an undocumented behaviour — is much cheaper than walking back a fait accompli later.

**Concrete evidence.**
- figrecovery's `RUNBOOK_TAG_AND_SPIKE.md` §B.3 first step is `idevice_id -l` which goes through usbmuxd. Any flow that starts with "device in Normal mode" goes through usbmuxd on Linux.
- `lsof /dev/bus/usb/...` against an iFig in Normal mode shows usbmuxd holding the device's interface 0 (`usbmuxd  ...  /dev/bus/usb/001/042`).
- `hrb-design/mgf-hrb-usb-worker.md` describes the worker's interaction with the kernel substrate (sysfs/usbfs/netlink) but does not explicitly cover "another process already holds the usbfs handle."
- `recipes/claim-a-device-for-vfio-passthrough.md` and `recipes/track-a-device-across-a-mode-change.md` both implicitly assume the broker can take the kernel handle when it tries.
- A real-world failure mode worth naming: `idevicerestore` itself stops usbmuxd before running (`sudo systemctl stop usbmuxd`), runs the restore, then starts it back. That's the current state-of-the-art for "I need raw access to an iFig device" on Linux; the broker should be able to do better.

**Suggested shape — pick the rule, document it.**

- **Approach A — Refuse-on-conflict (Honest).** The broker detects (via `/proc/<pid>/fd` or `lsof`-style scanning, or by checking the kernel's `bConfigurationValue` + active driver) that the device is held by a non-broker process. `Client.claim(urn, EXCLUSIVE)` raises a typed `ConflictError` with detail "device is held by external process `usbmuxd` (pid 1234); stop the holder, then retry." `OBSERVE` mode still works (read-only via sysfs is unaffected). Consumers explicitly script the daemon-stop themselves. Honest about what the broker controls; preserves the lattice's purity.
- **Approach B — Auto-disconnect with cap-gating.** `Client.claim(urn, EXCLUSIVE, detach=True)` (the existing `USBDEVFS_DISCONNECT_CLAIM` flag, capability-gated by `detach_driver`) is extended to also signal the holding userspace daemon — concretely, the broker sends SIGSTOP to usbmuxd, takes the device, and resumes usbmuxd on release. Or invokes the holder's documented release API if one exists. Powerful but takes a strong stance on broker-as-fleet-coordinator.
- **Approach C — Delegating mode (sit above usbmuxd).** The broker recognises usbmuxd / similar known daemons and, for devices they own, forwards `EXCLUSIVE` claims to "have usbmuxd back off this device" (it supports `USBDEVFS_DISCONNECT` via a documented socket protocol) rather than fighting it. Most useful, most surprising. Probably out of scope for v0.x.

Recommend **A** as the load-bearing v0.x rule. Document it explicitly: "the broker mediates between mgf-hrb tenants, not between mgf-hrb and the rest of the system. External process holds are surfaced as `ConflictError`; the operator scripts the resolution." Consider **B** as opt-in for fleet operators who do want broker-as-coordinator. **C** lives in the design corpus for now.

The recipe `recipes/track-a-device-across-a-mode-change.md` should grow a §0 ("Pre-flight: device readiness") that names the rule + shows the script shape for stopping usbmuxd before the figrecovery flow begins. That sentence would also retire the §B.3-vs-claim ambiguity for every libimobiledevice-shaped consumer.

**Decision rationale (2026-05-27).** **Approach A — refuse-on-conflict** as the v0.x rule. Reasons:

- **Honest about what the broker controls.** The lattice describes coexistence between mgf-hrb tenants. Extending that to coexistence with arbitrary userspace daemons (Approach B's SIGSTOP, Approach C's delegating mode) makes the broker a fleet-wide coordinator — a much bigger scope, harder to reason about, harder to test.
- **Predictable for operators.** "Stop the holder, then retry" is one rule. SIGSTOP-and-resume (B) has corner cases around daemon state restoration; delegate (C) means tracking holder-specific protocols across distro upgrades.
- **B + C remain available later.** B can ship as an opt-in `detach_holder=True` kwarg on `claim()`, capability-gated, when a fleet operator surfaces real demand. C lives in the design corpus until a holder-with-documented-release-API (rare today) surfaces.

**The rule (v0.x):**

> The broker mediates between `mgf-hrb` tenants, NOT between
> `mgf-hrb` and the rest of the system. When a non-broker
> userspace process holds the kernel open-file on a device
> (`usbmuxd` for iFig devices, `bluetoothd` for Bluetooth,
> `pcscd` for security tokens, `cupsd` for some printers), an
> `EXCLUSIVE` or `SHARED_RW` claim raises `ConflictError` with
> detail naming the holding pid + comm. `OBSERVE` mode still
> works (it's read-only via sysfs; daemon holds are
> irrelevant). The operator scripts the resolution
> (`systemctl stop <unit>` before the broker flow; restart
> after).

This commit ships the rule's documentation. The enforcement
code (the `/proc/<pid>/fd` scan that detects external holders
and the `ConflictError` payload shape) lands with USB-02 Slice
B's claim path — until Slice B ships, `Client.claim` against a
USB URN raises `NotFoundError` anyway because no USB worker is
wired, so the daemon-coexistence question doesn't arise yet.

**What ships now:**
- §0 ("Pre-flight: device readiness") added to `recipes/track-a-device-across-a-mode-change.md` per the submitter's request, naming the rule + showing the `systemctl stop usbmuxd` script shape for figrecovery.

**Retrospective.** Rule documented in `c88d5be`'s follow-up commit; enforcement retrospective filled when USB-02 Slice B ships.

---

## §3. Cross-references

- [`INDEX.md`](INDEX.md) — project entry point.
- [`DECISIONS.md`](DECISIONS.md) — the ADR catalog; decisions resolve papers here.
- [`hrb-design/`](hrb-design/) — the design narrative each paper refers into.
