Ctrl K
ComunidadGuías…

proposals/2026-07-26-one-connection-per-library.md

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

Archivo original ↗Preguntar a KiBot
# One connection per library: retire the "active" tier

**Goal** — every paired library reaches its host the same way, so no library is
privileged, "active" stops being a reliability tier, and the class of bug that
produced tonight's outage cannot recur.

**Tier** — **T2.** No wire change and no host change: the media channel, the
grant model and revoke semantics are untouched. What moves is IPC message shape
(`host:connected` / `host:disconnected` become per-library rather than
"the active one") and the runtime meaning of a persisted field
(`hosts.json.activeHostKey` stops selecting a connection tier). Proposal per
Constitution §3; no rollback plumbing needed beyond reverting the commit, since
nothing on disk or on the wire changes shape.

---

## Why this exists

PR #205 fixed a real outage: a library the phone was connected to could vanish
from All libraries for 20+ minutes. The A/B that followed (2026-07-26, same
phone, same LAN, one wifi bounce per configuration) settled the cause:

| Active | Pool | What the pool connection did |
| --- | --- | --- |
| Mac Mini | Umbrel | `pool:watchdog {"state":"zombie","conns":1,"live":1}` |
| Umbrel | Mac Mini | `pool:watchdog {"state":"zombie","conns":1,"live":1}` |

**The symptom followed the ROLE, not the host.** Whichever library was not
"active" was the one that died quietly and stayed dead.

The reason is structural. The active host's connection is reached through
`ensureConnected()`, and there are **39 call sites** - every favourite, every
resume write, every identity read. Each one re-joins the topic and forces a
discovery refresh, so ordinary use continuously repairs that one connection. No
equivalent caller exists for any other library: before #205 its only driver was a
timer that could stop while the host was still dark, and even now the watchdog is
a patch on an asymmetry rather than its removal.

Tim's question, which is the right one: with a blended library and every host
connected, **why is one of them special at all?** Auditing what `activeLibraryId`
still decides:

- **Browse and search** - served from the merged in-memory index. No host involved.
- **Streaming** - `routeTrack` / `bestCopy` send each track to a host that owns it,
  with failover to another copy. Not the active one.
- **Favourites, resume, play counts** - `trackClient(trackId)` routes to the
  track's OWNING host, with a per-library outbox when it is offline.
