summaryrefslogtreecommitdiffstats
path: root/docs/playlists.md
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-16 12:21:04 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-16 12:21:04 +0200
commit8889e90b8f1629f983c7ed4e44a2badbda525095 (patch)
tree9e5b32d9eed7338188be3ddf7dffb16e9e15b75e /docs/playlists.md
parent9cbff21274604e37c0986d57937deef85819c396 (diff)
downloadmeshbay-8889e90b8f1629f983c7ed4e44a2badbda525095.tar.gz
docs: playlists, corrected against what building it needed
Fourteen places the proposal was wrong, listed rather than edited away: the MNP bump was 2.1 and is 3.1; the kind pattern refused the one playlist every account has; confirm() is the house style and prompt() is what throws; submenus expand in place; deflate is 4.5x and the first fixture's 23x was a property of the fixture. Adds the hazard the proposal missed entirely: two wrappers that take two arguments and forward two, so every "add to queue" arrived as a plain play. The acceptance section now says what is checked and what is not — no real daemon round trip, no two-browser convergence, no Firefox. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'docs/playlists.md')
-rw-r--r--docs/playlists.md404
1 files changed, 289 insertions, 115 deletions
diff --git a/docs/playlists.md b/docs/playlists.md
index ecbbac4..49b1585 100644
--- a/docs/playlists.md
+++ b/docs/playlists.md
@@ -1,7 +1,13 @@
# MeshBay — Playlists (design)
-> Status: **proposal**, not implemented. This was deferred out of the Music
-> application's design, which deferred it for the right reason: *"a genuinely
+> Status: **built**, 2026-09-16, in five commits. This document was written as
+> a proposal and has been corrected against what the code turned out to need —
+> §0.2 lists every place the proposal was wrong, because a design document that
+> quietly agrees with the implementation afterwards is a document nobody can
+> use to check the implementation.
+>
+> It was deferred out of the Music application's design, which deferred it for
+> the right reason: *"a genuinely
> new category of per-account node state, not covered by anything E9 already
> enumerates — needs its own design pass (ownership, sync across devices,
> whether it's node-local or something else)"*. This document is that pass.
@@ -53,6 +59,30 @@ fields that actually have to be there.
---
+## 0.2 What building it corrected
+
+Fourteen things. They are listed rather than edited away because the ones that
+were *wrong* are more useful than the ones that were right.
+
+| | The proposal said | Building it found |
+|---|---|---|
+| 1 | MNP MINOR bump to **2.1** | The wire version was already **3.0**; the bump is **3.1**, and `MNP_MIN_SUPPORTED` does not move because nothing here is required |
+| 2 | `kind` is `playlist:<uuid>` | That refuses `playlist:favorites` — the one playlist every account has. The pattern is `[A-Za-z0-9_-]{1,64}` |
+| 3 | The blob is **msgpack**, "same as everything else on MNP" | The node never parses it, so the encoding is a private choice; MNP's codec is private to `transport.js`, a classic script. **JSON**, which is what keeps the merge runnable by `node` with nothing around it |
+| 4 | Delete confirms inline, "not `window.confirm()`, which blocks the SPA" | Backwards. `prompt()` **throws** in Electron and is banned by a test; `confirm()` is measured to work and is used in twenty places. A *name* needs a field; a *confirmation* uses `confirm` |
+| 5 | Submenus fly out, and are a sheet on a coarse pointer | They **expand in place**, which the account menu's language list already does. No flipping, no hover intent, no separate mobile design. The tracklist level loads when it is expanded |
+| 6 | Add all four new files to `STATIC_FILES` / `SPLIT_FILES` | Those lists check hook ordering. The four hookless modules do not belong in them, exactly as `source-merge.js` does not; only `menu.js` and `playlist-menu.js` do |
+| 7 | Four modules | **Five**: `playlist-crypto.js` is split out so seal/open can be executed standalone. A crypto layer that cannot be executed is one nobody has checked |
+| 8 | "Save the current queue" in the queue panel **and** the toolbar menu | The queue is the player's own state. Mirroring it into a menu in another component would mean lifting that state out of the player for a menu item, so it lives in the queue panel alone |
+| 9 | — | **The `op` hazard**, missed entirely. `group-page.js` and `search-page.js` both wrap `onPlayQueue` with a two-argument signature and forwarded two, so every "add to queue" arrived at the player as a plain play. §9.6 |
+| 10 | — | **`user_blob_list` is what makes sync quiet.** Comparing against the merged watermark says nothing about what *that node* holds, so the first version pushed every body on every sync. §7.1 |
+| 11 | — | **A tombstoned playlist's body must be deleted from each node**, or the account's quota fills with graves. §7.2 |
+| 12 | The IndexedDB version is bumped "in the existing `onupgradeneeded`" | And must stay there, in one file. Two modules opening one database at versions of their own is a `VersionError` thrown at whichever runs second. §14.2 |
+| 13 | deflate is worth about three | **4.5×** on realistic data — and the first fixture said 23× because it repeated one content hash on every track, which is a property of the fixture. §4.2 |
+| 14 | `body_rev` alongside `rev` | Right, and worth saying why: a rename and a track added elsewhere both write n+1 under one counter, and one of two non-overlapping edits is lost. §5.1 |
+
+---
+
## 1. What this design does not reopen
- **Views over the index, never a catalogue** (`MESHBAY_DESIGN.md` §6.5, §9.1).
@@ -75,16 +105,17 @@ fields that actually have to be there.
---
-## 2. Four problems, and only two of them are hard
+## 2. Five problems, and only two of them are hard
| | Problem | Where it is solved |
|---|---|---|
| A | Somewhere durable to put a few hundred KB per account | §3 — several acceptable answers, one clearly best |
| B | How to **name** a track that lives in one group on one node, inside a list that spans several | §5.3 — where the silent failures live |
| C | Reconciling N copies when nodes are ON and OFF | §6 — the stated fear, well founded *only* for the wrong granularity |
-| D | A queue that can be **appended to**, not only replaced | §8 — the player's queue is three `useState`s reset wholesale; that is the one real code change |
+| D | A queue that can be **appended to**, not only replaced | §9 — the player's queue is three `useState`s reset wholesale; that is the one real code change |
+| E | Getting `op` from the menu to the player **through two wrappers** | §9.2a — not foreseen at all, and the only defect here that shipped in a form nothing on screen would have shown |
-The request reads as a storage problem. Storage is the easy quarter of it.
+The request reads as a storage problem. Storage is the easy fifth of it.
---
@@ -288,11 +319,16 @@ which is the failure this whole document exists to prevent.
| limit | value | enforced by |
|---|---|---|
| manifest (`kind = "playlists"`) | 64 KB sealed | node |
-| one body (`kind = "playlist:<id>"`) | 1 MB sealed ≈ 10 000 tracks | node |
+| one body (`kind = "playlist:<id>"`) | 1 MB sealed ≈ 17 000 tracks | node |
| all rows for one account on one node | 8 MB | node |
| tracks per playlist | 10 000 | client, before sealing |
| playlists per account | 200 | client, before sealing |
+At 4.5× and ~270 bytes a track, a sealed body runs about **60 bytes a track**:
+the 1 MB cap is ~17 000 tracks in one playlist, and 8 MB per account is ~140 000
+track references across all of them. Both hold with room to spare, which is the
+point of writing the measurement down rather than the guess.
+
The client limits exist so the ordinary case produces a sentence in the UI
rather than an MNP error; the node limits exist because a client is not
trusted to hold to them, and this is an unbounded write primitive pointed at
@@ -313,6 +349,14 @@ named and then dismissed.
### 5.1 The manifest — `kind = "playlists"`
+**JSON, not msgpack**, inside the blob. The node stores bytes and never parses
+them, so the encoding is this client's private choice rather than a wire
+format — and MNP's own codec is private to `transport.js`, which is a classic
+script and cannot be imported. Keeping the payload in reach of `JSON.parse` is
+what lets `playlist-merge.js` be executed by `node` with nothing around it,
+which is the only real evidence the merge has. After deflate the difference
+from msgpack is a few per cent.
+
Small, always resident, and the only thing every menu needs:
```
@@ -350,9 +394,16 @@ how §6.3's defect ships.
```
Array order *is* playlist order. `rev` here is the body's own, and the
-manifest's `body_rev` is a copy of it — a client that sees a manifest naming
-`body_rev: 41` while holding body `rev: 39` knows to fetch without fetching to
-find out. A body whose manifest entry is `deleted` is dropped locally and
+manifest's `body_rev` is a **watermark**: the highest body revision anybody has seen,
+merged by taking the maximum and never decreasing, with `count` following
+whichever side carries it. A client that sees a manifest naming `body_rev: 41`
+while holding body `rev: 39` knows to fetch without fetching to find out.
+
+The two counters are not redundancy. Rename a playlist on one device while
+adding a track to it on another and both write revision n+1; under a single
+counter those collide and one of two edits that do not overlap at all is lost.
+`rev` carries the name and the tombstone, `body_rev` carries the tracks, and
+they move independently. A body whose manifest entry is `deleted` is dropped locally and
`user_blob_delete`d on each node as it is reached; the tombstone in the
manifest is what survives, not the body.
@@ -500,13 +551,30 @@ well as at menu time: the manifest says which bodies are stale (`body_rev`), and
a body is pulled when it is opened, loaded, or edited — not on every connection.
A user with 40 playlists who only ever plays two never transfers the other 38.
-Two additions on top:
+As built, that is: **one reconciliation when Music mounts on a connected
+group**, and another whenever a track is added to a playlist — both over the
+transport that group already has open. And an explicit **"Sync now"** in the
+playlist menu. **On sign-in, nothing**: the local copy is authoritative and
+complete (§6.4), and the first group opened reconciles.
-- **An explicit "Sync now"** in the playlist menu, which does the Search-style
- sweep and reports which nodes it could not reach — the same honest reporting
- `fetchAllIndexes` already does.
-- **On sign-in, nothing.** The local copy is authoritative and complete (§6.4);
- the first group opened will reconcile.
+### 7.1 Ask the node what it holds, never guess
+
+`user_blob_list` returns kinds and revisions, no payloads, in one message. Sync
+compares against **that**, not against the merged watermark.
+
+The difference is not an optimisation. `body_rev` in the manifest is the highest
+revision *anybody* has seen; it says nothing about whether this particular node
+has it. The first version of `syncWith` compared against it and so could not
+tell "this node is behind" from "this node is up to date" — and pushed every
+body on every sync, over a connection it had borrowed from something else.
+
+### 7.2 A tombstone has a body to reclaim
+
+Deleting a playlist tombstones its manifest entry, and the entry is what has to
+survive. The **body** does not: it is `user_blob_delete`d from each node as that
+node is reached. Without that step the body of every playlist ever deleted stays
+on every node for ever, and the account's 8 MB quota fills up with graves —
+which the node is entitled to refuse the next real write for.
A device that only ever opens one group therefore only ever converges with one
node. That is correct and not a defect: convergence is eventual, and the copy
@@ -518,7 +586,7 @@ was reachable, which is usually A's, and spreads from there.
## 8. Protocol and node-side implementation
-### 8.1 MNP — additive, MINOR bump (2.1)
+### 8.1 MNP — additive, MINOR bump (3.1)
```
user_blob_store { kind, rev, blob_enc } client → node
@@ -537,8 +605,22 @@ tells the node nothing it does not already know from its own table.
Modelled on `KEYPAIR_BUNDLE_*` in every respect, including that the node stores
and returns an opaque byte string. `kind` is validated against a **pattern** —
-`playlists` or `playlist:<uuid>` — so the table does not become an arbitrary
-key/value store for whatever a client feels like writing.
+`playlists`, or `playlist:` and one to sixty-four of `[A-Za-z0-9_-]` — so the
+table does not become an arbitrary key/value store for whatever a client feels
+like writing.
+
+The character class is deliberately wider than a UUID, and that is a correction:
+the reserved id is the word `favorites` (§5.1), so a hex-only pattern refuses
+the one playlist every account has. It stays narrow enough to carry no structure
+of its own — no `/`, no `.`, no second `:` — so a kind can never be read as a
+path or as anything but one name in one namespace.
+
+**The bump is 3.1, not 2.1.** The proposal read the version off an older
+document; the wire version was already 3.0. `MNP_MIN_SUPPORTED` stays at 3.0,
+because nothing here is *required*: a 3.0 node answers "unknown message type"
+and the client stores its playlists on the next node it reaches, keeping its own
+copy meanwhile (§6.4). That is the difference from the 3.0 bump, where the
+requirement rather than the messages is what made it MAJOR.
Every reply carries `req_id` through the ordinary `_send` path. This is not
optional and does not need re-arguing: `CLAUDE.md` records at length what
@@ -622,6 +704,33 @@ onPlayQueue(tracks, startIndex, source, op) // op: 'replace' | 'next' | 'appen
move, the `<audio>` element is not touched, nothing reloads.
- `'append'` — push them at the end of `order`.
+### 9.2a The wrapper that eats the argument
+
+One more component sits between the menu and the shell, and it is where this
+broke.
+
+`group-page.js` and `search-page.js` each wrap `onPlayQueue` before handing it
+down — the first to attach the group and its transport, the second to close a
+video modal. Both wrappers were written as `(tracks, startIndex)` and forwarded
+two arguments, so **every "add to queue" in a group arrived at the player as a
+plain play and silently replaced the queue.**
+
+Nothing about that reads as wrong at either end. The call site passes four
+arguments and is correct; the wrapper takes two and is correct for what it was
+written for; the player receives `op: undefined` and defaults, correctly, to
+`replace`. It was found by a browser probe pressing the button, and by nothing
+else.
+
+The rule this leaves behind, and the reason the contract is asymmetric:
+
+- **view → wrapper** is `(tracks, startIndex, op)`. A view has no `source` to
+ give and must not invent an argument slot for one.
+- **wrapper → shell** is `(tracks, startIndex, source, op)`. `source` is the
+ wrapper's own contribution.
+
+`test_music_queue.py` reads both wrappers for the named `op` parameter, which is
+weak evidence — but it is the evidence that names the failure.
+
### 9.3 Collapse `tracks` / `order` / `pos` into one reducer
Three `useState`s updated from one event is where this ships a defect. `setOrder`
@@ -671,10 +780,15 @@ do for a long playlist and is wrong, because for an album those 5 tracks share
one connection, and for a cross-group playlist they may need **five different
node dials**, each up to 10 s, against a pool of 12.
-Rule: **prefetch only entries whose group already has a live connection in the
-pool**; let the rest warm when the queue reaches them and the dial happens
-anyway. An album is unaffected (one group, always live). A shuffled cross-group
-playlist stops trying to open half the pool to warm tracks it may never reach.
+Rule, as built: **prefetch only entries in the same group as the track
+playing**, and never one whose group is already marked down (§11.3). An album is
+unaffected — one group, always live — and a shuffled cross-group playlist stops
+trying to open half the pool to warm tracks it may never reach. The rest warm
+when the queue gets to them and the dial has to happen anyway.
+
+Deliberately the *playing* group rather than "anything the pool already holds":
+the pool's contents change under it, and a prefetch rule whose answer depends on
+what some other page happened to open is a rule nobody can reason about.
---
@@ -700,10 +814,16 @@ The codebase has no context menu and no dropdown outside the account menu in
album than the one it was opened on.
- flips its anchor when it would open past the viewport edge, which on a phone
is most of the time.
-- submenus: a flyout on a fine pointer; **on a coarse pointer, a sheet**. A
- two-level flyout (§10.3's "remove a track") has nowhere to go at 400 px, and
- building it as a nested flyout and then discovering that on a phone is the
- predictable version of this.
+- submenus **expand in place**, downward, at every width. This is the
+ correction: the proposal wanted a flyout with a sheet on a coarse pointer, and
+ the account menu's language list (`app.js`) already shows the simpler answer.
+ Expanding needs no flipping, no hover intent and no separate mobile design,
+ and the two-level case (§10.3's "remove a track") has nowhere to fly out *to*
+ at 400 px anyway.
+- a submenu may declare **`loadItems`** instead of `items`, resolved when it is
+ expanded. Exactly one needs it: a playlist's tracklist, read from IndexedDB.
+ Building every playlist's tracklist eagerly would mean reading a ten-thousand
+ track list to draw a menu nobody may open.
Videos, Photos and Files all want this component eventually. It is deliberately
generic and lives outside `music-*.js`.
@@ -750,47 +870,59 @@ A new `playlist` glyph in `icon.js` next to `music`. One button, one icon, no
label — the toolbar already wraps to three rows on a phone
(`style.css:2557`) and has no room for a word.
-The menu, in the requested order:
+The menu, as built:
```
Load a playlist ▸ <list> → replace the queue, play
-Save the current queue as… <name prompt> ← §10.4
-New playlist… <name prompt>
-Delete a playlist ▸ <list> → inline confirm
+New playlist… <a field>
+Delete a playlist ▸ <list> → confirm()
Remove a track from a playlist ▸ <list> ▸ <tracks>
────────────────────────────────
Sync now ← §7
```
-Two additions to what was asked, both stated so they can be refused: **"Save the
-current queue as…"** is placed at 2 because it is a creation verb and belongs
-next to the other one; **"Sync now"** is §7's explicit sweep, which has to live
-somewhere and this is the only playlist surface there is.
+The four verbs asked for, in the order asked for, plus **"Sync now"** — §7's
+explicit reconciliation, which has to live somewhere and this is the only
+playlist surface there is.
+
+**"Save the current queue as…" is not here**, against the proposal. The queue is
+the *player's* own state; putting the action in a menu owned by a different
+component would mean lifting that state out of the player to reach it. It lives
+in the queue panel alone (§10.4), which is where the current queue is a thing
+the reader can see.
Rules the list items carry:
- **Load** replaces (§9.1) and starts playing. It fetches the body first if
`body_rev` says the local copy is stale, which is the one place a playlist
action can block — show the spinner the page already has.
-- **New** takes a name only, as asked; tracks are attached afterwards. The name
- must be unique *for this user*, checked against the manifest, case-folded with
- the same `foldKey` the Music grouping already uses (`music-app.js`, exported)
- rather than a second normaliser — a copy keeps agreeing until one of them
- changes.
-- **Delete** never lists Favourites (§5.1). It confirms inline; not
- `window.confirm()`, which blocks the SPA's event loop and looks like a browser
- alert in an Electron shell.
-- **Remove a track** is the two-level one, and is a **sheet on mobile**
- (§10.1): pick the playlist, then its tracklist with a trash button per row.
- That sheet is also where reordering lands if O4 is ever taken up, which is
- another reason not to build it as a flyout.
+- **New** takes a name only, as asked, typed into **a field**: `window.prompt`
+ does not exist in the desktop client — it throws, which is how the Files
+ toolbar's New folder button came to do nothing at all, and
+ `test_no_prompt_in_the_spa.py` refuses a build that reintroduces it. The name
+ must be unique *for this user*, checked against the manifest and folded for
+ case and accents, so "Soirée" and "soiree" are not two playlists.
+- **Delete** never lists Favourites (§5.1) — the store refuses it anyway, and
+ offering an action that always fails is worse than not offering it. It asks
+ with **`window.confirm`**, which is the correction: `prompt()` throws in
+ Electron and is banned by a test, while `confirm()` is measured to work and is
+ used in twenty places in this SPA. A deletion is a tombstone and nothing in
+ the interface undoes it, so it is worth one question.
+- **Remove a track** is the two-level one, expanded in place: pick the playlist,
+ then its tracklist, one click per track. The second level is read from
+ IndexedDB when it is opened (§10.1's `loadItems`).
### 10.4 Saving the current queue
-Reachable from two places, deliberately: the **queue panel** (`QueuePanel`,
-`music-player.js:136`) gets a button in its top bar, because that panel is where
-"the current queue" is a thing the user can see; and the toolbar menu mirrors it
-for discoverability.
+One place, not two: the **queue panel** (`QueuePanel`) gets a button in its top
+bar, because that panel is where "the current queue" is a thing the reader can
+see. The proposal wanted the toolbar menu to mirror it; the queue is the
+player's own state, and a menu owned by a different component would have to have
+it lifted out of the player to reach — which is a real change to the player for
+a second way to press the same button.
+
+The modal itself is the shell's (`app.js`), because the player bar outlives
+every page and the account it belongs to is there.
It saves `order.map(i => tracks[i])` — **play order, not insertion order**. If
shuffle is on, that freezes the shuffle, which is what "save what I am listening
@@ -855,14 +987,23 @@ things:
`groupId` down for the session, advance past **every** queued track from that
group in one step, and reset the counter.
-If that empties the queue, stop **once**, with a stated count — "3 groups
-unreachable, 42 tracks skipped" — which is the same honest reporting
-`fetchAllIndexes` does, and is what the 5-bound was protecting in the first
-place. A group marked down is retried only on an explicit user action (pressing
-play or next), never automatically, so a 300-track playlist does not re-dial a
-dead node 300 times. `transport.waitForReconnect()` already covers the transient
+If that empties the queue, playback stops **once**, with the error already on
+screen — which is what the 5-bound was protecting in the first place. A group
+marked down stays down for the session, so a 300-track playlist does not re-dial
+a dead node 300 times; `transport.waitForReconnect()` still covers the transient
within-group case and is untouched.
+How the two are told apart: `fetchTrackBlob` tags the error it raises when no
+node answered for that group (`err.isGroupDown`), and the load path passes the
+group id to `advancePastFailure` only for those. A decode failure — including
+the one that surfaces later as an `error` event on the `<audio>` element rather
+than a rejected promise — reaches the same function with nothing and takes the
+bounded path, unchanged.
+
+`test_music_queue.py` queues **six** tracks from a dead group behind one live
+one, which is one more than the bound: without the split, playback lands on the
+last of the six instead of past them.
+
---
## 12. Security — per adversary
@@ -923,7 +1064,7 @@ later.
| O4 | Reordering a playlist, and conflicts better than LWW | Losing a reorder is survivable; losing an added track is not, and per-playlist `rev` already prevents that. The drag surface is §10.3's sheet when it comes |
| O5 | Export / import a playlist as a file | Trivial once §14.1 exists (it is `JSON.stringify` of one body) and worth doing, but it is not what makes playlists work across devices |
| O6 | Tombstone collection driven by an explicit per-node acknowledged `rev` | §6.3's rule is adequate at this scale; a real collector matters at thousands of deletions, which is not a real state |
-| O7 | A `/playlists` page and sidebar entry | **Dropped from this design, not deferred lightly.** The first draft proposed it; the requirement is a menu in Music's toolbar, and Music is already mounted in both places that matter (§10). A separate page would be a third surface showing the same list, and the first one to go stale |
+| O7 | A `/playlists` page and sidebar entry | **Decided against, and now built that way.** The requirement is a menu in Music's toolbar, and Music is already mounted in both places that matter (§10) — Search needed nothing beyond being passed `userId`. A separate page would be a third surface showing the same list, and the first one to go stale |
| O8 | Per-queue persistence — restoring the current queue after a reload | Different object with a different lifetime: the queue is ephemeral and per-device, and `localStorage` is its right home, not §3's blob. Worth doing; not this |
---
@@ -932,19 +1073,30 @@ later.
### 14.1 Modules
-| file | contents | why separate |
-|---|---|---|
-| `playlists.js` | manifest + body load / merge / save, tombstones, id-path repair, compress, seal, open, the §5.3 whitelist | pure data layer, **no UI** — the merge is the one part that can be properly tested and must not be entangled with a component |
-| `queue-ops.js` | the `{tracks, order, pos}` reducer (§9.3) | same reason, and it is the part that can regress the player |
-| `menu.js` | `<Menu>`, `useMenu()` — anchored menu, submenus, coarse-pointer sheets | generic; Videos, Photos and Files want it too |
-| `playlist-menu.js` | the toolbar button, its menu, and the create / delete / remove-track / save-queue modals | ~300 lines of modal that do not belong in `music-app.js` |
+**Five files, not four.** Three of them have **no imports at all** and are
+executed whole by their tests — which is the only real evidence this feature
+has, and is why the split is where it is.
+
+| file | imports | contents | executed by |
+|---|---|---|---|
+| `queue-ops.js` | none | the `{tracks, order, pos}` reducer (§9.3) | `test_queue_ops.py` |
+| `playlist-merge.js` | none | the shapes, the merge rules, tombstones, id/path repair, the §5.3 whitelist | `test_playlist_merge.py` |
+| `playlist-crypto.js` | none | compress → pad → seal, and the HKDF subkey | `test_playlist_crypto.py`, against node's own WebCrypto |
+| `playlists.js` | the three above, plus `hub-client.js` | IndexedDB, the transport, the mutations | Chrome, against a stubbed node (`test_playlist_store.py`) |
+| `menu.js`, `playlist-menu.js` | components | the shared pop-up menu, and the toolbar button with its modals | Chrome (`test_playlist_ui.py`) |
+
+`playlist-crypto.js` is the one the proposal did not separate. Folding seal and
+open into `playlists.js` would have put them behind IndexedDB and a transport,
+where node cannot reach them — and a crypto layer that cannot be executed is a
+crypto layer nobody has checked.
Edited: `music-app.js` (toolbar button, per-item menus, the `<button>` → `<div>`
row fix), `music-player.js` (the reducer, the `op` branch, the split failure
-counters, the queue panel's save button), `app.js` (`op` through
-`handlePlayQueue`, the `groupTransportRef` guard), `hub-client.js` (IDB v2),
-`keyderive.js` (the HKDF handle), `icon.js` (a `playlist` glyph), `style.css`.
-`search-page.js` needs **nothing** — it already mounts `MusicApp`.
+counters, the queue panel's save button, the prefetch gate), `app.js` (`op`
+through `handlePlayQueue`, the `groupTransportRef` guard, the save-queue modal),
+`group-page.js` and `search-page.js` (forwarding `op` — §9.2a — and passing
+`userId`), `hub-client.js` (IDB v2 and `openDB`), `keyderive.js` (the HKDF
+handle), `icon.js` (`playlist` and `playnext`), `style.css`, ten locales.
### 14.2 The IndexedDB detail that will otherwise be missed
@@ -954,9 +1106,21 @@ handling it in the existing `onupgradeneeded`, which currently creates one store
and would otherwise never run again. A store that is never created throws on
first access, at a point far from the version constant.
-One store holding the manifest and the bodies, keyed by `kind`, so §8.1's
-namespace is the IndexedDB key as well and nothing has to translate between two
-schemes.
+One store holding the manifest and the bodies, keyed by **`userId|kind`**. Two
+accounts on one browser is ordinary, and so is a sign-out that never runs, so
+the separation belongs in the key rather than in a cleanup path that has to
+happen.
+
+**The version and every store stay in `hub-client.js`, and `openDB` is
+exported.** The first version of `playlists.js` opened the database itself at
+version 2 while `hub-client.js` still opened it at 1 — two modules disagreeing
+about a version is a `VersionError` thrown at whichever of them happens to run
+second, and neither file would look wrong on its own.
+
+Records are stored **in clear** locally, exactly as the cached group indexes
+already are. What is sealed is the copy that leaves this browser; a second
+encryption layer over the local cache would protect against nothing the index
+cache is not already exposed to, and would need its key held in the same place.
### 14.3 Checklist, per `MESHBAY_DESIGN.md` §9.4
@@ -964,7 +1128,10 @@ schemes.
2. Nothing for caching: every file under `static/` feeds the `/a/<hash>/`
fingerprint.
3. `test_hook_ordering.py` (`STATIC_FILES`) and `test_transport_contracts.py`
- (`SPLIT_FILES`) — all four new files.
+ (`SPLIT_FILES`) — **`menu.js` and `playlist-menu.js` only.** Those lists walk
+ components for hook ordering and dependency arrays; the four hookless modules
+ do not belong in them, exactly as `source-merge.js` does not. The proposal
+ said "all four new files", which would have been noise.
4. i18n keys in all ten `static/locales/*.js`; `test_locales.py` holds them to
`en.js`'s key set.
5. `npm run sync-ui` in `meshbay-client`, confirmed reported — CI fails if the
@@ -974,51 +1141,58 @@ schemes.
---
-## 15. Acceptance before shipping
+## 15. What is verified, and what is not
+
+Baseline before any of this: **2558 passed, 1 failed, 12 errors** — the failure
+and the errors are the pre-existing browser ones in `test_sticky_header.py`
+(a `.admin-table` overflow at 360px in Group settings, and Firefox producing no
+measurement at all here). Every commit was checked against that baseline, at the
+level of the named failure rather than the count.
+
+### 15.1 Checked, by tests that execute the real code
+
+| | What | Where |
+|---|---|---|
+| 1 | The queue reducer: append to an empty queue, insert-next at the last position, insert-next while shuffled, **two appends in one tick**, removing what is playing, shuffle after an append | `test_queue_ops.py` — 20 cases against `queue-ops.js` itself |
+| 2 | Album playback is unchanged: auto-advance, shuffle on mid-album keeping the playing track, play-all replacing | `test_music_queue.py`, in Chrome, on the shipped `GroupPage` and player |
+| 3 | The merge, through the five divergences — two devices, a node offline for each in turn, delete against edit, a resurrect attempt, a `rev` tie — and **it fails with the tombstone rule removed** (checked: one test fails, alone) and **fails on a clock-based merge** (checked: seven fail) | `test_playlist_merge.py` — 22 cases against `playlist-merge.js` itself |
+| 4 | Sealing: compression worth 4.5× on realistic data, padding that makes 1, 2 and 10 tracks the same length, a random nonce, the AAD refusing one playlist's body served as another's or as another account's, a flipped byte, a future format byte | `test_playlist_crypto.py` — against node's own WebCrypto |
+| 5 | **Argon2id runs exactly once** per sign-in, and the AES handle is genuinely not derivable — which is why the HKDF handle must be a second import | `test_playlist_key.py` |
+| 6 | The node: a blob round-trips as bytes and **survives the process**; the plaintext is not in `bundles.db`; one account never sees another; every cap refuses with a stated reason and writes nothing; the quota subtracts what a write replaces | `test_user_blob_store.py`, `test_user_blob_mnp.py` — 49 cases |
+| 7 | The store: a stale node cannot lower the merged state; a deletion is not resurrected; an edit made elsewhere arrives with its body; a sync with nothing to do writes nothing; a deleted body is reclaimed; what leaves the browser is sealed | `test_playlist_store.py`, in Chrome against a stubbed node |
+| 8 | The menus: the toolbar's five verbs, naming a playlist in a field, Favourites offered first before it exists, adding an album from its cover, loading into the queue, the two-level tracklist, deleting after a confirm | `test_playlist_ui.py`, in Chrome |
+| 9 | An unreachable group is skipped **whole** — six dead tracks, one more than the bound | `test_music_queue.py` |
+
+### 15.2 Not checked, and what that costs
-**No-regression first**, because §9.2 changes the player's queue state and
-nothing else in this document can be judged until that is known to be clean:
+These need things a test suite here does not have. They are listed so nobody
+reads §15.1 as more than it is.
-1. Baseline counts **before any edit**, per `CLAUDE.md`: the hub suite has
- pre-existing browser failures, and a count taken afterwards proves nothing.
-2. Play an album, let it auto-advance across at least three tracks, with
- prefetch observed warming ahead. Then the same with shuffle on, toggled
- mid-album, and confirm the playing track does not restart.
-3. `tests/harness/queue_ops.mjs` runs the real reducer through §9.3's six
- scenarios, including **two appends in one tick**, and fails if the reducer is
- replaced by the three-`useState` version.
+1. **A round trip through a real node daemon.** The MNP handlers are driven
+ directly against a real `BundleStore`, and the store is checked across a
+ close and reopen — but not across a running daemon, a real DataChannel and a
+ real handshake. The likeliest thing to be wrong there is msgpack `bin`
+ framing for a payload of this size, which nothing here exercises.
+2. **Two browsers, one account, one node, converging** — and the same with the
+ node stopped between the two edits. The merge is proved; the *plumbing*
+ around it is proved against a stub that always behaves.
+3. **A real cross-group playlist playing**, which is where §11.3 and §9.5 meet
+ real latency rather than a stub that rejects instantly.
+4. **The 650 ms sign-in budget**, measured. Argon2 is proved to run once; the
+ wall-clock cost of the second `importKey` is assumed to be nil rather than
+ measured.
+5. **Firefox**, for any of the browser-driven work: its probe produces no
+ measurement in this environment, which is a pre-existing gap and not one this
+ feature closes.
-**Then the feature:**
+### 15.3 Still open
-4. `tests/harness/playlist_merge.mjs` runs the real `merge()` through five
- divergences — two devices, one node offline for each in turn, a delete on one
- side and an edit on the other, a resurrect attempt, a `rev` tie — and **fails
- with the tombstone rule removed**. Check that, or the test is decoration.
-5. A round trip through a real node: seal, `user_blob_store`, restart the daemon,
- `user_blob_fetch`, open. Confirms the blob survives the process, not just the
- test. Repeat for a body and for the manifest.
-6. Read `bundles.db` back and confirm no playlist plaintext is in it — the same
- check `test_chat_key_storage.py` already makes for epoch keys, for the same
- reason. A plaintext table beside it is the obvious thing to write and would
- collapse the whole claim silently.
-7. Two browsers, one account, one node: edit in each, converge, confirm both
- agree. Repeat with the node stopped between the two edits, and confirm
- neither edit is lost when it comes back.
-8. Open Music with **every** node offline and confirm every menu in §10 draws in
- full — playlist names, track titles, artists, durations, entries greyed —
- because that is the requirement §5.3 exists for and it cannot be unit-tested
- meaningfully.
-9. Build a playlist of ~2 000 tracks, confirm the sealed body is under the 1 MB
- cap, and **measure the bytes written when one track is starred** — it must be
- the Favourites body plus the manifest, not the collection. That number is
- what §4.3 is for.
-10. Confirm each cap refuses rather than truncates, and that the refusal reaches
- the client as a stated reason rather than a bare `error`.
-11. Confirm sign-in still runs Argon2id exactly **once** after §3.4's change
- (measure it; the budget is the 650 ms already recorded in draft-v5 §7.1).
-12. A playlist spanning three groups, one of them stopped: confirm it loads
- whole, plays, skips the dead group's tracks **in one step** rather than
- erroring on the sixth, and reports the count.
-13. Create a playlist from inside a group, then open Search → Music and confirm
- the same playlist is in the menu there (requirement 6) — on the same device
- immediately, on a second device after it has opened any group.
+- **O7 is now decided, not deferred**: there is no `/playlists` page, and the
+ Search page needed no change beyond passing `userId` — §11's requirement is
+ met by `MusicApp` being mounted in both places.
+- `deleteUserBlob` reclaims a tombstoned body, but **tombstones themselves are
+ never collected** (O6). At forty bytes each this is not a real problem yet,
+ and a collector needs the per-node acknowledged `rev` that O6 describes.
+- **Nothing calls `syncWith` on the Search page.** It has a transport per group
+ and no single one to ride; the group page's mount is what reconciles today. A
+ reader who only ever uses Search converges only when they open a group.