Ctrl K
ComunidadGuías…

proposals/2026-07-30-one-token-across-scopes.md

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

Archivo original ↗Preguntar a KiBot
# One play token per person, across both session scopes

**Goal** - a device in the blended library view and one focused on a single library can no longer
both be "the active player" at the same time.

**Tier** - T1 shading into T2. No new fields, no new methods, no wire change; what changes is which
stored row a successful claim writes. Recorded as a proposal anyway because it is cross-device
arbitration, and the last one of those (2026-07-17) earned its write-up.

## Why

Found while diagnosing Tim's "two devices play at once" report (PR #272) and left open because it
was not what he hit. `host/state.js` keys the play session as `session:{ownerId}` for a
single-library device and `session:merged:{ownerId}` for one in the blend. Two rows, each with its
own `activeDeviceKey` and `generation`, and a compare-and-set on one is invisible to the other - so
a phone in the blended view and a phone focused on one library can each hold "the" token and both
play, for as long as they like. Nothing odd is required to get there: merged mode turns itself on
at 2+ paired libraries, so the two phones only need different pairing sets.

The separate rows are deliberate and stay. One host can be BOTH a person's single-library session
home and the elected merged home, and the queues mean different things in each: a merged queue
carries foreign trackIds tagged with their owning library, a single-library queue carries local
ones. Collapsing them would break "Play here" across scopes, which is a worse bug than the one
being fixed.

## Scope

Separate the two things the row conflates. **The queue is per-scope; the token is per-person.**

A successful `session.claim` in one scope also takes the OTHER scope's row for the claiming device:
`activeDeviceKey` moves, `generation` bumps, and the queue/index/position/shuffle/repeat of that
row are left exactly as they were. So:

- the previous holder of the other scope gets the same `session-superseded` push the same-scope
  loser already gets, and stops instantly;
- if it misses the push, its next `session.set` is refused - `setSession` gates on
  `activeDeviceKey`, which no longer names it - so the existing lazy path stops it too;
- "Play here" from either scope still works, because each row kept its own queue.

`session.get` is untouched: a device still reads its own scope, and `isActiveHere` now answers
honestly for a person rather than for a scope.

**Not in scope.** Merging the rows, electing one scope per person, or teaching the client to read
both. All three were considered; see below.

**AND A LIMIT WORTH STATING PLAINLY, because it is not obvious from the fix.** This arbitrates two
scopes on ONE host. It cannot arbitrate two devices whose session targets are DIFFERENT hosts, and
that is reachable: the merged home is the smallest hostKey among a device's connected libraries
(`electHome`), so a blended phone can elect host A while a single-library phone sits on host B.
Their rows then live in different stores under different ownerIds - the Umbrel's `p:naz38xk...`
means nothing on the Mac - and no host can see both. Concretely, with Tim's two hosts today
(Mac `cc971fqe...`, Umbrel `se4t5s91...`), a blended phone elects the MAC while a single-library
phone on the Umbrel is untouchable from there.

That is structural to host-as-hub rather than a defect in this change: arbitration needs one
authority, and there are two. Fixing it would mean a cross-host election for the token itself,
which is a much larger design question. Logged in TODO.md rather than smuggled in here.

## Compat

- No schema change. Same fields, same keys, same methods.
- OLD host, new client: no cross-scope supersede, which is exactly today's behavior. Not a
  regression, just the bug staying fixed only on updated hosts.
- OLD client, new host: it is superseded by a push and a refused write, both of which it has
  understood since 2026-07-17. Nothing to teach it.
- A person who has only ever used one scope has one row and never notices; taking a row that does
  not exist yet creates an empty one, which is what a first claim already does.

## Verify

- Unit: claiming the merged scope moves the single-scope row's `activeDeviceKey` and bumps its
  generation, and leaves that row's queue untouched. And the reverse.
- Unit: the claiming device holding both rows means the other scope's previous holder is refused
  by `setSession`.
- Integration, two devices one person against a real host: A claims single, B claims merged, A
  receives `session-superseded` and its next write is refused.
- VERIFIED ON HARDWARE 2026-07-30, before and after, on the same rig: TCL paired to BOTH the Mac
  and the Umbrel (blended, so its session home elects the Mac), Pixel paired to both but FOCUSED on
  the Mac (single scope), both devices assigned to one person on the Mac. Same script both times;
  only the host code changed.
    * BEFORE (Mac running 2026-07-24 host source): both phones `state=PLAYING(3)` for a full
      minute, and both `sessionInfo` calls returning `active:true` at the same moment - each
      reading its own row, each certain it held the token.
    * AFTER: exactly one plays. The blended TCL claiming while the Pixel played stopped the Pixel
      within 10s, and the Pixel's own read became `active:false, activeDeviceName:"TCL"`. The
      other direction stops the TCL and reads `activeDeviceName:"Pixel"`.
  The Pixel was returned to its original single-library Umbrel pairing afterwards.

## Rollback

Revert. The rows go back to being independent and the two-scope overlap returns. Nothing persists
that a reverted host would misread - the fields are unchanged, and a row whose token was moved
cross-scope is indistinguishable from one claimed normally.

## Open questions

- Should the cross-scope take also copy `playing: false` onto the other row? It currently leaves
  `playing` as it was, which briefly leaves a row saying "playing" under a device that is not.
  Nothing reads it in that window except the reconcile's liveness check, which is looking at its
  OWN scope, and the next heartbeat from the new holder corrects it. Left alone rather than
  guessed at.