# Recipe: tracking a device across a mode change

When a USB device re-enumerates — an iFig moving Recovery → DFU, a
dual-personality dongle switching modes, a fast unplug/replug — its
identity *changes*: the product ID flips, the serial may vanish, the
kernel devnum rotates, the `if<K>` set may renumber. A consumer
driving a multi-step flow has to answer one question:

> **Is the device I see now the same physical unit I started with?**

A naive `urn == urn` check answers *no* — interface URNs can renumber
across the transition. A `(vid, pid)` check answers *no* — the PID
flipped. `mgf-hrb`'s identity comparator (Part 2.4.1) answers *yes*:
same vendor, same hub port, within a debounce window. This recipe is
that pattern.

> **What ships today.** §1–§4 work against the real `mgf.hrb` SDK
> (`__version__ = "0.1.0"`) — the comparator is in the model layer
> (`Snapshot`, `same_likely`, `same_likely_explained` exported from
> `mgf.hrb`) and is pinned by `tests/unit/mgf/hrb/test_identity.py`
> against the iFig DFU scores. §5 ("pairing with the synthesizer")
> documents the broker-side event-stream story which is **Tier-1 —
> the subscription protocol is not yet wired**; the explicit
> snapshot + `same_likely()` form is the working answer until then.
> The runnable counterpart is `examples/03_track_identity.py`.

---

## §0. Pre-flight: device readiness (USB-03)

Before any `Client.claim(urn, EXCLUSIVE)` on a USB device, the
broker has to be the only userspace process holding that
device's kernel open-file. On stock Linux this is rarely true
for the devices figrecovery + libimobiledevice-shaped consumers
care about — common holders include:

| Device class | Common holder | Comm name |
|---|---|---|
| iFig (iPhone/iPad) in Normal mode | the mux daemon | `usbmuxd` |
| Bluetooth dongles | the BT stack | `bluetoothd` |
| Smart cards / U2F tokens | the PC/SC daemon | `pcscd` |
| Some printers | the print server | `cupsd` |

**The rule (v0.x — `USB-03` Approach A).** The broker mediates
between `mgf-hrb` tenants, not between `mgf-hrb` and arbitrary
userspace daemons. When an external holder owns the device, an
`EXCLUSIVE` or `SHARED_RW` claim raises `ConflictError` with
detail naming the holding pid + comm. `OBSERVE` mode still
works (read-only via sysfs is unaffected by daemon holds).

The operator scripts the resolution. For iFig devices in Normal
mode the conventional shape:

```sh
# Pre-flight — before driving the flow through mgf-hrb:
sudo systemctl stop usbmuxd       # release the device

# ...run the flow that claims the device via mgf-hrb...
python my_flow.py                  # Client.claim() now succeeds

# Post-flight — restore the daemon:
sudo systemctl start usbmuxd       # libimobiledevice tools work again
```

This is the same shape `idevicerestore` already uses internally
(it `systemctl stop usbmuxd`s itself before restoring); the
broker just refuses to fight for a device that another userspace
process actively holds, surfacing the conflict as a typed error
the operator can act on.

**When this section's rule doesn't matter:** §1 below assumes the
device is broker-claimable. If you're only running the comparator
against snapshots gathered via `mgf.hrb.sysfs.snapshot_from_sysfs`
(no broker claim involved), there's no daemon-coexistence
question — you're reading sysfs, not opening the device.

---

## §1. The pattern

Four steps: **find → snapshot → wait → confirm.**

```python
from mgf.hrb import (
    Client,
    Confidence,
    IdentityClass,
    Snapshot,
    same_likely,
)
from mgf.hrb.identity import now_ms

IFIG_VID = 0x05AC
RECOVERY_PID = 0x1281
DFU_PID = 0x1227
SOCKET = "/tmp/mgf-hrb.sock"


def restore(trigger_dfu) -> None:
    with Client.connect(SOCKET, cap_id=my_observe_cap_id) as client:

        # 1. Find the device in its starting mode. `client.list` returns
        #    URN strings; 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.
        starting_urn = next(
            (urn for urn in client.list("hw:/usb/**") if _is_ifig_recovery(urn)),
            None,
        )
        if starting_urn is None:
            raise RuntimeError("no iFig in Recovery mode on the bus")

        # 2. Snapshot its identity BEFORE the mode change. The Snapshot
        #    is backend-free; it survives the device dropping off the bus.
        #    Construct directly from worker-supplied identity fields —
        #    the comparator only needs the URN, vid/pid, serial (if any),
        #    bus + port-chain, and a timestamp.
        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(),
        )

        # 3. Your domain logic triggers the transition; the device drops
        #    off the bus and re-appears in DFU mode. Poll for it (Tier-1
        #    event-subscribe will replace this loop — see §5).
        trigger_dfu(starting_urn)
        candidate_urn = _poll_until_dfu_appears(client, timeout_s=30)

        # 4. Confirm: same physical unit, or a different iFig someone
        #    plugged in mid-restore?
        after = Snapshot(
            urn=candidate_urn,
            identity_class=IdentityClass.INTRINSIC,
            vid=IFIG_VID,
            pid=DFU_PID,
            serial_number=None,         # DFU mode drops iSerial
            bus=1,
            port_chain=(2, 3),
            captured_at_ms=now_ms(),
        )
        conf = same_likely(before, after)
        if conf is Confidence.DIFFERENT:
            raise RuntimeError("a different iFig appeared on the bus")
        if conf is Confidence.AMBIGUOUS:
            raise RuntimeError("ambiguous identity match; aborting for safety")
        # SAME or LIKELY_SAME — proceed.
```

