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

### `SDK-16` — an admission-shed connection greets with `TooBusy`, and the SDK reports it as an unparseable greeting instead of `OverloadedError`

| Field | Value |
|---|---|
| Status | OPEN |
| Severity | 🟡 High |
| Submitter | mgf-hrb (M-B chunk B3 court, finding `F-B3-09`) |
| Submitted | 2026-08-13 |
| Decision | pending |
| Decided on | — |
| Scheduled for | — |
| Shipped in | — |

> **Court-id note.** These two papers came out of the M-B chunk B3
> court. The fold COMMITS on `feat/mb-sdk-live` label some findings with
> a permuted numbering (02↔03 and 05↔07 relative to the court's own
> `findings-b3.json`); the Submitter fields below carry the COURT's ids,
> which are the ones a future cross-check will have in hand. Recorded
> rather than silently corrected, because a reader comparing the commit
> messages against the findings file needs to know the offset exists.

**Concern.** The broker sheds an over-cap connection politely: it writes
`{"ok":false,"err":"TooBusy"}` and closes, precisely so the client gets a
structured answer rather than a kernel `ECONNREFUSED`. `admission.zig`
says what is supposed to happen next, in its own words:

```zig
/// The polite "you've hit a connection ceiling, back off" wire line.
/// Shared by the aggregate-cap site and the per-uid-cap site. `TooBusy`
/// maps to `OverloadedError` in the SDK.
pub const TOO_BUSY_LINE: []const u8 = "{\"ok\":false,\"err\":\"TooBusy\"}\n";
```

It does not. `Client.connect` reads the line, fails to find a `cap_id` in
it, and raises the generic handshake error:

```
MgfHrbError: unexpected broker greeting: {'ok': False, 'err': 'TooBusy'}
(expected mgf-hrb hello with a cap_id; path=/tmp/mgf-hrb.sock)
```

