proposals/2026-07-21-person-carryover-on-repair.md
Rama master · réplica de lectura del repositorio original.
# Carry a person over when a device that LEFT BY ITSELF pairs again
**Goal** — a phone that removed a library and later pairs back to the same host returns to the person
it already belonged to, instead of arriving as a stranger and leaving that person holding zero
devices. An operator revoke must NOT carry over.
**Tier** — T3 (unpair semantics + who a device belongs to, refining
`proposals/2026-07-20-client-self-leave.md` and `proposals/2026-07-21-auto-person-on-new-claim.md`).
## Problem
`grants.revoke` tombstones the row and `pair.js` mints a **fresh** grant for any device whose existing
row is revoked:
```js
const existing = await this.grants.get(remoteKey)
if (existing && !existing.revokedAt) { /* idempotent re-pair, personId untouched */ }
else await this.grants.grant({ deviceKey: remoteKey, ... }) // personId: null
```
So every remove-and-pair-again cycle produces an unassigned device and an **orphan person**: a row
holding no devices. Observed live twice this month - removing the Mac library from the TCL left person
"Timmy" empty, and the Mac host already carried an orphan "Tim2" from an earlier cycle.
Two costs, and the second is the one that matters:
1. **Silent accumulation.** People pile up on the dashboard holding nothing. Cosmetic; deleting them
already works.
2. **User state is stranded.** Since milestone 3 the host is the hub: favorites, resume positions,
play counts and playlists are keyed by **owner**, and for an assigned device the owner is its
*person*. A device that comes back as unassigned therefore reads an empty library-of-one - its
own history is still on the host, under a person nothing points at any more. Re-pairing looks
like data loss, and "just claim the name again" does NOT fix it: claiming a name a person already
holds correctly stays pending until the operator confirms (2026-07-21).
## The rule
> **A device that revoked ITSELF, and pairs again through an operator-opened window, returns to the
> person it held at the moment it left. Any other revocation starts it as a stranger.**
This is deliberately narrower than "restore the person on re-pair". It restores only what the device
gave up voluntarily, and only for the same Noise-authenticated key.
## Scope
**Changes:**
- `grants.revoke(deviceKey, { by })` records **`revokedBy`** on the tombstone: `'self'` from
`leaveDevice` (the `device.leave` path), `'operator'` from `revokeDevice`, `'person'` from
`revokePerson`. Default `'operator'` - so anything that forgets to say fails to the strict side.
- `pair.js`, on the mint path, carries `personId` (and `claimedUser` / `claimedAt`) over from the
revoked row **only if all of**:
- `existing.revokedBy === 'self'`, and
- `existing.personId` is set, and
- that person still exists and is not revoked.
Otherwise the fresh grant is unassigned exactly as today.
- Logged as its own event (`pair:person-restored`, with the person id and name) - a re-pair that
restores an identity must be visible in the host log, not inferred from a diff.
**Does NOT change:**
- **Operator revokes.** A device the operator threw out comes back as a stranger and needs the
claim/confirm flow. That checkpoint is the whole point of revoke.
- **`revokePerson`.** The person is revoked; there is nothing to return to.
- **Expired guest grants.** No tombstone is written by expiry (the gate simply denies), so a guest
re-pairing lands on the existing-row path and keeps whatever it had - unchanged by this proposal.
- **The idempotent re-pair path** (a LIVE grant re-scanning the QR) - already keeps personId.
- **Orphan people that already exist.** They stay until deleted on the dashboard. This stops the
mechanism producing new ones; it does not clean up history.
- **The grant store's placement.** Still host-local, still never replicated.
## Security analysis
The question is whether this hands identity back to anyone the operator did not admit.
- **The key is proof.** The carried-over row is keyed by the same device public key, and HyperDHT's
Noise handshake authenticates it. Only the holder of that secret key can present it, so this cannot
be another device wearing the name.
- **A window is still required.** A revoked device is denied by the firewall; the ONLY way to reach
the mint path at all is an operator opening a pairing window and the device presenting that
window's one-time `rv`. The operator's deliberate admission remains the root protection, exactly as
it is for a first pair.
- **Revoke keeps its teeth.** `revokedBy: 'operator'` never carries over, so "throw this phone out"
is unchanged: it comes back unassigned and pending. Same for a revoked person.
- **What it does cost.** For the self-leave case the operator loses one confirmation click they get
today - a phone that left and returns rejoins its person without being re-confirmed. That click is
a *re*-confirmation of a decision the operator already made, for a key they already trusted, on a
connection they just admitted. Accepted, and made visible by the log line + the dashboard row.
- **Stale pointer.** If the person was deleted or revoked while the device was away, the checks fail
and the device arrives unassigned. Nothing can point at a person that is not there.
## Compat
Host-only, no wire change; old and new clients pair identically. `revokedBy` is a new persisted
field, so **existing tombstones have it undefined** - which is not `'self'`, so they never carry over.
Every grant written before this ships behaves exactly as it does today; the new behaviour only
applies to leaves recorded after the upgrade. An old host paired by a new client is unaffected (there
is nothing on the wire to be old about).
## Verify
`npm run verify`, with unit tests in `test/grants.test.js` covering:
- a self-leave then re-pair restores `personId` (and the claim), logged;
- an **operator** revoke then re-pair does NOT - `personId` null, claim pending;
- `revokePerson` then re-pair does NOT;
- a self-leave whose person was DELETED meanwhile does NOT (and does not throw);
- a self-leave whose person was REVOKED meanwhile does NOT;
- a tombstone with no `revokedBy` (pre-upgrade row) does NOT.
Then on hardware, TCL vs the Umbrel: favourite a few tracks, remove the library on the phone, pair
again through a fresh window - the device shows under the same person on the dashboard and the
favourites are back, with no operator confirm. Repeat with an operator revoke: the device returns
unassigned and pending.
## Rollback
Revert the `pair.js` branch. `revokedBy` stays on the tombstones as inert data (nothing else reads
it), so there is no migration either way and no state to unwind.
## Open questions
- **Should the dashboard offer to delete a person the moment it loses its last device?** That is the
other half of the orphan story (the accumulation half) and is a separate, T0 dashboard change. Not
included here: deleting is destructive of that person's user state, and with carry-over in place an
emptied person is often about to be re-filled by the same phone coming back.
- **Should a device leaving keep its state at all?** Today `device.leave` revokes the grant but leaves
the user state under the person untouched, which is what makes the return valuable. If "remove
library" should mean "forget me entirely", that is a different proposal - and one that would want
an explicit "delete my data" affordance rather than a silent side effect of unpairing.