The comparator returns one of four tiers (`Confidence.SAME`,
`LIKELY_SAME`, `AMBIGUOUS`, `DIFFERENT`). Your consumer encodes the
policy — the library encodes the heuristic.

---

## §2. Why not `==`

`Snapshot` equality compares every identifying field. Across a
Recovery → DFU transition the fields look like:

| Field | Recovery | DFU | Stable? |
|---|---|---|---|
| `urn` | `hw:/usb/bus1/port-2.3/dev/if0` | `hw:/usb/bus1/port-2.3/dev/if0` | yes — port-chain stable |
| `vid` | `0x05ac` | `0x05ac` | yes |
| `pid` | `0x1281` | `0x1227` | **no** — mode change |
| `serial_number` | `"F4GS9PWWJCM7"` | `None` | **no** — DFU drops it |
| `bus` / `port_chain` | `1` / `(2, 3)` | `1` / `(2, 3)` | yes |

Three of five field-groups differ — `==` is useless here. What is
*stable* is the vendor and the hub port chain, and that the change
happened quickly. `same_likely` scores exactly those signals (Part
2.4.1) and returns a tier rather than a bool, so the consumer owns the
policy:

- `SAME` — every signal agrees; proceed without question.
- `LIKELY_SAME` — the mode change is explained (PID flipped, VID
  stable, port stable, within debounce); proceed for any flow whose
  cost-of-wrong is recoverable.
- `AMBIGUOUS` — signals split; prompt the operator, or back off.
- `DIFFERENT` — different vendor or genuinely different physical
  unit; do not proceed.

---

## §3. The score, made visible

The comparator's score is inspectable for tooling and for debugging
flaky tests:

```python
from mgf.hrb import same_likely_explained

result = same_likely_explained(before, after)
print(result.confidence)        # Confidence.LIKELY_SAME
print(result.score)             # 11 (Recovery↔DFU per spec)
for c in result.contributions:
    print(f"  {c.score:+d}  {c.label}")
# +3  vid_match
# +2  serial_asymmetric_tolerated   (snapshot had serial, candidate doesn't)
# +4  port_match
# +2  mode_change_explained         (vid stable, pid differs, within debounce)
```

`Contribution` is a frozen `dataclass(label: str, score: int)`. The
detailed scoring rules are spec'd in
[`hrb-design/mgf-hrb-usb-worker.md`](../hrb-design/mgf-hrb-usb-worker.md)
§4.3 — the weights have been validated against the iFig DFU sequence
and inherited from the working `mgf-usb` library, and are pinned by
`tests/unit/mgf/hrb/test_identity.py` (Python) +
`hrb-code/src/identity_test.zig` (Zig) against the same expected
scores (drift detection for D-035).

---

## §4. Tolerances — when to relax the defaults

The comparator's defaults match the iFig case (the most common
DFU-shape transition). For other devices, pass a `ComparatorOpts`:

```python
from mgf.hrb import ComparatorOpts, same_likely

same_likely(
    before, after,
    ComparatorOpts(
        tolerate_mode_change    = True,    # default; PID-flip on stable VID + port allowed
        tolerate_port_change    = False,   # default; an unexplained move is suspicious
        tolerate_missing_serial = True,    # default; DFU-style serial vanish allowed
        port_debounce_ms        = 1000,    # default; the mode-change window (ms)
    ),
)
```

- **`tolerate_port_change=True`** — for "cable was moved by the
  operator deliberately" scenarios. Defaults off because two-identical-
  units-on-different-ports is the more common case and answering
  `LIKELY_SAME` there is wrong.
- **`tolerate_missing_serial=False`** — for devices that *always*
  expose `iSerialNumber` (USB drives with persistent serials, security
  keys). Catches the case where the serial vanishing is genuine
  evidence of a different device, not a DFU quirk.
- **`port_debounce_ms`** — widen for slow transitions; narrow for
  flaky environments where ports flicker.

