Ctrl K
ComunidadGuías…

proposals/2026-07-20-client-self-leave.md

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Client self-leave: removing a library ends the grant on the host

**Goal** — when a device removes a library (or unpairs), tell that host to drop the device's *own*
grant, so a departure the user made on the phone is reflected on the operator's dashboard instead of
leaving a live grant and a stale row behind.

**Tier** — T3 (touches the grant/revoke core: the host's allow-list).

## Problem

`removeHost` / `forget` are local-only by design (see `src/bare.js`: *"the host still holds the old
grant; it can revoke it if it wants the row gone"*). Two consequences the user hit:

1. The device + person rows persist on every host's People & Devices list after the phone removed
   the library — nothing told the host.
2. Worse than cosmetic: **the grant stays LIVE**. "Remove library" doesn't actually end the phone's
   access at the host; it hides it locally, and a re-pair would silently restore access with no
   operator re-confirmation. "Remove" doesn't mean what it says, and the host — which CLAUDE.md makes
   the sole authority on access — never learns the device chose to leave.

## Scope

**Changes:**
- New media method **`device.leave`** (no params). The handler revokes *this connection's own* grant
  (`grant.deviceKey`, the Noise-authenticated key — never a client parameter) and cuts the
  connection, the same teeth as an operator revoke, logged `host:device-left`. Allowed for ANY scope
  (a device dropping its own access is the least-privileged possible action; a read-only device may
  leave too), so it is deliberately NOT in the `MUTATING` scope gate.
- Host `leaveDevice(deviceKey)` mirroring `revokeDevice` (revoke + `connections.kill`), wired into
  `serveMedia` as an `onLeave` callback.
- Client `deviceLeave()` RPC. `removeHost` and `forget` call it **best-effort** on each removed
  host's live connection before tearing the connection down; a rejection/close is swallowed.

**Does NOT change:**
- The offline path. If the host is unreachable when you remove the library, nothing is sent — the
  row persists exactly as today, and the operator can still delete it by hand. Self-leave is a
  best-effort courtesy, not a guarantee; we do NOT queue a self-revoke to fire on some later connect
  (a delayed, unattended self-revoke is surprising, and you can't reach a host you can't reach).
- Person rows. We do NOT auto-delete a Person. Revoke tombstones the device, and the dashboard
  already HIDES revoked rows behind its "show revoked" toggle (PR #12) — so a left device disappears
  from the default view while the tombstone stays auditable, and a Person that still holds other
  devices (Tim's "Tim" also has the TCL) is untouched. Auto-pruning an operator-named Person is
  destructive and conflates *disconnect* (transient — must never delete anything) with *leave*;
  left to the operator's existing one-click delete. See Open questions.

## Compat

No wire/channel change — `device.leave` is a new method string on the existing media req/res, exactly
like the favorites/session methods were. An **old host** answers `ENOMETHOD`; the client swallows it
and falls back to today's local-only behavior. An **old client** never sends it. Fully backward-
compatible; no migration.

## Verify

`npm run verify` (host integration test over real DHT: pair → connect → `deviceLeave()` → the grant
is revoked and the live connection killed; a read-only grant can still leave; a *second* device under
the same person is untouched). Then on-device: on the Pixel, in a blend, remove one library while
connected — its device row leaves the host's default Devices list within a second; the TCL (also under
"Tim") is unaffected and "Tim" persists.

## Rollback

Revert the change. Clients fall back to local-only `removeHost`/`forget`; hosts simply stop receiving
`device.leave`. No persisted state depends on it (a revoke tombstone is the same shape an operator
revoke produces).

## Open questions

- **Revoke vs delete the row.** This revokes (tombstones), so the row is hidden-by-default but
  auditable under "show revoked". Deleting it outright would match "gone" more literally but loses the
  audit trail and the security model refuses deleting a *live* grant (must revoke first anyway).
  Chosen: revoke.
- **Person auto-prune.** Should a leave that empties a Person (all its devices revoked) auto-delete an
  un-renamed / auto-created Person, while keeping operator-named ones? Deferred — manual for now.