- **Requests** - fan out to every connected host (#187), collapsed for display.
- **The play session / "Play here"** - already coordinates through an ELECTED home
  (`sessionHomeLib` = smallest hostKey among connected), deliberately *not* the
  active one.

So nothing a user can name depends on it. What remains is internal: which
connection is the variable `client`, which one the shim falls back to, and -
critically - which one gets the well-trodden repair path. That is not a product
concept, it is a leftover from the single-host era that has quietly become a
reliability tier.

### The second cost: choreography

Because there are two tiers, connections must be MOVED between them, and every
move has been a bug:

- `demoteActiveToPool` exists because adding a library orphaned the previous
  active connection - Hyperswarm dedups one connection per peer, so re-joining its
  topic never re-emitted it and the host showed offline forever (2026-07-23).
- `promotePoolToActive` exists because the mirror case stranded the survivor when
  the active library was removed (2026-07-24).
- `attachActive` grew a `switching` flag so a fresh client is used for the incoming
  host and the outgoing one keeps its own.

None of that is essential complexity. It is all bookkeeping for a distinction we
are proposing to delete: with one tier there is nothing to promote, nothing to
demote, and nothing to orphan.

---

## Scope

### In

1. **One connection map.** Extend today's `pool` map to hold EVERY paired library,
   the current active one included: `libraryId -> { host, client, discovery,
   nudgeTimer, waiters }`. One `attach(host, conn)` replaces `attachActive` +
   `attachPool`. `demoteActiveToPool` and `promotePoolToActive` are DELETED.
2. **One accessor.** `clientFor(libraryId)` replaces the `client` global plus
   `poolClient`. `ensureConnected()` becomes `ensureLibrary(defaultLibraryId)`,
   keeping its name at first so the 39 call sites change in one mechanical pass.
3. **One repair path.** The #205 watchdog covers every library rather than
   skipping the active one, and runs whenever the app has 1+ paired libraries
   rather than only in merged mode. The nudge loop likewise becomes per-library
   with no special case.
4. **The shim resolves per request.** `setClient(c)` gives way to the resolver it
   already half has (`hostClient`, `libForTrack`, `libForCover`), falling back to
   the default library when a URL names no host.
5. **`activeLibraryId` becomes the DEFAULT library**, in name and in comments: the
   answer to "which library when the caller named none" and "which library does
   single-host mode show". It selects a default, never a connection tier.
6. **Per-library connection events.** `host:connected` / `host:disconnected`
   always carry `libraryId`, so the UI greys exactly the library that dropped.
   (The merged status already reports per-library connectivity; this makes the
   single-host path agree with it.)

### Out

- **The wire protocol, the grant store, revoke.** Untouched, including the T3
  guarantee that revoke kills live connections - it must be re-verified, not
  redesigned.
- **The host.** No change; an unmodified host serves this phone exactly as today.
- **The elected session home.** Already independent of "active"; it stays as is.
- **Single-host UX.** With 0-1 libraries the app looks and behaves identically.
- **The merged index build.** Just repaired in #205; this proposal does not touch
  `buildMergedIndex` or the rebuild gate.
- **Per-library state on disk** (dirs, leases, outboxes, queues). Already
  per-library, and correctly so.
- **A "make this library active" picker.** Explicitly rejected: exposing the
  choice would enshrine the asymmetry we are removing. If a user ever needs to
  pick a default for OTHER reasons (say, where a libraryId-less write lands),
  that is a separate, smaller question.

---

## Compat

- **On disk:** `hosts.json` keeps `activeHostKey` with its current shape and
  meaning-as-a-default. No migration, and an older build reading it still works.
- **On the wire:** nothing changes, in either direction. A phone on this build
  talks to today's hosts; today's phones talk to today's hosts.
- **Across the app:** the WebView UI and the worklet ship in the same APK, so the
  per-library event shape has no cross-version window. The UI change is additive
  (`libraryId` on events it already receives).
- **Downgrade:** reverting the commit restores current behaviour with no cleanup,
  because nothing persisted changed shape.

---

## Risks

- **Hyperswarm's one-connection-per-peer dedup.** The reason today's code
  choreographs moves. Unifying removes the moves, which is the point - but the
  first cut must confirm that joining every library's topic up front never
  produces two entries for one peer (it cannot: one topic per host, one host per
  peer, and `attach` is keyed by libraryId).
- **39 call sites.** `ensureConnected()` is threaded through half the method
  surface. The mitigation is to keep the function and its name, and change only
  what it resolves to, so the diff stays reviewable.
- **Battery and data.** Outside merged mode the pool is not joined today, so a
  single-focus user would go from 1 topic membership to N. Idle Hyperswarm
  keepalives are small, but this should be measured on the TCL over an hour
  before it ships, and stated in DONE.
- **The offline lease.** `stampAuth()` stamps the active library; per-library
  stamping already exists (`stampAuthFor`) and every connection would stamp its
  own. Worth an explicit test - a lease that stops being renewed silently expires
  a cached library.

---

## Phases

- **P1 - symmetry without restructuring.** Extend the #205 watchdog and the nudge
  loop to cover the default library too, and drop the merged-mode-only guard. Small,
  independently shippable, and removes the remaining reliability gap even if P2
  never lands.
- **P2 - one map, one accessor.** `clientFor` / `ensureLibrary`, `attach`, delete
  demote/promote, shim resolves per request. The bulk of the diff.
- **P3 - naming and events.** Rename the runtime concept to "default library",
  make connection events per-library, update the UI to grey per library.

---

## Verify

- **Gate:** `npm run verify`, green, no merges red (Constitution §5).
- **Unit:** the connection-map policy (which libraries need a dial, which need a
  probe) in the style of `worklet/pool-health.js`; a `clientFor` resolution table
  covering named / unnamed / offline libraries.
- **Integration (testnet, `test/transport.test.js` harness):** two hosts, both
  reachable through one swarm; kill one and confirm the other is unaffected;
  change the default library and confirm NO connection is torn down or rebuilt -
  the property that makes the whole change worth it.
- **Hardware (TCL + the Umbrel + the Mac), each with logs kept:**
  1. Launch: both libraries connect, blend shows both.
  2. Wifi bounce with the app foregrounded: both return, neither zombies.
  3. Background 5+ minutes and return: both return, blend rebuilt.
  4. Add a library while connected to another - the 2026-07-23 orphan shape.
  5. Remove the default library while connected - the 2026-07-24 strand shape.
  6. **Revoke mid-song from the dashboard: within a second, browse, the next
     track, art and reconnect are all denied.** The T3 guarantee, re-proved.
  7. An hour idle on the TCL with 2 libraries, battery noted, for the risk above.

---

## Open questions

1. **Keep a persisted default at all, or elect it?** `sessionHomeLib` already
   elects a coordinator deterministically. The same could answer "which library
   when none was named", which would let `activeHostKey` be deleted outright - at
   the cost of a hosts.json migration (a bigger T2). Recommendation: keep the
   persisted default for now; it is stable across restarts and costs nothing.
2. **Should focusing a single library disconnect the others?** No, in this
   author's view - the header switcher is a VIEW filter and the blend is the
   default with 2+ libraries. Worth confirming against the battery measurement.
3. **Does the default library still deserve any special treatment at all** (e.g.
   first to connect on a cold launch, so the app is usable a moment sooner)? A
   deliberate ordering is fine; a permanent tier is not.