---

## §5. Pairing with the synthesizer  *(Tier-1 — planned)*

> **Status.** Part 8.3 of the design has the broker running the
> comparator internally to collapse `removed`+`added` pairs into
> `state_changed` events on the subscribe stream. The subscribe
> protocol and `Client.monitor()` / `amonitor()` / `monitor_fd()`
> entry points are reserved in the public API but raise
> `NotImplementedError` today — the synthesizer lives on that
> stream. Until the broker-side wiring lands, use the explicit
> `Snapshot` + `same_likely()` form in §1.

When the subscribe stream ships, the broker will deliver
`state_changed` events carrying both `previous` and `resource`,
so consumers won't need to call `same_likely()` themselves for the
common collapse case:

```python
# Future shape — Tier-1 stub today.
with client.monitor(scope="hw:/usb/**") as events:
    for event in events:
        if event.kind != "state_changed":
            continue
        # The broker already correlated the pair. `event.previous`
        # and `event.resource` are the same physical device.
        if event.previous.pid == RECOVERY_PID and event.resource.pid == DFU_PID:
            return event.resource
```

The explicit `Snapshot` + `same_likely()` form remains the right
answer even after the synthesizer ships when:

1. The snapshot needs to outlive a debounce window (the broker keeps
   only ~1 second of pending pairs by default; a 5-minute restore
   flow needs your snapshot).
2. You want to compare against a *specific* prior state captured at a
   point of your choosing, not whatever the synthesizer paired.
3. You want to *override* tolerance defaults (the synthesizer uses
   the worker's per-bus defaults).

---

## §6. Testing this pattern

The comparator is pure — testing it needs no broker, no real USB, and
no fakes module. Construct `Snapshot` values directly:

```python
from mgf.hrb import Confidence, IdentityClass, Snapshot, same_likely


def _ifig_snapshot(*, pid: int, serial: str | None, port: tuple[int, ...], at_ms: int) -> Snapshot:
    return Snapshot(
        urn=f"hw:/usb/bus1/port-{'.'.join(str(p) for p in port)}/dev/if0",
        identity_class=IdentityClass.INTRINSIC,
        vid=0x05AC, pid=pid, serial_number=serial,
        bus=1, port_chain=port, captured_at_ms=at_ms,
    )


def test_recovery_to_dfu_is_likely_same():
    before = _ifig_snapshot(pid=0x1281, serial="F4GS9PWWJCM7", port=(2, 3), at_ms=1_000)
    after  = _ifig_snapshot(pid=0x1227, serial=None,            port=(2, 3), at_ms=1_500)
    assert same_likely(before, after) is Confidence.LIKELY_SAME


def test_a_different_ifig_is_different():
    before = _ifig_snapshot(pid=0x1281, serial="F4GS9PWWJCM7",  port=(2, 3), at_ms=1_000)
    other  = _ifig_snapshot(pid=0x1281, serial="DIFFERENT_SER", port=(3, 1), at_ms=1_500)
    # Different serial on the same vendor + different port → DIFFERENT.
    assert same_likely(before, other) is Confidence.DIFFERENT
```

For the integration-level shape (claim through the broker, then watch
for re-enumeration), `tests/e2e/test_e2e.py` is the working
substrate; the `mgf.hrb.testing` fakes module designed in Part 8.6.1
(`FakeClient`, `FakeResource`, `ScriptedBus`, `install_fake`) is the
next consumer-side surface to land.

---

## §7. The non-goals

The comparator is **not**:

- a cryptographic identity proof — a malicious device deliberately
  mimicking another will pass as `LIKELY_SAME`. That's a *capability*
  question, not an *identity* question (see Part 4).
- portable across hosts — `(bus, port_chain)` is a host property.
  Cross-host identity uses synthetic-pinned aliases (Part 2.4 rung 3).
- deterministic across firmware updates — a firmware change that
  alters `iSerialNumber` will look like a different device, correctly
  by the heuristic.

Honesty about what the comparator does and doesn't promise is itself
a fidelity guarantee.

---

## §8. Related

- [`examples/03_track_identity.py`](../examples/03_track_identity.py)
  — the runnable counterpart of this recipe (no broker required).
- `mgf-hrb-complete-design.md` Part 2.4 (the stability ladder), Part
  2.4.1 (the runtime comparator concept).
- `mgf-hrb-usb-worker.md` §4 (the comparator algorithm, weights, and
  the iFig validation).
- [`usb-event-monitor-with-role-interfaces.md`](usb-event-monitor-with-role-interfaces.md)
  — the event-stream patterns the synthesizer in §5 will deliver on.
- The validated source of the heuristic: `mgf-usb`'s
  `docs/design/IDENTITY_HEURISTIC.md` at the repo root.