(measured against mgf-hrb 0.1.10 + this branch's broker, 2026-08-13)

The mapping `TooBusy → OverloadedError` exists and works — for every op
**after** the handshake, through `_WIRE_TO_EXC`. The handshake is the one
place it is bypassed, because `connect` parses the greeting for a
`cap_id` before anything looks at `err`.

**Why it matters.** The two failures the consumer must tell apart are
"the broker is at capacity, back off and retry" and "the broker answered
something I cannot use, and retrying will not help". `OverloadedError`'s
own docstring tells consumers to back off and retry; `MgfHrbError` with
"unexpected greeting" reads as a protocol mismatch — a version skew, a
rogue process on the socket — which is a *stop and investigate* signal.
A consumer following the message it actually gets does the opposite of
the right thing at exactly the moment the broker asked for back-pressure.

It is also not a rare path. `MAX_CONN_PER_UID` is 32 and every monitor
costs a connection, so a GUI that opens and closes monitors reaches it
routinely — and `SDK-15` (filed alongside this) makes it reachable in a
burst, because a closed monitor's broker-side slot survives for the
remainder of its `wait_ms`. Measured: 31–34 open/close cycles of one
`monitor_fd` at the default 5 s wait exhaust the per-uid ceiling, and the
next connection is shed with the greeting above. The count is the
invariant (`MAX_CONN_PER_UID`, less whatever the process already holds);
the elapsed time is not — 0.016–0.7 s across benches, which measures how
fast the loop issues connects.

**Suggested shape.** In `Client.connect`, before the "unexpected
greeting" branch: if the greeting parses as JSON with `ok: false` and an
`err`, route it through the same `from_wire` mapping every other response
uses. `TooBusy` then arrives as `OverloadedError`, `NotPermitted` as
`NotPermittedError`, and any future shed code is handled the day the
broker learns it — rather than each one needing its own branch here.
Additive: every existing `except MgfHrbError` still catches all of them,
since the typed errors are subclasses.

This also composes with `SDK-13` (typed handshake-timeout): together they
turn `connect`'s single opaque failure into the three operationally
distinct ones — *the broker is full*, *the broker is starting*, *the peer
is not a broker*.

**Cost if deferred past v1.0.** N/A contractually — the narrowing is
additive. The standing cost is that consumers either substring-match
`"TooBusy"` out of SDK prose or treat a back-pressure signal as a
protocol error, and the first breaks silently on any message edit.

**Decision rationale.** _Filled when triaged._

**Retrospective.** _Filled when shipped._

---

### `SDK-15` — a long-poll waiter cannot see its connection die, so a closed monitor holds a broker slot for the rest of its `wait_ms`

| Field | Value |
|---|---|
| Status | OPEN |
| Severity | 🟡 High |
| Submitter | mgf-hrb (M-B chunk B3 court, finding `F-B3-02`) |
| Submitted | 2026-08-13 |
| Decision | pending |
| Decided on | — |
| Scheduled for | — |
| Shipped in | — |

> **Court-id note.** These two papers came out of the M-B chunk B3
> court. The fold COMMITS on `feat/mb-sdk-live` label some findings with
> a permuted numbering (02↔03 and 05↔07 relative to the court's own
> `findings-b3.json`); the Submitter fields below carry the COURT's ids,
> which are the ones a future cross-check will have in hand. Recorded
> rather than silently corrected, because a reader comparing the commit
> messages against the findings file needs to know the offset exists.

**Concern.** M-B B2 gave `drain_events` a `wait_ms` long poll: the
connection's thread blocks on the registry condvar until an event
arrives, the subscription dies, or the timeout lapses. It does not wait
on the socket. So when the client goes away mid-wait — `close()`,
process exit, `SIGKILL`, a pulled cable — the FIN reaches a socket
nobody is reading, and the broker thread stays parked until its timeout
runs out. Everything that thread owns stays with it: the connection's
admission slot (`MAX_CONN_PER_UID` = 32, `MAX_ACTIVE_CLIENTS` = 128), its
subscriptions, its hidden legacy-drain subscription, and the thread
itself.

The client cannot paper over this. mgf-hrb's own SDK shuts the socket
down on `close()` so its OWN teardown is immediate (0.4 ms with a 5 s
poll in flight) — the broker-side slot is released on its own schedule
regardless, and no client-side action can shorten it.

**Why it matters.** It converts a client-side rate into a broker-side
ceiling. With the default 5 s wait, a consumer that opens and closes
monitors — a GUI reconnecting, a CLI run in a loop, a test suite — can
hold all 32 of a uid's slots without 32 monitors ever existing at once.

The invariant, stated as two numbers so neither is mistaken for a
benchmark: the ceiling is `MAX_CONN_PER_UID` = **32 live connections per
uid**, and a closed monitor's slot returns **one `wait_ms` after its
close**. The sustainable churn rate is therefore 32 cycles per `wait_ms`
— about six a second at the 5 s default, 32 per *thirty seconds* at the
ceiling — and it has nothing to do with the consumer's actual
concurrency. Reaching the ceiling from cold is bounded only by how fast
the loop issues connects: **31–34 cycles observed, in 0.016–0.7 s
depending on the bench and on what the loop did between them**. Every
slot consumed past the first is the corpse of an already-closed
connection.

Two second-order effects worth naming. The shed connection's error is
currently unreadable as back-pressure (filed as `SDK-16`), so the
consumer meets this as a protocol error rather than a capacity one. And
the natural consumer response to a monitor that seems stuck — close it
and open another — is precisely the action that consumes the next slot.

**Concrete evidence** (this branch's broker, 2026-08-13):

- `subscription.zig`'s `drainSubscriptionWait` waits on the registry
  condvar with a timeout; the wake sources are `pushFanout`'s commit
  phase, `reapPeer`, and the timeout. Socket state is not among them.
- `server.zig`'s per-connection thread reaps the peer
  (`subscription.reapPeer`) and releases the admission slot in its
  `defer`, which runs when the read loop returns — and the read loop
  cannot return while dispatch is blocked.
- Measured hold: up to the full `wait_ms` (14.7 s observed at
  `wait_ms=15000`). Slot exhaustion reproduced independently at 31 and at
  34 cycles on two benches — the spread is what the measuring process
  already held open, not variance in the ceiling itself.

**Suggested shape.** Make the waiter observe connection death alongside
the condvar. The shape that fits the existing thread-per-connection
design is to have the blocked drain wait on BOTH the condvar and a
`poll()` of its own connection fd for `POLLHUP`/`POLLERR` — the same
readiness the read loop would have seen — and return early (an empty,
successful answer) when the peer is gone, so the existing `defer` path
reaps normally. A timer-sliced variant (wake every N ms, poll the fd,
re-wait) is simpler and costs a wakeup per slice per waiter; an
eventfd-per-connection signalled by a socket-watching reaper is more
work and wakes exactly once. Any of them turns "up to `wait_ms`" into
"one slice".

Explicitly NOT suggested: lowering the `wait_ms` ceiling. The ceiling is
what makes long-polling worth doing, and shortening it would trade a
correctness fix for a latency regression.

**Cost if deferred past v1.0.** Nothing contractual — no wire shape
changes either way. The standing cost is a capacity ceiling that scales
with a *timeout* rather than with concurrency, and a consumer whose
remedy for it (close and retry) is also its cause. B3 documents the hold
in `PUBLIC_API.md` so consumers can price it; documentation is the
mitigation available on the client side, and it is not a fix.

**Decision rationale.** _Filled when triaged._

**Retrospective.** _Filled when shipped._

---

### `SDK-14` — `Resource` carries no `kind_raw`/`family_raw`, so only the DETAIL path can be honest about an unknown kind

| Field | Value |
|---|---|
| Status | OPEN |
| Severity | 🟢 Low |
| Submitter | hrb-app (M-A chunk A3 court, finding `F-A3-03`) |
| Submitted | 2026-08-13 |
| Decision | pending |
| Decided on | — |
| Scheduled for | — |
| Shipped in | — |

**Concern.** `SDK-11` (shipped in 0.1.10) did exactly the right thing for
`ResourceDetail`: an unrecognised wire `kind` becomes `Kind.UNKNOWN`
instead of being coerced to `pcie_function`, and the broker's own string
is preserved in `kind_raw` / `family_raw` so a consumer can render
`unknown (nvme_namespace)`. `Resource` — the richer type on the LIST path
— got the enum half and not the raw half:

```
Resource:        urn, family, kind, identity_class, fidelity, parent,
                 state, vid, pid, serial_number, extra
ResourceDetail:  urn, family, kind, fidelity, attrs, parent, state,
                 kind_raw, family_raw
```

(measured against `mgf-hrb` 0.1.10 via `dataclasses.fields`, 2026-08-13)

So a consumer rendering a LIST can say "unknown" and cannot say what the
broker called it. The same device is honest in a detail view and mute in
the list that led the operator there.

**Why it matters.** The asymmetry is invisible until you build the second
surface. hrb-app hit it as a gate: M-A's exit criterion MA-4 is "unknown
kind/family render honestly end-to-end", and the app-side proof had to be
an INSPECT-pane pin rather than the tree-render pin the milestone
originally named — not because the tree was wrong, but because no data
reaching it can carry the fact. (hrb-app's tree is fed by `Client.list`,
which returns `list[str]`, so it is doubly out of reach here; the point
stands for any consumer that uses `Resource` itself.)

A list is also where the operator meets an unclassifiable device FIRST.
"There is something here I cannot name" is a worse thing to read in a list
of forty rows than in a detail pane, because in the list it is the only
thing you have.

**Suggested shape.** Add `kind_raw: str | None = None` and
`family_raw: str | None = None` to `Resource`, populated by its `from_wire`
exactly as `ResourceDetail.from_wire` already does, `None` when the enum
matched. Purely additive; every existing consumer is untouched; the two
types stop disagreeing about how honest they are allowed to be.

**Cost if deferred past v1.0.** N/A contractually — additive either way.
The standing cost is that every list-rendering consumer either shows a
bare "unknown" or issues an extra `inspect()` per unrecognised row purely
to recover a string the list response already contained.

**Decision rationale.** _Filled when triaged._

**Retrospective.** _Filled when shipped._

---

### `SDK-13` — a wedged hello and a refused hello are the same exception type, so consumers cannot word them apart

| Field | Value |
|---|---|
| Status | OPEN |
| Severity | 🟢 Low |
| Submitter | hrb-app (M-A chunk A2 court, finding `F-A2-03`) |
| Submitted | 2026-08-13 |
| Decision | pending |
| Decided on | — |
| Scheduled for | — |
| Shipped in | — |

**Concern.** `Client.connect` raises a bare `MgfHrbError` for every
handshake-phase failure, and two of them are operationally opposite:

- the broker **answered and declined** — `{"ok": false, "err":
  "NotPermitted"}`, an unknown greeting, a hello with no `cap_id`. The
  broker is up and this will not change with time; the fix is to restart
  it (`CAP-01`'s root-cap binding is the common cause).
- the peer **accepted and never spoke** — `hello_deadline_ms` expired.
  Zero bytes arrived. The most likely cause is a broker still starting
  up, and the fix is to *wait a moment and try again*.

Both arrive as `MgfHrbError` with only the human-readable message
differing (`_client.py:955-984` — the timeout branch and the generic
handshake branch construct the same class). A consumer that wants to tell
an operator what to do has to either substring-match the SDK's own
message — which is not a contract — or give up and say something true of
both.

**Why it matters.** This is a *remedy* difference, not a cosmetic one.
hrb-app's disconnected panel offers exactly one action, and the two cases
want opposite advice: "restart the broker" is wrong for a broker that is
still starting (it bounces a service that was about to work), and "wait
and retry" is wrong for a bound capability (the operator watches an
attempt counter climb and concludes the GUI is broken — the failure mode
this repo's `PAPER-01` documented from the field).

hrb-app's own court found the app shipping the first of those errors: the
`OI-17` copy, written for the decline case, told a wedged-hello operator
that the broker "answered" (it had sent nothing) and that retry could
never clear it (it can).

**Concrete evidence** (mgf-hrb `0.1.10`, `src/mgf/hrb/_client.py`):

```
# hello read timed out — peer accepted, never spoke
raise MgfHrbError(
    f"broker hello not received within {hello_deadline_ms}ms "
    f"(path={path}); broker may be wedged or speak no hello"
) from exc

# broker answered with a greeting we cannot use
raise MgfHrbError(
    f"unexpected broker greeting: {hello!r} "
    f"(expected mgf-hrb hello with a cap_id; path={path})"
)
```

Consumer-side workaround now shipped in hrb-app (M-A A2): both cases are
classified as one `phase="refused"`, and the operator-facing copy is
narrowed to the only claim true of both — "it accepted the connection but
did not complete the handshake" — with BOTH remedies offered in the order
to try them. It works, and it is strictly less useful than being able to
say which one happened.

**Suggested shape.** Either is fine; the first is smaller.

1. A typed `HandshakeTimeoutError(MgfHrbError)` raised only from the
   hello-deadline branch. Additive, back-compatible (every existing
   `except MgfHrbError` still catches it), and enough on its own — the
   remaining handshake failures all share the "the broker answered"
   property.
2. Or a `phase` attribute on the exception the SDK raises from `connect`,
   distinguishing `"hello_timeout"` from `"greeting_rejected"`. More
   general, and it would let this seam stop guessing entirely.

Not asked for: changing the messages. They are good; they are just not a
type.

**Cost if deferred past v1.0.** N/A contractually — purely additive
either way. The standing cost is that every consumer with an
operator-facing surface either substring-matches SDK prose or waters its
advice down to what is true of both cases, and the first of those breaks
silently on any message edit.

**Decision rationale.** _Filled when triaged._

**Retrospective.** _Filled when shipped._

---

### `SDK-12` — `mgf.hrb.testing` is designed and unshipped, so every consumer hand-rolls a fake of the wire contract

| Field | Value |
|---|---|
| Status | OPEN |
| Severity | 🟡 High |
| Submitter | mgf-hrb (split out of `DOC-05` item 4, commit `18c8df6`) |
| Submitted | 2026-08-12 |
| Decision | pending |
| Decided on | — |
| Scheduled for | — |
| Shipped in | — |

**Concern.** `mgf.hrb.testing` — `FakeClient` / `ScriptedBus` / `install_fake`
— is documented as designed-but-unshipped in two places (`STARTHERE.md:60` and
`:242`) and does not exist under `src/mgf/hrb/`. It arrived as item 4 of
`DOC-05`, whose other three items were documentation fixes; this one is a
scheduling decision, and closing it inside a docs commit would have been
exactly the status-that-disagrees-with-reality this file's closing discipline
is about. Refiled so it is visible as open work.

**Why it matters.** Every consumer that needs to test against the SDK without a
broker writes its own fake, and each fake is a private, untested restatement of
the wire contract. `SDK-06` already recorded what a fake that diverges from the
handshake costs: a test suite that hangs silently, and ~10 minutes of bisecting
before the cause was clear. hrb-app is the named waiting consumer — it
hand-rolls its SDK fakes today.

**Concrete evidence** (mgf-hrb `main`, `__version__ = "0.1.9"`):
- `STARTHERE.md:60` — "The `mgf.hrb.testing` injection surface (`FakeClient` /
  `ScriptedBus`) is designed but not yet shipped — that's the remaining gap."
- `STARTHERE.md:242` — the same surface listed under what is not yet built.
- No `testing` module exists under `src/mgf/hrb/`.
- The in-repo precedent for what a good fake must do: `SDK-06`'s pin
  (`tests/unit/mgf/hrb/test_sdk06_hello_deadline.py`) and the reference fake
  shape in `PUBLIC_API.md`'s **Handshake** section, which exists precisely
  because consumer fakes kept getting the hello wrong.

**Suggested shape.** The minimum useful slice, per `DOC-05`: `FakeClient`
honouring the hello handshake (`SDK-06`), plus `install_fake` for GUI tests.
Worth adding, given this chunk: the fake should also honour `deadline_ms` —
a consumer testing its own timeout handling needs a fake that can be told to
wedge, and A1's `_FakeBroker` wedge fixture
(`tests/unit/mgf/hrb/test_sdk10_deadlines.py`) is a working prototype of
exactly that, currently private to this repo's tests.

**Cost if deferred past v1.0.** N/A contractually (purely additive). The
standing cost compounds: each consumer's private fake is a copy of the wire
contract that no CI compares against the real one, and they diverge silently —
the failure is a test suite that passes against a broker that would not.

**Decision rationale.** _Filled when triaged._

**Retrospective.** _Filled when shipped._

### `RT-01` — the broker's listen socket is `SOCK_STREAM`; D-021 (Accepted) says `SOCK_SEQPACKET`, and the carved runtime honours D-021

| Field | Value |
|---|---|
| Status | OPEN |
| Severity | 🟡 High |
| Submitter | hrb-app (OI-21 intent check, 2026-08-07 estate review) |
| Submitted | 2026-08-08 |
| Decision | pending — two honest exits, maintainer's call |
| Decided on | — |
| Scheduled for | — |
| Shipped in | — |

**Concern.** ADR **D-021** is titled *"SOCK_SEQPACKET, not STREAM or
DGRAM"*, is **Accepted**, and gives the rationale: kernel-preserved
message boundaries + SCM_RIGHTS + connection-death liveness
(`DECISIONS.md:263`). The estate now implements it both ways at once:

- the carved runtime crate honours it — `mgf-hrb-rt`'s
  `liveness.zig:24` creates `SOCK.SEQPACKET` socketpairs, citing the
  same design §5.2;
- the broker's own public listen socket does not —
  `hrb-code/src/server.zig:315` opens
  `SOCK.STREAM | SOCK.CLOEXEC`, and compensates with hand-rolled
  newline line-framing (`server.zig:463`: *"The trailing `\n` is the
  line-framing terminator"*) — exactly the framing D-021 says
  SEQPACKET exists to make unnecessary. The private-helper channels
  (`priv_ipc.zig`, `usb_backend_via_ipc.zig`) are STREAM socketpairs
  too.

**Why it matters.** Functionally the broker works — EOF/RST liveness
and SCM_RIGHTS both hold on STREAM — so this is an intent divergence,
not an outage. But an Accepted ADR that the reference implementation
contradicts is a standing trap: the Python SDK, the framing layer,
and every future wire client are being built against behaviour the
design record disavows, and D-021's *"on the future kernel this
becomes THE IPC primitive"* claim silently loses its bridge.

**Two honest exits** (the paper takes no side):

1. **Make the code match the ADR** — migrate the listen socket (and
   SDK connect) to `SOCK_SEQPACKET`, retiring the newline framing.
   Wire-visible; needs a coordinated SDK release.
2. **Make the ADR match the code** — supersede D-021 with a decision
   that blesses STREAM + line-framing and records why (portability of
   client stacks, buffered-reader ergonomics, the framing already
   shipped), keeping SEQPACKET for the rt crate's internal pairs
   where it already lives.

Either exit closes hrb-app's `OI-21`; what cannot stand is an
Accepted "not STREAM" next to a STREAM listener.

### `SDK-09` — No enumerate-drivers verb: `driver_info(name)` requires a name the consumer can only get from sysfs

| Field | Value |
|---|---|
| Status | OPEN |
| Severity | 🟢 Nice-to-have |
| Submitter | hrb-app (commit `96aeb42`) |
| Submitted | 2026-07-29 |
| Decision | pending |
| Decided on | — |
| Scheduled for | — |
| Shipped in | — |

**Concern.** `driver_info(name)` reports the broker-visible interface of a kernel driver, but there is no verb that answers "which drivers are there?". A consumer that wants to offer a bind-driver choice therefore cannot start from the SDK: it has to enumerate `/sys/bus/usb/drivers` itself, then feed the names back into `driver_info()`. That is a sysfs bypass in a consumer of a broker whose reason for existing is to be the single mediated path to that information — and the SDK already owns the other half of the round trip.

**Why it matters.** hrb-app's bind-driver dropdown reads `/sys/bus/usb/drivers` directly today. Every consumer that needs the same list will write the same directory walk, each with its own answer to the questions the broker has already answered: which bus subtrees count, what happens under a container or a restricted mount namespace, how a name maps to a URN's kind. `control(verb="bind_driver", value=<name>)` is a first-class SDK verb, so the SDK offers the *action* on a driver while the *list* of drivers must be obtained behind its back.

**Concrete evidence.**
- `driver_info(self, name: str) -> DriverInfo` — `src/mgf/hrb/_client.py:1188-1192` ("Report the broker-visible interface of a kernel driver (Part 13 §5.2.2)"); the name is required and there is no default.
- No enumeration verb exists: a search of `src/mgf/hrb/` for `list_drivers` / `drivers_list` / `bus/usb/drivers` returns nothing.
- The action side ships: `control(urn, verb, value, *, cap_id=0)` with `bind_driver` / `unbind_driver` among the accepted verbs — `:916-930`.
- The consumer-side bypass: hrb-app's bind-driver dropdown enumerates `/sys/bus/usb/drivers` directly.

**Suggested shape.** `list_drivers(bus: str | None = None, *, deadline_ms: int = 0) -> DriverList` — one `DriverInfo`-shaped record per driver the broker can see (or a light `(name, bus, bound_count)` projection with `driver_info()` remaining the deep read), scoped by bus so a USB console does not page in PCI. Returning the same typed record type as `driver_info()` keeps one shape for one concept; the natural home is next to `driver_info` in the Part-13 §5.2 read family.

**Cost if deferred past v1.0.** N/A (additive read verb). The standing cost is that each consumer's sysfs walk becomes load-bearing UI code, and those walks are precisely what breaks first in a container, on a non-USB bus, or on the day the broker gains a view of drivers the filesystem does not show.

**Decision rationale.** _Filled when triaged._

**Retrospective.** _Filled when shipped._

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

## §4. Shipped / resolved

> Papers that are done. A paper's status flips in the commit that ships it and
> it moves here in the same act — see *Closing discipline* at the foot of this
> file.
>
> Everything from `CORE-02` down arrived as a batch on 2026-08-11 rather than
> one at a time: those entries' own `Status` fields already said SHIPPED or
> RESOLVED while they sat under *§2. Open feedback (active)*, and `CI-01` sat
> under *§3. Cross-references*, where nobody looking for open work would ever
> see it. Nothing in that batch was new work: the flip had happened, the move
> had not.

### `SDK-08` — `monitor_fd` cannot consume a subscription: server-side predicate filtering is unreachable from the fd path

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | hrb-app (commit `96aeb42`) |
| Submitted | 2026-07-29 |
| Decision | accepted — Approach A's REACH via a third route the paper did not consider (see rationale) |
| Decided on | 2026-08-12 |
| Scheduled for | **0.1.11** (milestone M-B, chunk B3) |
| Shipped in | **0.1.11** — `0d1c7b3` (the monitor family becomes one subscription-consuming event path), `81713ba` (the real-broker pins), `d217532` (the typed predicate builders the fd path now has something to pass) |

**Concern.** The SDK ships two features that cannot be combined. `subscribe(sub_id, scope, *, predicate=...)` registers a **server-side** event filter (Part 13 §5.4.2) and `drain_events(scope, *, subscription_id=...)` drains it. But the three-layer event API — `monitor()`, `amonitor()`, `monitor_fd()` — accepts only `scope`; none of them takes a `subscription_id` or a `Subscription`. So a consumer whose entire event path is the fd (`monitor_fd()` + `QSocketNotifier`/`add_reader`, the integration the fd layer exists for) cannot use predicates at all. The only way to combine them is to abandon the fd and hand-roll a `drain_events(subscription_id=...)` poll loop — which reintroduces exactly the polling the fd was built to remove, and gives up the readable-fd semantics the GUI event loop wants.

**Why it matters.** hrb-app's whole event path is `monitor_fd` + `add_reader` under qasync, so predicate filtering — the feature that keeps a busy bus from waking the GUI on every unrelated event — is unavailable to the one consumer shape it matters most for. The cost is paid on the wrong side of the wire: without a server-side filter the broker ships every event in `scope` and the GUI thread discards most of them, per event, forever. And because both halves exist and are documented, a consumer reads the surface, assumes composition, and only discovers otherwise at the signature.

**Concrete evidence** (`src/mgf/hrb/_client.py`, mgf-hrb 0.1.8):
- `subscribe(self, sub_id: str, scope: str = "hw:/**", *, predicate: dict[str, Any] | None = None) -> Subscription` — `:838-844`.
- `drain_events(self, scope: str = "hw:/**", *, subscription_id: str | None = None) -> EventList` — `:804-809` ("**Subscriber mode** (`subscription_id` set)").
- `monitor(self, scope: str = "hw:/**", *, poll_interval_s: float = 0.5)` — `:893-898`; `monitor_fd(self, scope: str = "hw:/**", *, poll_interval_s: float = 0.5) -> int` — `:1769-1774`; `amonitor(...)` — `:1908`. No subscription parameter on any of the three.
- The fd layer's own stated purpose: `:1775-1779` ("Designed for Qt/Gtk/asyncio integrations that take an fd").
- Consumer path: hrb-app drives its event stream from `monitor_fd()` under qasync (`add_reader`), per its GUI event-path design.

**Suggested shape.**

**Approach A (recommended) — accept a subscription on all three layers.** `monitor_fd(*, subscription_id: str | None = None)` (and the same on `monitor` / `amonitor`), mutually exclusive with `scope`, passed straight through to the `drain_events` call the monitor thread already makes. The change is small precisely because the pump already speaks subscriber mode; the only thing missing is a way for the caller to say so.

**Approach B — put it on `Subscription`.** `Subscription.fileno()` (or `.monitor_fd()`), so the object returned by `subscribe()` is itself the thing a GUI registers. Nicer to read at the call site and it makes the lifetime obvious (closing the subscription closes the fd), at the cost of a widened `Subscription` and a second place that owns a monitor thread. A is the smaller move; B is the better API if `Subscription` is meant to grow.

**Cost if deferred past v1.0.** Additive either way, so nothing is foreclosed — but every event-driven GUI consumer built before it lands ships a client-side filter it will later delete, and the per-event waste is invisible in tests (a quiet bus filters fine) and only shows up on a busy one.

**Decision rationale.** Accepted, and neither Approach A nor B as
written. Both proposed letting the CALLER hand a subscription to the
monitor, which cannot work: subscriptions are keyed to the CONNECTION
broker-side (`server.zig`'s peer key), and the monitor's pump runs on its
own second connection — so a `subscription_id` registered on the caller's
connection is not merely inconvenient to pass, it is unreachable from the
pump by construction. Passing it through would have produced `NotFound`
at the first drain.

The obstacle dissolves from the other end: the monitor subscribes on the
connection it drains. Every shape (`monitor` / `amonitor` / `monitor_fd`)
now opens its own connection, registers the subscription there, and
long-polls it, so the caller passes a `predicate` — the thing they
actually wanted — and never handles an id at all. That also closes the
paper's real complaint ("the cost is paid on the wrong side of the wire")
more completely than A would have, because it applies to all three
layers rather than to whichever one the consumer remembered to wire up.

**Retrospective.** Shipped in **0.1.11** (M-B chunk B3). What the paper
did not foresee, and what the work found:

- **The fd layer was never the hard part.** Once the subscribe moves to
  the pump's own connection, the fd path gets predicates for free — the
  pump was already speaking subscriber mode, as the paper noted. The
  work that remained was the WAIT: a subscription you poll every 500 ms
  is still polling. `wait_ms` (shipped broker-side in B2) is what makes
  the filter worth having, and the two land together for that reason.
  Measured event → fd readable: **0.13–0.87 ms**.
- **The same move fixed a hazard the paper never mentions.** Giving
  `monitor`/`amonitor` their own connection is not symmetry — a long
  poll holds `_Transport.call`'s lock for the whole round trip, so a
  monitor sharing the caller's `Client` would park that lock for
  `wait_ms` at a time and re-open the amplifier `SDK-10` closed.
- **The default filter is deliberately NOT `urn_glob(scope)`.** A `gap`
  event carries no URN, so a `urn_glob` PREDICATE evaluates false for it
  and drops every gap. Subtree filtering rides the subscribe's `scope`,
  which the broker applies gap-honestly (D-022). The broker's own suite
  pins both halves.
- **One consumer-visible narrowing:** a `Client` built over a bare
  transport (no socket path) can no longer monitor, because there is no
  address to open the monitor's connection to. It raises `ValueError`
  naming the fix.

Follow-on papers filed from the same work: `SDK-15` (a long-poll waiter
cannot see its connection die) and `SDK-16` (`TooBusy` at the handshake
is not typed as `OverloadedError`).

### `SDK-10` — `control()` is a write with no deadline, and the whole read path has none either — one wedged broker stalls every call on the `Client`

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 Nice-to-have |
| Submitter | hrb-app (commit `96aeb42`) |
| Submitted | 2026-07-29 |
| Decision | accepted — Approach A **and** B (they compose) |
| Decided on | 2026-08-12 |
| Scheduled for | **0.1.10** (milestone M-A, chunk A1) |
| Shipped in | **0.1.10** — `9a2eb22` (deadline plumbing + lock bound + bounded pump), `3d90fac` (the wedge fixture + 110 pins) |

**Concern.** Start with the one that is not a symmetry argument: **`control()` is a state-mutating write and it takes no `deadline_ms`.** Its signature is `control(urn, verb, value, *, cap_id=0)` — it accepts the writer's capability id but no deadline, while every other mutating verb on the `Client` (`claim`, `release`, `revoke`, `recover`, `raw`, `irq_scan`, and `Lease.release` / `Lease.raw`) takes one. So the verb that authorizes a device, powers a port, or binds a driver is the one write a consumer cannot bound. (`attenuate()` — also a wire op, `:1562` — is the second instance.) The read path then generalizes the same gap: `list`, `inspect`, `inspect_full`, `dump_all`, `dump_chain`, `capabilities`, `driver_info`, `audit_tail`, `state_snapshot`, `drain_events`, `subscribe`/`unsubscribe` and `doctor` accept no deadline either. That the asymmetry is drift rather than policy is visible in the newest read verb: `list_leases`, added for SDK-03, **does** take `deadline_ms`.

**Why it matters.** `_Transport.call` is serialized by an internal lock (the SDK-01 retrospective made this explicit: "`_Transport.call` is now serialized by an internal lock, so multiple threads / coroutines can share a single `Client`"). One unbounded op therefore does not hang alone — while a wedged broker holds a `capabilities()` or a `control()`, every other call on that `Client` waits behind the lock, including the ones that were given finite deadlines. For a GUI that shares one `Client` between the event pump and the operator's actions, the consequence is that a single unbounded verb converts a slow broker into a frozen application, and the deadline the consumer carefully passed to `claim()` cannot protect it. hrb-app now passes finite deadlines everywhere the signature allows; the ops above are the residue it cannot bound, and `control()` is the one with side effects.

**Concrete evidence** (`src/mgf/hrb/_client.py`, mgf-hrb 0.1.8):
- **Write without a deadline:** `control(self, urn: str, verb: ControlVerb | str, value: str | bool, *, cap_id: int = 0) -> ControlOutcome` — `:916-923`; the wire call at `:960`. `attenuate(...)` — `:1506-1512`, wire call at `:1562`.
- **Writes with one, for contrast:** `claim` `:1421-1428`, `release` `:1453`, `revoke` `:1462`, `recover` `:1574`, `irq_scan` `:1636`, `raw` `:1655`, `Lease.release` `:526`, `Lease.raw` `:534`.
- **Reads without one:** `list` `:786`, `drain_events` `:804`, `subscribe` `:838`, `unsubscribe` `:882`, `inspect` `:984`, `inspect_raw` `:1004`, `dump_all` `:1013`, `inspect_full` `:1048`, `dump_chain` `:1103`, `capabilities` `:1159`, `driver_info` `:1188`, `audit_tail` `:1225`, `state_snapshot` `:1276`, `doctor` `:1382`.
- **The counter-example that shows it is accretion:** `list_leases(self, scope="hw:/**", *, deadline_ms: int = 0)` — `:1344-1349`.
- **Why one op hangs all of them:** `_Transport.call` takes `self._lock` for the whole round trip — `:197-198`, `_call_locked` at `:245`; SDK-01's retrospective (this file) records the lock as deliberate.

**Suggested shape.**

**Approach A (recommended) — `deadline_ms` on every verb that touches the wire, `control()` first.** Keyword-only, defaulted to today's behaviour (`0` = broker default), so it is source-compatible everywhere; `control()` should also be able to say "I will wait this long for a device-state change to be acknowledged", which is a real operator-facing number, not just a timeout. Then the read family, in the same pass, so the surface has one rule instead of a per-verb history.

**Approach B — a client-level default.** `Client(..., default_deadline_ms=...)` applied to any op that did not pass one. Fewer signature changes and one place for a GUI to set policy; but it hides the number from the call site and does not give `control()` the per-call budget it deserves. B is a reasonable complement to A, not a substitute.

**Cost if deferred past v1.0.** `deadline_ms` is keyword-only and defaulted, so adding it later is not breaking — the cost is behavioural, not contractual: until it lands, no consumer can bound `control()`, and the transport lock means one unbounded op defeats every deadline the consumer did pass. That is a liveness property of the whole client, and it is the kind of thing 1.0 is read as promising.

**Resolution (2026-08-12).** Both approaches, because the paper was right
that B is "a reasonable complement to A, not a substitute".

**A — `deadline_ms` on every wire verb.** All 30 wire-speaking `Client`
methods now take a keyword-only `deadline_ms: int | None = None`,
`control()` included; so do `Lease.release` / `Lease.raw` and
`diagnostics.run_all`. A meta-pin asserts the sweep's verb table is
complete against `Client`'s public methods with a NAMED exemption set,
so the next verb cannot quietly join the 17 that had no deadline.

**B — the client-level default.** `Client.connect(..., default_deadline_ms=0)`
(and the `__init__` equivalent) is the policy knob; a per-call value wins,
`None` defers to it, `0` stays "explicitly unbounded". The default is `0`, so
0.1.9 behaviour is preserved byte-for-byte — including the request dict, since
a zero deadline is simply not attached on the verbs that never carried the key.

**The lock — the half the paper called "a liveness property of the whole
client".** The paper's diagnosis was exact: the deadline bounded the socket and
nothing bounded the wait for the transport, so a wedged unbounded op defeated
every deadline a consumer *did* pass. `_Transport.call` now acquires with
`timeout=` when a positive deadline is in force, raises the typed
`TimeoutError` naming the transport lock when it cannot, and releases in a
`finally` — exactly once on every path. The two-thread pin is
sabotage-verified: reverted to `with self._lock:`, the bounded thread hangs on
`_lock.acquire()` until the pytest timeout.

**Declared, not hidden:** the deadline bounds each blocking PHASE (lock, then
socket), not their sum, so the documented worst case is ~2x `deadline_ms`.
Deducting the lock wait from the socket budget would make a call's broker
timeout depend on unrelated contention; a bounded overshoot is the better
failure mode, and it is written into `PUBLIC_API.md` rather than left for a
consumer to discover.

**Retrospective.** The paper is a good example of a finding whose *second*
paragraph mattered more than its first. "`control()` takes no deadline" is a
missing parameter, fixable in a line. "One unbounded op stalls every deadline
you passed" is a property of the client, and no amount of adding parameters
would have fixed it — a consumer could have passed a deadline to all 30 verbs
and still frozen, because the amplifier is in the lock, not the signature. The
sweep is the cheap half; the `acquire(timeout=)` is the actual close.

Worth recording that the wire needed nothing: the broker's request parser reads
only known keys, so `deadline_ms` on a verb that ignores it is inert. This
whole paper was closable Python-side, which is why it could ship in one chunk.


### `SDK-11` — `ResourceDetail.from_wire` coerces an unknown `kind` to `pcie_function`: the first new hardware kind ships as a silent lie in every consumer surface

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | hrb-app (commit `5c60339`) |
| Submitted | 2026-07-30 |
| Decision | accepted — the suggested shape, both fields |
| Decided on | 2026-08-12 |
| Scheduled for | **0.1.10** (milestone M-A, chunk A1) |
| Shipped in | **0.1.10** — `99187d3` |

**Concern.** `ResourceDetail.from_wire` coerces enum fields defensively — the right call at the seam (G2: never crash the consumer on version skew) — but the fallback chosen for `kind` is a concrete value: `_enum_or_default(Kind, row.get("kind"), Kind.PCIE_FUNCTION)`. Any kind the SDK does not recognise becomes `pcie_function`. This contradicts both of the surface's own contracts: `Kind`'s docstring declares an open registry ("New kinds are additive; consumers should NOT assume this list is exhaustive"), and `from_wire`'s docstring promises the honest fallback ("unknown enum values fall back to ``unknown`` rather than raising") — but `Kind` has no `unknown` member, so the code delivers a specific lie where its own docstring promises an honest shrug. (`family` → `Family.CLAIMABLE` is the same shape one field up; `fidelity` → `FieldFidelity.UNKNOWN` is the honest pattern already in the same call.)

**Why it matters.** `kind` is the discriminator that drives rendering in every consumer surface, and the coercion happens inside the typed projection every consumer shares — so the mis-identification is uniform, not per-app. Verified in hrb-app: the GUI inspect panel and the CLI info renderer both project `.kind` straight from the shared view row, so a future broker kind — the enum's *declared* expectation — renders as a PCIe function in both, identically and silently. The only trace is a DEBUG log: the I1 review note on `_enum_or_default` added logging precisely because "the silent degradation hid a new device variant behind the default" — the observability half of this problem was already found and fixed; the default itself still lies. SDK-02's retrospective addendum (this file) records the per-field-default choice deliberately; this paper is that choice hitting its limit for a required discriminator.

**Concrete evidence** (mgf-hrb `main`, `__version__ = "0.1.8"`):
- The coercion: `src/mgf/hrb/domain.py:198` (`kind=_enum_or_default(Kind, row.get("kind"), Kind.PCIE_FUNCTION)`); `family` at `:197`; the honest `fidelity` counterpart at `:199`.
- The contradicted contracts: `domain.py:69-77` (`Kind` — open-registry docstring, four members, no `unknown`); `domain.py:186-188` (`from_wire` docstring: "unknown enum values fall back to ``unknown`` rather than raising").
- The half-fix that proves the failure mode is real: `domain.py:211-234` (`_enum_or_default`, DEBUG log added by I1: "previously the silent degradation hid a new device variant behind the default").
- Consumer surfaces that inherit the lie identically: `hrb-app/src/hrb_app/view/inspect.py:32` (GUI inspect row) and `hrb-app/src/hrb_app/ui/cli/info.py:162,167` (CLI renderer) — both read the projected `kind` with no access to the raw wire string.

**Suggested shape.** Add `Kind.UNKNOWN = "unknown"` and make it the `from_wire` default — this makes the code agree with its own docstring, and matches `FieldFidelity`'s stated philosophy ("``unknown`` is the system refusing to lie"). Keep the raw wire string reachable so a consumer can render "unknown (`<raw>`)" honestly: cheapest carrier is a forward-compatible `kind_raw: str | None = None` field set only when coercion fell back (the class already has the optional-field convention for exactly this kind of growth), or an entry in `attrs`. Same treatment for `family` if a third family is conceivable — or a one-line docstring note that CLAIMABLE-as-default is policy, so the asymmetry is at least declared.

**Cost if deferred past v1.0.** The change is technically additive (a new enum member plus a default swap), but its cost curve is not: every consumer written before it carries `match`/`if` arms over the current four kinds and renders the default arm as PCI, so the first new hardware kind the broker ships mis-identifies in every deployed console at once, with no WARN and no ERROR — a fleet-wide silent mis-render discovered by an operator staring at the wrong icon. The honest fallback is strictly cheaper the earlier it lands.

**Resolution (2026-08-12).** Implemented as suggested, for `family` too.
`Kind.UNKNOWN = "unknown"` and `Family.UNKNOWN = "unknown"` exist;
`ResourceDetail.from_wire` falls back to them and never to a concrete member;
and the raw wire strings are preserved on new optional
`kind_raw: str | None` / `family_raw: str | None`, appended after `state` so
positional construction written against 0.1.9 is unaffected. The intended
render — `"unknown (<raw>)"` — is documented in `PUBLIC_API.md`.

The paper offered "or a one-line docstring note that CLAIMABLE-as-default is
policy" as the cheaper exit for `family`. Declined: nobody could state that
policy, and an asymmetry that exists only because no one revisited it is not a
policy. Both fields now behave the same way, which is one rule to remember
instead of two.

One distinction the paper did not have to make, and the implementation did: a
broker that *itself* reports `"unknown"` MATCHES the new member, so `*_raw`
stays `None`. `kind_raw is not None` therefore means precisely "this SDK is
older than this broker" — honest broker emptiness and version skew are
different facts and now read differently.

`_enum_or_unknown` is the new two-value coercion; `_enum_or_default` survives
for `fidelity`, whose default IS a real member.

**Retrospective.** The paper's sharpest observation was that the observability
half had already been fixed and the honesty half had not: the I1 review added a
DEBUG line to `_enum_or_default` *because* "the silent degradation hid a new
device variant behind the default" — and then left the default lying. Logging
the lie is not the same as not telling it. Three existing pins asserted the old
behaviour and had to be rewritten, which is the concrete measure of how far a
default like this travels once it ships.

Shipped in `99187d3` with 16 new pins covering the four coercion states, field
independence, non-string wire values, additive field ordering and hashability.


### `DOC-04` — `PUBLIC_API.md` drifted from `PUBLIC_API.json`: a consumer's promotion gate false-negatived for two months

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟡 High |
| Submitter | hrb-app (commit `96aeb42`) |
| Submitted | 2026-07-29 |
| Decision | accepted — Approach B's honesty, Approach A's gate |
| Decided on | 2026-08-12 |
| Scheduled for | **0.1.10** (milestone M-A, chunk A1) |
| Shipped in | **0.1.10** — `18c8df6` |

**Concern.** `PUBLIC_API.md` says two things that cannot both be true: that its tables are the contract surface ("**What is NOT listed here is NOT public**") and that `PUBLIC_API.json` "is generated from these `__all__`s in CI and pinned". The `.json` is in fact generated; the `.md` tables are hand-written, and they have fallen behind. 18 of the 72 names in `PUBLIC_API.json` do not appear anywhere in `PUBLIC_API.md`, 12 of them `mgf.hrb.__all__` exports; and 8 shipped `Client` verbs — the whole Part-13 read/observability surface — appear **zero** times in the `.md`. Because the `.md` carries the exclusion rule and the `.json` carries the truth, a consumer that obeys the document is told a shipped surface does not exist.

**Why it matters.** This is not a documentation nit for a consumer with a promotion gate: hrb-app treats `PUBLIC_API.md` as the authority for what it is allowed to wrap (`hrb-app/STARTHERE.md:156`, `hrb-app/docs/CLAUDE.md:390`, `hrb-app/docs/inprogress/PRESENTATION_LAYER_RESEARCH.md:201`), and its adoption check is a grep of that file. The Part-13 surface shipped in the SDK; hrb-app's gate reported "not promoted" from **2026-05-26 until 2026-07-29**, and the app kept a hand-rolled substitute (or simply did without the verb) for two months while the real thing sat exported and tested. The failure mode is the worst kind: the gate was green, the answer was wrong, and nothing in either repo could notice — the `.json` check passes because the `.json` is generated, and the `.md` has no check at all.

**Concrete evidence** (mgf-hrb `main`, `__version__ = "0.1.8"` at `src/mgf/hrb/__init__.py:97`, verified 2026-07-29):
- **The two claims:** `PUBLIC_API.md:8-9` ("the JSON companion … is generated from these `__all__`s in CI and pinned") and `PUBLIC_API.md:10` ("What is NOT listed here is NOT public").
- **Names in `PUBLIC_API.json` and absent from `PUBLIC_API.md` (18):** `ChainEntry`, `ControlOutcome`, `DriverInfo`, `DumpAllResult`, `DumpChainResult`, `InspectFullResult`, `InspectNode`, `InspectTree`, `Subscription`, `SupersededBy`, `UnavailableError`, `WorkerSnapshot` (all 12 exported from `mgf.hrb.__all__`), plus `MAX_PORT_DEPTH`, `MAX_SERIAL_BYTES`, `check_usbfs_devnode`, `check_usbfs_openable`, `from_wire`, `now_ms`.
- **Shipped `Client` verbs with zero occurrences in the `.md`:** `driver_info` (`_client.py:1188`), `dump_all` (`:1013`), `subscribe` / `unsubscribe` (`:838`, `:882`), `audit_tail` (`:1225`), `state_snapshot` (`:1276`), `inspect_full` (`:1048`), `dump_chain` (`:1103`). Method-level surface is not covered by the generated `.json` either, so for these eight verbs **neither** artifact is authoritative.
- **The reverse direction happens too:** `list_leases` appears in the `.md` and not in the `.json` — the `.json` only knows `__all__` names, so the two files are not comparable as written, which is why nothing has been comparing them.

**Suggested shape.**

**Approach A (recommended) — generate the `.md` tables from the same source (DP-14, one source of truth).** The `__all__`-derived tables in `PUBLIC_API.md` become generated output (names + signatures + one-line summaries pulled from the docstrings), with the hand-written prose kept as prose around them, and a CI drift check that fails when the generated block does not match. Extend the generator's scope to **`Client` methods**, since the eight-verb hole above is exactly the part `__all__` cannot see — the consumer contract for an SDK is its verbs, not only its type names.

**Approach B — demote the `.md` and re-point consumers.** Rewrite `PUBLIC_API.md` as orientation prose, delete the "NOT listed here is NOT public" rule from it, and state plainly that `PUBLIC_API.json` is the machine-readable contract that gates belong on. Cheaper, and honest — but it leaves human readers with no table, and it moves work onto every consumer's gate (hrb-app's included).

**Cost if deferred past v1.0.** The `.md` is what AP-11's stability tier is written on, so the drift compounds directly into the 1.0 promise: at 1.0 the file is supposed to become the thing consumers may rely on. Every month it stays hand-written adds names it does not list, and every consumer gate built on it inherits a silent false negative. The fix is mechanical now and archaeological later.

**Resolution (2026-08-12).** Neither approach as written, because each gave up
something the other kept — and the paper's own diagnosis said why.

From **B**, the honesty: the header now states which artifact is authoritative
for what, instead of asserting a completeness the `.md` did not have.
`PUBLIC_API.json` is authoritative for **names** (generated, CI-pinned);
`PUBLIC_API.md` is authoritative for **method-level surface + semantics**,
which `__all__` structurally cannot see. The "what is NOT listed here is NOT
public" rule now spans both files rather than resting on the unchecked one.

From **A**, the gate — because the paper's real finding was not staleness but
that *nothing could notice*: "the `.json` check passes because the `.json` is
generated, and the `.md` has no check at all". `test_public_api_pins.py` now
asserts every `__all__` name AND every public `Client` method appears in the
`.md`. Sabotage-verified red in both halves.

What was NOT taken from A: generating the `.md` tables. A generated block would
have to carry the one-line summaries, and those are where the surface is
actually explained (why `usbfs_openable` differs from `usb_sysfs`; why an
intermediate `dump_chain` rung is not enumerable). The gate is therefore a
PRESENCE test — nothing may be absent, and how it is described stays a human's
job. That is a deliberate trade: it cannot catch a row that is present and
wrong.

The drift itself is repaired: all 18 absent names documented, and all eight
zero-occurrence verbs — `subscribe` / `unsubscribe` /
`drain_events(subscription_id=)` with the full v1 predicate grammar,
`dump_all`, `inspect_full`, `dump_chain`, `driver_info`, `audit_tail`,
`state_snapshot` — plus `revoke`, `attenuate`, `recover`,
`predicate_grammar_version` and `correlation_scope`, which the paper's count
had not reached.

**Retrospective.** Two months of a green gate returning the wrong answer, and
the paper is right that this is the worst shape a defect can take. The lesson
generalises past this file: a document that carries an exclusion rule
("anything not here is not public") has made itself a gate, and a gate with no
test is a gate that passes. The fix that matters is `18c8df6`'s test, not
`18c8df6`'s prose — the prose will be stale again eventually; the test is what
makes that a red build instead of a consumer's lost quarter.


### `DOC-05` — Doc nits: the stability banner still says pre-`0.1.0`, the CLI section carries no deprecation note, and STARTHERE's future-shape is superseded

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🟢 Nice-to-have |
| Submitter | hrb-app (commit `96aeb42`) |
| Submitted | 2026-07-29 |
| Decision | accepted — the three doc items; item 4 refiled as `SDK-12` |
| Decided on | 2026-08-12 |
| Scheduled for | **0.1.10** (milestone M-A, chunk A1) |
| Shipped in | **0.1.10** — `18c8df6` (items 1-3) |

**Concern.** Four small staleness items in the docs a consumer reads first. (1) `PUBLIC_API.md`'s AP-11 stability banner says "Pre-`0.1.0`. **Everything below is `experimental`** until the SDK reaches its first published release" and the closing section repeats "Until `mgf-hrb` ships `0.1.0`" — but `0.1.8` is published, so the condition both sentences are written against has already been met and a reader cannot tell what tier they are actually on. (2) The `mgf.hrb.cli` section documents the embedded CLI with no deprecation note, while the code itself banners the deprecation at runtime. (3) `STARTHERE.md`'s future-shape still lists `mgf-hrb-cli` as a planned extracted sibling — superseded by the hrb-app absorption that produced that very deprecation banner. (4) `mgf.hrb.testing` (`FakeClient` / `ScriptedBus` / `install_fake`) is documented as designed-but-unshipped in two places, and it has a waiting consumer.

**Why it matters.** Items 1–3 are the class of drift that makes a reader distrust the rest of the file: the version banner is the first thing an adopter checks to decide whether it may depend on the surface, and a CLI section with no deprecation note actively recommends a path the CLI itself tells you to leave. Item 4 is the one with a live cost — hrb-app hand-rolls its own SDK fakes because the injection surface is designed and not shipped, and hand-rolled fakes are the drift risk the real thing exists to remove (SDK-06 already recorded what a fake that diverges from the handshake costs: silently hanging tests). Recording hrb-app here as the waiting consumer so the shipping decision has a named beneficiary.

**Concrete evidence** (mgf-hrb `main`, `__version__ = "0.1.8"` — `src/mgf/hrb/__init__.py:97`):
- `PUBLIC_API.md:13-16` — "Pre-`0.1.0`. **Everything below is `experimental`** until the SDK reaches its first published release"; `PUBLIC_API.md:238-243` — "## Stability commitment / Until `mgf-hrb` ships `0.1.0`".
- `PUBLIC_API.md:222` — "## `mgf.hrb.cli` — the embedded CLI", no deprecation note; versus `src/mgf/hrb/cli/__init__.py:14` ("This module is RETAINED for one release cycle as a deprecation banner") and `:172` (the printed banner: "The `mgf-hrb` CLI is deprecated; install hrb-app and use …").
- `STARTHERE.md:243` — "**Future siblings**: `mgf-hrb-cli` (extracted from `src/mgf/hrb/cli/`), …".
- `STARTHERE.md:60` and `:242` — `mgf.hrb.testing` "designed but not yet shipped"; no `testing` module exists under `src/mgf/hrb/`.

**Suggested shape.** Three one-line edits plus one decision: state the current AP-11 tier as a fact about `0.1.8` (in both places, ideally sourced from `__version__` so it cannot drift again); add a one-line deprecation note at the head of the `mgf.hrb.cli` section pointing at hrb-app, matching the runtime banner; drop `mgf-hrb-cli` from STARTHERE's future-siblings list with a pointer to the absorption. For `mgf.hrb.testing`, the ask is a scheduling decision rather than a doc fix — with a waiting consumer, the minimum useful slice is `FakeClient` honouring the hello handshake (SDK-06) plus `install_fake` for GUI tests.

**Cost if deferred past v1.0.** N/A for the doc items (hygiene, but they are the doc that AP-11 is asserted in, so they should not survive the 1.0 cut). For `mgf.hrb.testing` the cost is real and compounding: every consumer that waits builds its own fake, and each of those fakes is a private, untested restatement of the wire contract.

**Resolution (2026-08-12).** Items 1-3 shipped in `18c8df6`.

1. **The AP-11 banner.** It said "Pre-`0.1.0`. Everything below is
   `experimental` until the SDK reaches its first published release", and the
   closing section repeated the condition — while `0.1.9` sat on devpi. Both
   now state the `0.1.x` tier as a fact, source the current release from
   `__version__` (CI already pins `__version__` == `PUBLIC_API.json` ==
   `pyproject.toml`), and say what `experimental` means here in practice: the
   maintainer is not yet bound, the surface has only grown, and announced
   deprecations get the AP-04 cycle even pre-1.0.
2. **The CLI deprecation note.** The `mgf.hrb.cli` section now opens with the
   deprecation the runtime has been printing all along, pointing at
   `hrb-app cli` and naming the removal window.
3. **STARTHERE's future shape.** `mgf-hrb-cli` is gone from the
   future-siblings list — and `mgf-hrb-gui` with it, which the paper did not
   catch: both were superseded by the same hrb-app absorption that produced
   the deprecation banner.

4. **`mgf.hrb.testing` — NOT closed here.** The paper says it itself: "the ask
   is a scheduling decision rather than a doc fix". Closing it inside a
   documentation commit would have made this paper's own status the kind of
   thing it was filed about. Refiled as **`SDK-12`** with hrb-app named as the
   waiting consumer and the minimum useful slice recorded.

**Retrospective.** Item 1 is the one worth remembering. A stability banner is
not decoration — it is the first thing an adopter reads to decide whether they
may depend on the surface, and it had been contradicted by the repository's own
release history since `0.1.0`. The failure mode is specific: a reader who
believes it under-relies on a stable surface; a reader who notices it is wrong
stops believing the rest of the file. Sourcing the version from `__version__`
means the sentence cannot rot again the way it did.

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

**Filed under §2 until 2026-08-12.** The entry has read `ADOPTED` since
2026-05-30; it sat in *Open feedback (active)* for ten weeks anyway. Moved as
part of the M-A chunk A1 ledger pass — same defect the batch below records,
one paper late.

### `CORE-02` — `release` is authorized by possession of a lease id, and lease ids count from 1: a non-holder can free — and then take — any device

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🔴 Blocker |
| Submitter | hrb-app (commit `b75634c`) |
| Submitted | 2026-08-06 |
| Decision | accepted — Approach A (connection-scoped release) |
| Decided on | 2026-08-06 |
| Scheduled for | next release, **with or before the CAP-01 fix** |
| Shipped in | **0.1.9** — #62 merge `5d4744f`, fix `8858a42`; published to devpi 2026-08-08 |

**Resolution (2026-08-06).** Approach A implemented. `Core.release` now
takes a `caller` and returns an error union, matching `Core.raw`'s shape
— the ownership-free variant is gone entirely rather than kept as a
legacy path, since an available unauthenticated path that a wire layer
picks up is exactly how this defect arose. Recorded under **D-038**,
which the fix extends from `claim`/`raw` to `release`.

Both constraints named below were honoured: the check sits under the
same lock as the `findLease` and before M3's swap-remove, and after the
not-found early return so idempotency survives and the error is not an
existence oracle.

Verified: the probe in the evidence section now reports `REFUSED:
NotPermittedError` with the lease still standing. 3 new dispatch-seam
tests plus a new impostor case folded into the `#8` lease-table fuzz
property (391/391 Zig), and 4 new E2E checks (47/47). Seeded sabotage
removes only the ownership check: the security assertions fail while the
idempotency and positive-control assertions keep passing, so the tests
discriminate the property from the plumbing.

**Concern.** `release` carries no caller identity at any point in its path. `request.zig` parses it as `{urn, lease_id, deadline_ms}` — **the only mutating op with no `cap_id` on the wire at all**. `Facade.release` performs no verifier check, unlike its `claim` / `raw` neighbours which both branch on `if (self.verifier) |_|`. And `Core.release(id, deadline)` takes no caller parameter, so there is no identity available to check even if someone wanted to. Authorization is therefore *possession of a lease id* — and lease ids are allocated sequentially from 1 per worker core (`next_lease: LeaseId = 1`, `self.next_lease += 1`). The SDK's own docstring calls `release` "the holder ending their **OWN** lease"; that contract is stated at the top of the stack and enforced nowhere below it.

**Why it matters.**

1. **It is an unauthenticated bypass of `revoke`'s `steal` gate.** `revoke` — taking a device away from its holder — is deliberately capability-gated on the `steal` right (`format.zig:125`, `permitsRevokeFromPeer`), because taking someone's device is a privileged act with a name and an audit trail. `release` produces the same operator-visible outcome (the holder loses the device; the resource returns to free) and requires **no capability whatsoever**. The design put a lock on the front door and left the side door open next to it.
2. **It is takeover, not merely denial of service.** Verified below: the connection that freed another's lease then claimed the device for itself. The victim's next op fails; the thief holds the hardware.
3. **The id is guessable, not merely obtainable.** An attacker does not need `list_leases` (which *is* `audit_read`-gated) to learn a target id — `1` is the first id any core ever issues, and the space is dense and ascending. The gate is a small integer that counts up from one.
4. **`CAP-01` is currently acting as an accidental lid, and the lid is scheduled for removal.** Today a second *process* cannot connect at all (the root cap binds to the first peer), so exposure is confined to connections from the one process that owns it. The moment `CAP-01` is fixed — which is desired, and which multi-operator *requires* — every process that can open the socket gains the ability to free and take any device. **This makes `CORE-02` a prerequisite for shipping the `CAP-01` fix, not a follow-up to it.** Sequencing them the other way round briefly ships a broker where any peer can steal any device.

On reachability, stated precisely rather than dramatically: the socket is mode `775`, and `connect(2)` on a Unix socket requires write permission, so this is reachable by the broker's own uid and its group — not by every local user. Within the multi-operator model the design is built for, that is exactly the population that matters: the other operators.

**Concrete evidence** (mgf-hrb `fix/core01-connection-scoped-leases`, commit `baf4bd2` — i.e. *with* D-038 already in place, which is the point: `release` was untouched by that fix and this is an independent defect).

```
conn A claimed hw:/test/dev0 -> lease_id=1
  live leases: [('hw:/test/dev0', 1, 1)]

conn B (holds nothing) calls release('hw:/test/dev0', 1)
  -> ACCEPTED (no error raised)

  live leases after: []

RESULT: a non-holder released another connection's lease.
  and then claimed the freed device: lease_id=2
  -> denial of service AND takeover, from a guessable integer
```

Connection B never claimed anything, held no lease, and presented no capability — the wire op has no field in which to present one.

The code path:
- `request.zig:710-719` — the `release` parse: `urn`, `lease_id`, `deadline_ms`. No `cap_id`; compare `raw` immediately below it at `:720-733`, which has one.
- `facade.zig:211-214` — `release` routes and calls straight through; no `verifier` branch, unlike `claimFromPeer` (`:172-184`) and `rawFromPeer` (`:233-247`).
- `core.zig:895` — `pub fn release(self: *Core, id: LeaseId, deadline: Deadline) void` — no caller parameter, and a `void` return, so not even "refused" is expressible today.
- `core.zig:203` + `:806-807` — `next_lease` starts at 1 and increments.
- SDK `_client.py:1453` / `:526` — the "holder ending their OWN lease" wording.

**Suggested shape.**

*Approach A — connection-scoped release (recommended; completes D-038).* Thread the connection id into `Facade.release` → `Core.release` and refuse when the found lease's holder is not the caller. D-038 already makes every lease's holder the broker-assigned connection id, so the identity needed is present and unforgeable; this is the one mutating op that has not yet been re-keyed to it.

Two constraints the implementation must respect:

- **The idempotency contract survives unchanged.** "Releasing an already-released lease is a no-op" (Part 3.2, and the SDK's documented "total + idempotent") must stay true, or a legitimate double-release becomes an error. So *not found* keeps returning silently; only *found but not yours* becomes `NotPermitted`. That also means `Core.release` must gain an error return, which is a signature change on a currently-`void` function.
- **The check belongs under the same lock as the `findLease`,** before the M3 placeholder protocol's swap-remove — not after, and not outside. M3 deliberately removes the lease slot under the lock and runs `backend.release` outside it; an ownership check placed after the removal would authorize a lease it had already destroyed.

Practically this breaks nothing real: a client that claims on one connection and releases on another is already impossible under D-038, because the first connection's death reclaimed the lease.

*Approach B — cap-gate `release` like every other mutator.* Add `cap_id` to the release wire op and check it, making `release` consistent with `claim` / `raw` / `revoke`. **Cost:** a wire-format change plus an SDK change, and — more importantly — it answers the weaker question. A capability says "may act on this scope"; it does not say "is the holder". A cap-holding neighbour could still release your lease, which is precisely the situation `revoke`'s `steal` right exists to make explicit and auditable.

**Recommendation: A**, because the question `release` needs to ask is "is this yours?", and D-038 has already made that question answerable. B's `cap_id` is a reasonable defence-in-depth addition on top, but is not a substitute — and shipping B alone would leave the `steal`-gate bypass in point 1 open to any capability holder.

### `CORE-01` — Connection death never reclaims leases: `holderDied` has no production caller, so a dead client strands its device until the broker restarts

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🔴 Blocker |
| Submitter | hrb-app (commit `37fd60e`) |
| Submitted | 2026-08-06 |
| Decision | accepted — Approach A (connection-scoped), per D-011/D-024 |
| Decided on | 2026-08-06 |
| Scheduled for | next release |
| Shipped in | **0.1.9** — #62 merge `5d4744f`, fix `d68f6a9`; published to devpi 2026-08-08 |

**Resolution (2026-08-06).** The A/B fork below was settled by the
project's own ADRs rather than by preference: **D-011** requires that a
lease exist only while its holder is provably alive and that ghost
ownership be *structurally impossible*, and **D-024** names "the live
connection set" as the authoritative record of who holds what. That is
Approach A; Approach B (session resume across reconnect) would have
contradicted an Accepted decision. Recorded as **D-038** — "The lease
holder IS the connection: broker-assigned, never client-declared" — and
implemented: the broker assigns each connection a monotonic id, `claim`
and `raw` are keyed to it, and `handleClient`'s teardown calls
`holderDied`. Verified by 4 new dispatch-seam tests (388/388 Zig) and 6
new E2E checks (43/43), each confirmed to fail under seeded sabotage.
The `raw` escalation noted in point 4 below is closed by the same change.

**Concern.** `Facade.holderDied` is documented as the client-death reclamation entry point — "A client died (driven by the liveness monitor): tell EVERY worker to reclaim that client's leases" (`facade.zig:489-491`). **Its only callers are tests.** `server.zig` never imports the liveness module, never constructs a `LivenessMonitor`, and never calls `holderDied`; `handleClient`'s teardown reaps subscriptions, shm regions, the admission slots and the connection counter, but never touches leases. Compounding it, the lease holder is not connection-derived at all — `request.zig:687` parses `holder` straight out of the request JSON, so the broker has no connection→holder mapping to reclaim by even if something wanted to. A lease therefore outlives the connection that created it, indefinitely, and the resource stays claimed until the broker process restarts.

**Why it matters.**

1. **G3 is proven in the suite and absent from the product.** `hrb-code/README.md` presents the LivenessSource as "the signal that makes G3 real", and `liveness_test.zig` / `facade_test.zig` genuinely demonstrate reclamation against real sockets. But those tests call `holderDied` themselves — `liveness_test.zig:24-29` is explicitly an *adapter* ("wire the LivenessMonitor's death signal into `Core.holderDied`") written for the test. The running broker has no such adapter. The guarantee is real in the engine and unwired at the seam, which is the failure mode a test suite is least able to catch: every layer works, nothing connects them.
2. **A crashed operator strands hardware.** An exclusive claim plus a crash equals a device no one can use. The only recovery is restarting the broker — which destroys *every other operator's* lease at the same time. Recovering from one client's crash requires a fleet-wide outage.
3. **It composes with `CAP-01` (this file) into a dead end.** After a client dies you cannot reconnect (CAP-01: the root cap is still bound to the dead pid) *and* the device it held stays held (this paper). Both are cleared only by a broker restart. Two independent lifetime bugs with the same one-and-only remedy is what turns a papercut into an operational rule ("restart the broker whenever anything happens").
4. **The holder id is unverified.** Because `holder` is client-declared, one client can claim as another's id today. Any reclamation keyed on that number inherits the problem — so this is not purely a missing-wire; it needs a decision about what identity a lease is actually keyed to.

**Concrete evidence** (mgf-hrb `main`, commit `bf47858`).

Call-graph — every `holderDied` reference in `src/`:

| Site | Caller |
|---|---|
| `facade.zig:492` | definition |
| `core.zig:1015` | definition |
| `liveness_test.zig:24,29` | **test** (an adapter written for the test) |
| `facade_test.zig:127,173` | **test** |
| `core_test.zig:100,122,763,970` | **test** |

No production caller exists. `grep -n "liveness\|Liveness" src/server.zig` returns nothing.

Empirical confirmation — one process, two connections (a second *process* cannot connect, per CAP-01). Connection A claims a device on the `hw:/test/` fake backend, then its socket is closed with no protocol release — byte-identical, from the broker's read loop, to a `SIGKILL`ed client. Connection B observes:

```
conn A (client_id=4242) claimed hw:/test/dev0 -> lease_id=1
conn B (client_id=99) up as observer

BEFORE A dies:
  active_connections=2  active_leases=1  cap_table_size=1
--- conn A's socket closed, no protocol release ---
AFTER A died (+2s):   active_connections=1  active_leases=1   urn=hw:/test/dev0 holder=4242 id=1
AFTER A died (+5s):   active_connections=1  active_leases=1   urn=hw:/test/dev0 holder=4242 id=1
AFTER A died (+10s):  active_connections=1  active_leases=1   urn=hw:/test/dev0 holder=4242 id=1

CONTROL — did the broker observe A's death?
  YES: active_connections 2 -> 1. A's teardown ran.

CONSEQUENCE — can the device be claimed again?
  REFUSED: ConflictError: Conflict
  -> hw:/test/dev0 is held by a connection that no longer exists.
```

The control is the load-bearing part: `active_connections` dropping 2→1 proves the broker *did* observe the death and *did* run `handleClient`'s teardown. So the surviving lease is a missing reclamation, not a sample taken too early — and ten seconds of settling rules out a lazy or periodic sweep. The closing `Conflict` is the operational consequence stated by the broker itself: a live client, with a valid capability, refused a device whose only holder no longer exists.

Probe script: `lease_reclaim_probe2.py` (reproduces in ~15s against `hw:/test/dev0`, no hardware required); available on request or trivially reconstructed from the transcript above.

**Suggested shape.** The wiring is one line; the real question this paper needs answered first is **what identity a lease is keyed to.** That is the fork:

*Approach A — connection-scoped leases (recommended for correctness now).* The holder becomes the connection, not a client-declared number. Reclaim in the teardown that already exists:

```zig
// handleClient, alongside the existing per-connection defers
defer _ = facade.holderDied(connHolderId(cfd));
```

This is what the rest of `handleClient` already believes: subscriptions (`:500`) and shm regions (`:574`) are both cfd-keyed, with the comment at `:568-573` spelling out the reasoning verbatim — "a connection's shm data-plane regions never outlive it". Leases become the third member of that set instead of the exception. **Costs:** the wire's `holder` field degrades to a label, which is a protocol change (or a compat path where a supplied `holder` is recorded for display but the *reclamation* key is the connection); and a client that reconnects loses its leases, which may be exactly wrong for long-running passthrough.

*Approach B — session-scoped leases with an explicit liveness contract.* Keep a client identity across connections, bind holder→connection at first claim, and reclaim on death only after a grace window, letting a reconnecting client reclaim its own session. `liveness.zig` already carries both halves this needs (connection-death *and* heartbeat, per the README's own table), and `liveness_test.zig:24-29` shows the adapter shape. **Costs:** a real session concept, a heartbeat the SDK must drive, and a grace-window policy — considerably more design than A.

**Which one is right depends on a question I can't answer from outside**: the SDK already exposes `recover` and `reconcile_after_restart`, and `restart_marker.zig` exists, so continuity across a *broker* restart is clearly modelled. If continuity across a *client* reconnect is meant to be modelled too, B is the honest shape and A would quietly contradict a deliberate design. If it isn't, A is correct and much cheaper. Worth stating the answer in `SCOPE.md` either way, since the current behaviour — leases survive everything, forever, reclaimed by nothing — is the one option that cannot be the intended design.

**Regardless of A or B:** the client-declared `holder` should stop being load-bearing for ownership. Either derive it from the connection (A) or bind it to an authenticated session at first use (B); today any client can claim as any holder id, and `release`/`revoke` decisions downstream trust that number.

### `CAP-01` — The root cap binds to the first client's `{uid, pid}` and is never released: one broker serves exactly one process, once

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| Severity | 🔴 Blocker |
| Submitter | hrb-app (commit `37fd60e`) |
| Submitted | 2026-08-06 |
| Decision | accepted — Approach B (per-connection capabilities) |
| Decided on | 2026-08-06 |
| Scheduled for | next release, **with `CORE-02`, not before it** |
| Shipped in | **0.1.9** — #62 merge `5d4744f`, fix `94ea5c6`; mgf-hrb-cap #24 merged (`98d4641` pinned); published to devpi 2026-08-08 |

**Resolution (2026-08-06).** Approach B implemented and recorded as
**D-039**. Each connection is minted its own capability with `bound_to`
set at mint time — binding at birth rather than by a later `bindTo`,
which removes the shared mutable state entirely rather than arbitrating
it — and revokes it on teardown.

The blocker named in the costs below is closed: `CapTable.revoke` now
exists (mgf-hrb-cap `98d4641`, branch `feat/cap01-revoke`) and is
transitive over `attenuate` lineage, so a connection's sub-capabilities
cannot outlive it. `build.zig.zon` is re-pinned to that commit with a
locally-computed content hash. **Maintainer action required: push
mgf-hrb-cap before this branch builds anywhere but this machine** — the
hash is correct, but the URL cannot resolve until the commit exists on
Codeberg.

Verified: three consecutive `hrb-app` launches all connect (the second
previously failed), and **two `hrb-app` instances connected to one
broker simultaneously** — the day-1 multi-operator constraint working
for the first time. 5 new capability tests plus a new E2E check that
spawns a genuinely separate PROCESS, which is the only way to test this
at all: the old bind was on `{uid, pid}`, so every in-process assertion
passed whether the bug existed or not. Three existing E2E checks that
asserted the shared-cap behaviour were inverted — they had been pinning
the defect. Seeded sabotage confirms both halves bite.

**Concern.** `server.zig` mints ONE process-wide `root_cap` at startup and hands the same id to every client thread; each connection then calls `ct.bindTo(root_cap, peer)` with its `SO_PEERCRED` `{uid, pid}`. `CapTable.bindTo` sets `bound_to` on first call, is idempotent for an equal peer, and returns `NotPermitted` for any other — and **nothing anywhere clears `bound_to`**. There is no `revoke`/`unbind` in `CapTable`'s public API at all. So the first process to connect owns the broker's only root capability for the broker's entire lifetime; every later process is refused at the handshake, including *the same program restarted* (new pid ⇒ new `PeerCred`). The broker is effectively single-use: it serves one process, once, and must be restarted to serve another.

**Why it matters.** Four ways, in rising order of severity:

1. **Multi-operator is not merely unimplemented — it is impossible.** hrb-app's README names "multiple humans driving the same broker concurrently" a *day-1 design constraint*, and the GUI ships an "All leases (on this broker)" pane whose entire purpose is showing what other operators hold. A second operator's client can never complete a handshake, so that pane is structurally incapable of ever showing a second operator's lease. A headline feature of the flagship consumer is unreachable through no fault of its own code.
2. **Ordinary client restart strands the broker.** The GUI dev loop (edit → restart) breaks after the first run. So does a crash, and so does any supervisor that restarts a client. Recovery requires restarting the *broker* — i.e. tearing down every lease the broker holds — to recover from a *client* exiting normally.
3. **The error is actively misdiagnosable.** The client sees `{"ok":false,"err":"NotPermitted"}` — indistinguishable from a genuine authorization failure. It reads as "you are not allowed", when the truth is "a process that no longer exists still owns this". Only the broker's own stderr disambiguates, and only if the operator has it in view. I lost real time to this: a healthy broker, a healthy client, and an error that names neither the cause nor the remedy.
4. **It contradicts the design's own framing.** Part 12 §3.3 Tier-0 #2 is titled *connection-bound* capabilities, and `cap.zig`'s doc comment says "presenting the id over a connection whose `SO_PEERCRED` doesn't match is rejected". But the implementation binds for the lifetime of the *CapTable entry*, which is the lifetime of the *process* — strictly and unboundedly longer than the connection the design names. The binding outlives the thing it is named after.

**Concrete evidence** (mgf-hrb `main`, commit `bf47858`; observed against `hrb-code/zig-out/bin/mgf-hrb-server`).

Repro — three commands, no hardware needed:

```bash
./zig-out/bin/mgf-hrb-server &     # cold start
uv run hrb-app                     # connects fine; quit it
uv run hrb-app                     # NotPermitted — same user, same binary, new pid
```

Broker stderr on the second run (the only place the truth appears):

```
broker: cap-bind rejected cap_id=5636812389632567169 new_peer uid=1000 pid=3063355
  err=NotPermitted — another peer already holds this cap (Tier-0 #2 binding)
```

Client-side, the same event (hrb-app `0.2.0`, `mgf.hrb._client`): `unexpected broker greeting: {'ok': False, 'err': 'NotPermitted'}` → `starting in disconnected mode`.

The code path:
- `hrb-code/src/server.zig:316` — the single process-wide `root_cap` is minted once in `main()`.
- `hrb-code/src/server.zig:435` — the same id is passed to every spawned `handleClient` thread.
- `hrb-code/src/server.zig:538` — every connection calls `ct.bindTo(root_cap, peer)`; failure writes `NOT_PERMITTED_LINE` and drops the connection before the hello.
- `mgf-hrb-cap/src/cap.zig:420-429` — `bindTo` sets `bound_to` once; equal peer is a no-op, unequal peer is `NotPermitted`. No clear path.
- `mgf-hrb-cap/src/cap.zig:82-88` — `PeerCred` is `{uid, pid}`, `eql` compares both, so pid reuse is the only way a later process could ever match.

**The anomaly, in the codebase's own idiom.** `handleClient` already practises rigorous per-connection teardown — every acquisition it makes is released by a matching `defer`:

| Acquisition | Release |
|---|---|
| `accept4` cfd | `defer linux.close(cfd)` (`:495`) |
| subscriptions | `defer subscription.reapPeer(cfd)` (`:500`) |
| active-conn count | `defer audit.decActiveConnections()` (`:506`) |
| aggregate conn slot | `defer admission.aggregateRelease()` (`:509`) |
| per-uid conn slot | `defer admission.uidConnRelease(peer.uid)` (`:567`) |
| shm regions | `defer shm.globalPool().releaseAllForConn(cfd)` (`:574`) |
| **cap binding (`:538`)** | **— none —** |

Six releases, one omission. The shm comment at `:568-573` even spells out the exact reasoning this case needs — keyed on the cfd, "a connection's shm data-plane regions never outlive it". The cap binding is the one per-connection acquisition that does outlive it, and the file's own discipline says it shouldn't.

**Suggested shape.**

*Approach A — refcount the binding, release it in `handleClient`'s teardown.* Add a `bind_refs` counter and an `unbindFrom`; clear `bound_to` at zero:

```zig
// mgf-hrb-cap/src/cap.zig
pub fn bindTo(self: *CapTable, cap_id: u64, peer: PeerCred) Err!void {
    // … lock, get …
    if (c.bound_to) |existing| {
        if (!existing.eql(peer)) return Err.NotPermitted;
        c.bind_refs += 1;              // same peer, another connection
        return;
    }
    c.bound_to = peer;
    c.bind_refs = 1;
}

pub fn unbindFrom(self: *CapTable, cap_id: u64, peer: PeerCred) void {
    // … lock, get …
    const existing = c.bound_to orelse return;
    if (!existing.eql(peer)) return;   // not ours to release
    c.bind_refs -= 1;
    if (c.bind_refs == 0) c.bound_to = null;
}
```

```zig
// server.zig, immediately after the successful bind at :538
defer ct.unbindFrom(root_cap, peer);
```

One line in `server.zig` puts the cap bind under the same defer discipline as its six neighbours. **Cost:** touches the carved `mgf-hrb-cap` package → version bump + hash re-pin in `build.zig.zon`. **Limit, stated plainly: A fixes stranding but NOT multi-operator.** Two operators still cannot be connected simultaneously — the second is refused while the first holds the binding. Given that multi-operator is a stated day-1 constraint of the primary consumer, A is a stopgap, not the answer.

*Approach B — per-connection root caps (recommended).* Mint a fresh root cap per accepted connection, bound at mint time, and drop it in the connection's teardown. The shared mutable `bound_to` disappears, and with it the contention there is currently no way to arbitrate. `Capability.bound_to` is already a public field defaulting to `null`, so the mint side needs **no change to `mgf-hrb-cap` at all**:

```zig
// in handleClient, replacing the passed-in root_cap
const conn_cap = ct.mint(.{
    .scope = Urn.from("hw:/**"),
    .rights = ROOT_RIGHTS,   // hoisted out of main()
    .bound_to = peer,        // bound at birth — no bind step, no race
}) catch { _ = linux.write(cfd, NOT_PERMITTED_LINE.ptr, NOT_PERMITTED_LINE.len); return; };
defer ct.drop(conn_cap);
```

Properties:
- N operators, N caps, N independent lifetimes → multi-operator works as the design says it should.
- A dead client's cap dies with its connection. No stranding, and no "when is a peer really gone?" question to answer.
- *Strictly stronger* isolation than today: two connections from the same `{uid, pid}` get distinct caps, so connection A cannot present connection B's id — today they share one, and the binding cannot tell them apart.
- It is where the code already says it is going: `server.zig:313-315` — "Per-client cap attenuation is U-future; today every client sees the root cap id via the handshake". B is that future, minus the attenuation step.

**Costs of B, honestly.** (i) It needs a `drop`/`revoke` on `CapTable`, which does not exist today — and without one this approach *leaks*: `caps` is an `ArrayList` and `get()` is a **linear scan** (`cap.zig:279-286`), so an unrevoked cap per connection means both unbounded growth and an O(n) authz check that degrades all day. The revoke is not optional garnish; it is the load-bearing half. (ii) That change lands in the carved `mgf-hrb-cap` repo (version bump + hash re-pin), same as A. (iii) A decision is needed on whether leases claimed under a dropped cap survive it — I'd say yes (the lease engine is the ownership truth; the cap authorized the claim, it doesn't hold it), but it should be written down rather than inferred. (iv) The cap id is now per-connection, so nothing may cache it across reconnects — the Python SDK already re-reads `cap_id` from each hello, so no consumer change is needed today, but it becomes a contract worth stating in `PUBLIC_API.md`.

**Recommendation: B**, with A available as a patch-release stopgap if one is wanted before the larger change. They compose cleanly — A's refcount is exactly what B makes unnecessary.

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

| Field | Value |
|---|---|
| Status | ✅ SHIPPED |
| 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 | design: `hrb-design/mgf-hrb-usb-worker.md`; implementation: `hrb-code/src/linux_usb.zig` (see USB-02) |

**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.** The paper's ask — a concrete worker design — shipped
as `hrb-design/mgf-hrb-usb-worker.md` in the same enrichment pass; the
status row simply never flipped.

- **Resolution (2026-07-10 queue sweep — verified in repo):** the
  design doc exists at `hrb-design/mgf-hrb-usb-worker.md` and covers
  the paper's five asks (URN translation, substrate matrix, composite
  handling, identity continuity via §4.3's weight table — cited by
  MODEL-01's retrospective — and the per-interface `OpsTable`).
  Better: the design has since been *implemented and
  seam-pressure-tested* — `hrb-code/src/linux_usb.zig` (2000+ lines:
  sysfs enumeration, usbfs claim/raw, netlink uevent watch) plus
  `usb_scan.zig` / `stub_usb_test.zig` / `linux_usb_test.zig`, closing
  the paper's falsifiability concern (the kabi seam now has its second
  worker). Status flipped to SHIPPED. Per-slice ship evidence lives in
  USB-02's resolution.

---

### `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 | ✅ SHIPPED |
| 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 | commit `6c70310` (2026-05-29, "feat(security): #7/D9 — refuse DMA-capable claims with no IOMMU protection (LINUX-01)") + `hrb-code/src/linux_iommu.zig` |

**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.** Shipped 2026-05-29 as Tier-1 #7 (commit
`6c70310`); the status row lagged.

- **Resolution (2026-07-10 queue sweep — verified in repo):** the
  paper's "assume is not enforce" gap is closed on all three fronts:
  1. **Checked precondition** — `hrb-code/src/linux_iommu.zig`
     verifies `/sys/kernel/iommu_groups` exists/is populated
     (`linux_iommu.zig:55-58`) and resolves the target device's
     IOMMU group via the sysfs symlink.
  2. **Declared degraded mode** — `linux_backend.zig:284-312` (the
     block is annotated "#7 / D9 (FEEDBACK LINUX-01)"): any
     non-`observe` (DMA-capable) acquire on a device with
     `postureFor(urn) == .no_iommu` is REFUSED with an explicit
     "device is not behind an IOMMU group" message; observe paths
     keep working, exactly the paper's suggested shape.
  3. **Operator visibility** — the doctor surface carries the stable
     `IOMMU_PRESENT` finding code (`src/mgf/hrb/diagnostics.py`)
     with vfio/IOMMU remediation text.
  Subsequent hardening extended the same posture into the real VFIO
  path (detach/bind/revoke slices, the R5 series `de4b021` /
  `3cd70ae` / `00a0fef` / `6bcb31b`). Status flipped to 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 | figrecovery (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 | ✅ SHIPPED (Slices A + B + C) |
| Severity | 🔴 Blocker |
| Submitter | figrecovery (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 | Slice A: closure pinned 2026-05-27 (`tests/conformance/test_usb_sysfs_model01.py`, CHANGELOG "USB-02 Slice A closure pin"). Slice B: commit `a07c1cf` (2026-05-27, "tests: R1 USB-02 Slice B closure pins"). Slice C: netlink watch + composite handling + `state_changed` synthesis in `hrb-code/src/linux_usb.zig` (U2b) |

**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.** All three slices shipped; the status row lagged
the code by weeks.

- **Resolution (2026-07-10 queue sweep — verified in repo):**
  - **Slice A (discovery + identity)** — closed 2026-05-27. The Zig
    worker's sysfs enumeration routes `hw:/usb/**` through
    `kabi.list` / `kabi.inspect`; the MODEL-01 cross-side conformance
    pins live in `tests/conformance/test_usb_sysfs_model01.py`
    (broker-inspect ↔ `snapshot_from_sysfs` round-trip, the
    `USB_SYSFS_READABLE` doctor row, `Client.list("hw:/usb/**")` ≥
    lsusb device count). Validated on real hardware: 15 USB URNs
    round-tripped, all `SAME` (CHANGELOG 2026-05-27 entry).
  - **Slice B (claim + raw channel)** — closed 2026-05-27, commit
    `a07c1cf`. `hrb-code/src/linux_usb.zig` carries the full U3a
    phase: `vAcquire` (USBDEVFS_CLAIMINTERFACE), `vAcquireDetach`
    (opt-in `USBDEVFS_DISCONNECT_CLAIM`, `linux_usb.zig:99-111`),
    `vRelease`, `vRawChannel`, `vFdForToken`; the DOC-03 libusb
    recipe was the pre-requisite and shipped earlier (`c88d5be`).
    The claims table later gained a G6 race pin (`282202b`).
  - **Slice C (events + composite)** — the NETLINK_KOBJECT_UEVENT
    watch + parser (`linux_usb.zig` Phase U2b), per-interface
    composite enumeration (the Slice-A conformance pin counts
    per-interface URNs), and comparator-driven `removed`+`added` →
    `state_changed` synthesis (pinned by `stub_usb_test.zig` —
    N transitions collapse to N `state_changed`, zero on
    comparator-DIFFERENT).
  figrecovery's adoption path (find → snapshot → claim → raw →
  release across a mode change) is end-to-end unblocked. Status
  flipped to SHIPPED.

---

### `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 (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 (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 | ✅ SHIPPED (rule + refuse-on-conflict enforcement; holder pid/comm naming DEFERRED) |
| Severity | 🔴 Blocker |
| Submitter | figrecovery (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: `c88d5be` follow-up; enforcement: USB-02 Slice B (`a07c1cf` closure; `hrb-code/src/linux_usb.zig` EBUSY→Conflict) |

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

- **Resolution (2026-07-10 queue sweep — verified in repo):** USB-02
  Slice B has shipped (commit `a07c1cf`), and with it the
  refuse-on-conflict behaviour the rule requires:
  `hrb-code/src/linux_usb.zig` maps `EBUSY` from
  `USBDEVFS_CLAIMINTERFACE` to `Err.Conflict` (`.BUSY =>
  Err.Conflict` at the errno mapping plus the acquire-path comments
  at `linux_usb.zig:1057` / `:1323`), so an `EXCLUSIVE`/`SHARED_RW`
  claim against a device whose interface is held externally (the
  usbmuxd case) surfaces as a typed `ConflictError`, exactly the
  Approach-A contract. Driver-detach stays opt-in via
  `acquireDetach` (`USBDEVFS_DISCONNECT_CLAIM`), per the design.
  **Honest residual:** the rule's diagnostic nicety — naming the
  holding **pid + comm** in the ConflictError detail (the
  `/proc/<pid>/fd` scan) — is NOT implemented; the consumer gets the
  conflict but must identify the holder themselves (`lsof`). That
  slice stays DEFERRED (quality-of-life, not load-bearing: the v0.x
  rule's substance — the broker refuses rather than fights external
  holders — is enforced). Status flipped to SHIPPED with that
  residual named.

---

### `CI-01` — `main` is RED and has been for weeks: 5 tier-0-3 tests fail in CI but pass locally — and CI cannot show which ones

- **Severity:** 🔴 Blocker (a red `main` trains everyone to ignore red; the cause is currently unknowable from CI)
- **Filed by:** Court/CTO fleet sweep on 2026-07-11
- **What hurts:** `mgf-hrb`'s `main` pipeline fails on the `supervisor-tier-0-3` step —
  `[mtest] FAIL: 567 passed, 5 failed, 9 skipped (581 total)`. It is **NOT new** and was
  **not** introduced by the 2026-07 CI wave: pipelines **#156** (pre-wave), **#162**
  (wave re-render) and **#165** (format gate) all fail the same way. The identical suite
  is **GREEN locally** at the same tier on the same commit — `python -m mgf.test_supervisor
  --tier 0-3` → `OK: 579 passed, 2 skipped` — so the 5 failures are **CI-environment-specific**
  (container privileges / device access / timing are the obvious suspects for a
  hardware-adjacent repo).
- **Why it matters twice over:** (1) the fleet's Zig cornerstone ships from a red `main`,
  and a permanently-red main is indistinguishable from a newly-broken one — the exact
  "people learn to shrug off red" failure mode named in mgf-standard PAPER-109; (2) **the
  cause is invisible**: `mgf-test-supervisor` writes failure detail to
  `.gates/last-run/failures/*.txt` and prints only the count, and the pipeline uploads no
  artifacts — so the CI log physically cannot tell you WHICH 5 tests failed or why. The
  only way to see it today is to reproduce the container by hand.
- **Evidence:** pipelines #156/#162/#165 (`supervisor-tier-0-3` red, same counts);
  local `mgf.test_supervisor --tier 0-3` on `origin/main` (`0b8bf8b`) → 579 passed / 2
  skipped, 0 failed; the CI log's only failure output is the summary line above.
  Coverage also warns `Module mgf.hrb was never imported` / `No data was collected` in the
  same step — worth checking whether the coverage-vs-supervisor wiring is part of the story.
- **Suggested shape:** (a) reproduce the container (`python:3.13-slim` + the pipeline's
  install) to name the 5 tests, then either fix them or mark them
  `hardware`/`skipif`-gated with a recorded reason — never leave `main` red; (b) make the
  failures **visible in CI** — the companion filed against `mgf-test-supervisor`
  (`pending-feedback/mgf-test-supervisor.md`): print per-failure detail to stdout when
  `CI=true` (or upload `.gates/`), because a gate whose failures you cannot read is a gate
  you cannot act on.
- **Resolution (2026-07-11, `fix/red-main-2026-07-11`):** Reproduced the container
  (`docker run python:3.13-slim` + the pipeline's install + `coverage run -m
  mgf.test_supervisor --tier 0-3 --quarantine-attempt`) and read
  `.gates/last-run/failures/*.txt`. It reproduced **exactly**: `5 failed`. The 5 are **two
  unrelated bugs**, and — the punchline — **neither one is "CI-environment-specific" in the
  way the paper guessed**:

  **(1) Three `perf` pins, killed by our own coverage gate.**
  `tests/perf/test_fmt_helpers_latency.py::test_format_{uptime,duration,rss}_p99_under_1us`.
  Not the container at all: **`coverage run` is the cause.** These are *sub-µs* per-call
  pins (~150-300 ns) against a fixed 1000 ns budget, and `coverage`'s tracer fires a hook on
  every line of the helper — inflating them 4-5x. Measured in `python:3.13-slim`:
  `format_rss` is **297 ns** standalone and **1430 ns** under `coverage run`. So the pin was
  timing the tracer, not `mgf.hrb.fmt`. This was **self-inflicted by #23c** (the commit that
  moved the coverage gate onto the supervisor): `[tool.coverage.run] patch = ["subprocess"]`
  propagates the tracer into every supervisor worker. Proof: the same suite in the same
  container **without** `coverage run` → `2 failed`, and those 3 pass.
  *Fix:* `_p99_ns` now skips when a line tracer is installed (`_tracer_active()` checks both
  `sys.gettrace` and PEP-669 `sys.monitoring`), with the reason recorded in the skip message.
  A real budget asserted against a fake measurement is worse than no assertion. The pins keep
  their teeth in the **un-traced** lanes, which is where they were always meant to run: plain
  `pytest -m perf` and the `nightly-tier-0-3` step (bare `mgf-test-supervisor`, no coverage).

  **(2) Two `e2e` USB claims, failing on a guard that gated on a proxy.**
  `tests/e2e/test_r1_usb_claim_happy_path.py::test_claim_then_release_against_driver_free_usb_device`
  and `::test_claim_against_driver_bound_usb_is_conflict_without_detach`. Both guarded with
  `if os.geteuid() != 0: pytest.skip(...)` — i.e. they used **root as a proxy for "USB
  hardware is reachable"**. A container breaks that proxy in both directions at once: it runs
  **as root** (so the guard passed), it bind-mounts the host's **`/sys`** (so
  `broker.list("hw:/usb/**")` returned real URNs for the host's real USB devices) — but it has
  **no `/dev/bus/usb`**, so the node the broker's `vAcquire` opens does not exist. The claim
  came back `NotFoundError`, which reads like a broker bug and actually means "there is no USB
  hardware here". *This is why it was green locally and red in CI: the developer is not root.*
  *Fix:* `_skip_unless_usb_claimable()` now gates on the resource itself — root **and**
  `/dev/bus/usb` present — and `_usb_interface_candidates()` additionally requires that the
  usbdevfs node (`/dev/bus/usb/<bus:03d>/<devnum:03d>`, via sysfs `devnum`) for a candidate
  device actually exists before offering it. That also removes a copy-pasted URN-parsing loop
  that was duplicated across the two tests.

  **Verified** — the CI container, same image, the `supervisor-tier-0-3` commands verbatim,
  after the fix: `[mtest] OK: 567 passed, 14 skipped (581 total)`, supervisor exit **0**,
  `coverage report --fail-under=80` → **86%**, gate exit **0**. Before the fix, same command,
  same container: `FAIL: 568 passed, 5 failed, 8 skipped`. Locally (non-root, un-traced) the
  suite stays `OK: 573 passed, 2 skipped`, and `pytest -m perf` still runs all 5 fmt pins for
  real (5 passed — *not* skipped), which is the check that the tracer guard didn't just
  disable the tests everywhere.

  Skips went 8 → 14: the 5 failures became honest skips, **plus one more** —
  `test_format_severity_glyph_p99_under_1us` also routes through `_p99_ns`, so it now skips
  under coverage too. It had been *passing* under the tracer, but only by luck: it is the
  cheapest helper (~125 ns), so even 4-5x inflation stayed under the 1 µs budget. Its
  measurement was exactly as meaningless as the three that failed. Leaving it "green" would
  have preserved a number nobody should trust.

  **One loose end I could NOT close, stated plainly.** The paper says pipeline **#156**
  (*pre*-wave) already failed with these same counts. That does not fully square with cause
  (1): `coverage run -m mgf.test_supervisor` was introduced by **#23c itself** (`b835882` — it
  is the only commit touching that line), and before it the coverage gate lived on the plain
  `pytest --cov` step, whose `addopts` *deselect* `perf`. So pre-#23c the perf pins were never
  traced, and a coverage-inflated `format_rss` cannot explain a 5-failure #156. Either the
  counts were assumed identical across #156/#162/#165 rather than read from each, **or** those
  three pins were *already* tripping on the shared CI runner for the ordinary reason — a
  1000 ns absolute budget against a ~300 ns helper is only ~3x headroom, and a slow or
  contended runner can eat that without any tracer. I could not distinguish these: reading
  pipeline logs needs Codeberg API access, which was out of scope for this pass.

  **What that means for the residual risk.** The traced lane (`supervisor-tier-0-3`, the one
  that gates `main`) is *proven* green — the skip makes it independent of runner speed
  entirely. But the **un-traced** lanes (`nightly-tier-0-3`, `pytest -m perf`) still assert
  the raw 1000 ns budget on CI hardware I have never measured. If the second explanation above
  is the true one, the **nightly can still trip these three pins**, and the honest next move is
  not to loosen the budget blindly but to make the pin machine-relative (calibrate against a
  measured reference op) — worth its own paper if a nightly goes red.

  **Still open — the diagnosability half, and it is the important half.** Part (b) of the
  suggested shape is NOT done here and remains filed against `mgf-test-supervisor`: CI still
  prints only the count and uploads nothing, so naming these 5 required a human to rebuild
  the container by hand. **That is the actual defect** — the two bugs above were shallow, and
  they survived for weeks purely because the gate could not tell anyone what it had found.
- **Status:** RESOLVED (this repo's half — `main` goes green). The companion
  `mgf-test-supervisor` paper (make failures readable in CI) stays **OPEN**.

---

## Closing discipline

A paper's status is flipped **in the release commit that ships it** (REL-07), and
it moves out of the active section **in the same act**. Twenty entries here said
SHIPPED in their own tables while sitting under *§2. Open feedback (active)*, and
`CI-01` sat under *§3. Cross-references* carrying `Status: RESOLVED` — invisible
to anyone scanning for open work.

A status that disagrees with its own section is indistinguishable, from the
outside, from a paper nobody has looked at.
