aboutsummaryrefslogtreecommitdiffstats
path: root/docs/playlists.md
blob: ecbbac480fb3d22d2e424b10faabfe2c46a3a456 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
# 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
> 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.
>
> Read `MESHBAY_DESIGN.md` §9.8 first — Music is built, and this adds nothing
> to its playback *path*. Read §9.11 second: the cross-group consolidation
> this feature needs already exists there, and most of the work is
> recognising that.
>
> **Scope, settled before writing this:** a playlist belongs to **one
> account** and is never shared with other group members. That answer is what
> keeps §6 small; see §13 for what changes if it is ever reversed.
>
> Follows the project convention: every claim names the adversary or the
> failure it holds against (§12).

---

## 0. What was asked, in one paragraph

Somewhere to keep a user's playlists, so that they survive a cache clear and
turn up on that person's other devices — and so that one playlist may hold
albums from **several different groups on several different nodes**, the way
the Search page already searches a consolidated view. With the constraint,
stated up front, that nodes go offline for an evening or for a month and that
this must not corrupt anything. And — added after the first draft, and now
half of this document — **a user interface**: the queue verbs (play, play
next, enqueue) that a music player is expected to have, a way to build a
playlist from what is on screen, a way to manage playlists from one button,
and none of it breaking the album auto-advance and prefetch that already
work.

## 0.1 What changed from the first draft, and why

The first draft answered the storage question and stopped. Three things in it
were wrong or missing, and they are corrected here rather than patched:

| | First draft | Now | §  |
|---|---|---|---|
| Size | one blob for everything, capped at 256 KB | **manifest + one blob per playlist**, compressed, stored as a BLOB; caps per unit | §4, §5 |
| The stored track reference | "takes it unchanged" into `onPlayQueue` | **it does not** — `size` and `name` are missing and both are required to fetch a track at all | §5.3 |
| UI | one route `/playlists` and a sidebar entry | **dropped.** A menu in Music's sticky toolbar, which Search → Music inherits for free | §9, §13 O7 |

The 256 KB figure contradicted its own worked example: the draft estimated
1 000 tracks at ~200 bytes as "~200 KB", against a cap applied to `blob_enc`,
which was a **base64 TEXT** column — so its own example was already ~267 KB on
the wire, over a cap it declared generous. §4 redoes the arithmetic with the
fields that actually have to be there.

---

## 1. What this design does not reopen

- **Views over the index, never a catalogue** (`MESHBAY_DESIGN.md` §6.5, §9.1).
  A playlist is a list of *references*; it creates no second identity for a
  file and no server-side database of content.
- **Nothing about content reaches the hub** (H7). §3.1.
- **No new streaming path.** `MESHBAY_DESIGN.md` §9.8 stands untouched: a track
  is fetched through `pipelinedDownload` and handed to `<audio>`. Playlists add
  no node-side playback code whatsoever, and no change to transcode, chunking,
  or the GEK.
- **Node-side derived data lives in the node's own `data_dir`**, never beside a
  shared file (`MESHBAY_DESIGN.md` §6.5). The blobs in §3.3 go in `bundles.db`.
- **Identity keys are per node** (`MESHBAY_DESIGN.md` §3.2). §3.4 exists
  entirely because of this, and getting it wrong is the fastest way to ship a
  playlist that cannot be read from the second device it was invented for.
- **The player bar stays where it is.** It is already at shell level in
  `app.js`, already resolves a connection per track from `entry.groupId`, and
  already crosses groups within one queue. The cross-group requirement reads
  like the hard part and is in fact already built.

---

## 2. Four 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 |

The request reads as a storage problem. Storage is the easy quarter of it.

---

## 3. Where the state lives

### 3.1 Not the hub, and the rule is narrower than "nothing on the hub"

The hub *does* hold small per-account state: `user_preferences`
(`db/models.py:302`), behind an allowlist of keys that already includes
`music_keep_screen_on` and `default_tab:<group_id>`. So the operative rule is
not "the hub stores nothing about a user". It is:

> **No content metadata on the hub.**

A playlist is, literally, a list of blake3 hashes of private-group files —
plus, if it is to render while nodes are offline (§5.3), their titles and
artists. That is the exact object H7 removed from the hub, and it is what
`CLAUDE.md` already pins one size smaller for the video resume position:
*"nothing new learns what you watch"*.

**An encrypted blob on the hub** is technically trivial and is still refused.
The precedent is exact and recent: keypair bundles *were* hub-stored, and T3
reduction phases 1 and 2 deliberately moved them onto nodes. Putting a
different per-account blob back would undo a migration that has already been
paid for. The residual cost is real even sealed — the hub learns the
cardinality (how many playlists), the size (roughly how many tracks) and the
timing of every write (when this account listens and edits), and it retains
ciphertext indefinitely under a passphrase-derived key, which is C4's own
argument.

If a future need makes hub involvement genuinely attractive, the only
acceptable shape is a **version vector** — `playlists_rev: 7` in
`user_preferences`, telling a client that some node is stale — never the
payload. Even that is probably not worth its own migration; §7 gets the same
result with no hub change at all.

### 3.2 Not node-to-node

Refused, and not on cost grounds. Nodes do not know each other, share no
authenticated channel, and `replication.py` is legacy public-content code that
has nothing to do with this. Beyond the protocol that would have to be
invented, it leaks the thing this architecture is most careful about: node A
would learn that this account also uses node B — that two unrelated operators
host the same person. Per-node identity exists precisely so that this
correlation is unavailable (`MESHBAY_DESIGN.md` §3.2).

### 3.3 The node, as opaque per-account blobs — a shape already built

The node already stores an encrypted per-account object it cannot read: the
keypair bundle (`bundle_store.py`, table `keypair_bundles`, MNP ops
`KEYPAIR_BUNDLE_STORE` / `_FETCH` / `_DELETE`). Playlists are the same shape
with a different payload, so this introduces **no new trust boundary**: the
node is not being asked to hold anything of a kind it does not already hold
for this same account.

A new table in the same `bundles.db`:

```sql
CREATE TABLE IF NOT EXISTS user_blobs (
    user_id     TEXT NOT NULL,
    kind        TEXT NOT NULL,          -- "playlists" (the manifest), or
                                        -- "playlist:<id>" (one playlist's tracks)
    rev         INTEGER NOT NULL,
    blob_enc    BLOB NOT NULL,          -- NOT TEXT — see below
    blob_enc_recovery BLOB,             -- reserved, §13 O2
    stored_at   TEXT NOT NULL DEFAULT (datetime('now')),
    PRIMARY KEY (user_id, kind)
);
```

Two deliberate differences from `keypair_bundles`, each of which is a mistake
avoided rather than a preference:

- **`BLOB`, not `TEXT`.** `keypair_bundles.bundle_enc` is base64 TEXT because
  it holds a few hundred bytes and nobody will ever notice the 33 % tax. This
  table holds hundreds of KB, where a third is not a rounding error. The
  transport already carries msgpack `bin` (`transport.js:3571` handles it
  explicitly), and `aiosqlite` binds `bytes` natively, so base64 buys nothing
  here and costs a third of every write.
- **`kind` is a namespace, not an enum.** `"playlists"` is the manifest;
  `"playlist:<uuid>"` is one playlist's tracks. The allowlist in §8.2 is a
  *pattern*, not a literal set, and that is the whole reason the split in §4
  costs no second table.

`blob_enc_recovery` is declared now and left NULL. `bundle_store.py` already
carries `_migrate_keypair_recovery` — a `PRAGMA table_info` dance — for exactly
this column added to exactly this kind of table one release late. `CREATE TABLE
IF NOT EXISTS` never adds a column, which is the node-local twin of the
`create_all()` lesson in `CLAUDE.md`. Declaring the slot costs nothing today
and removes the migration entirely.

### 3.4 The key — the one thing that must not be got wrong

Identity keys are **per node**. A blob encrypted under one is unreadable from
every other node, which is the precise opposite of the requirement.

The only secret this account holds *everywhere* is the **bundle key**:
Argon2id over the passphrase, rederived at every sign-in on every device
(`keyderive.js:145`), and already the key that opens the per-node identity
bundles. So:

```
playlist_key = HKDF-SHA256(bundle_key_v2, info = "meshbay:playlists:v1")
```

Three consequences, each of which is a line of code somewhere:

- **`deriveEncryptionKey` must return an HKDF handle as well as the AES-GCM
  one.** Today (`keyderive.js:154`) it imports the 32 Argon2 bytes
  non-extractably as `AES-GCM` with `['encrypt','decrypt']`, from which nothing
  can be derived. Import the *same* `out.hash` a second time as `HKDF` with
  `['deriveKey']`. One Argon2 run, two handles, no extra cost on the 650 ms
  sign-in path. Do **not** add a second Argon2 call, and do **not** encrypt
  playlists directly under the bundle key with a different AAD: `groupbox.py`
  already sets the convention ("*Never reuse `chunk_key_aes` with a pseudo-file
  for this*") and purpose-separated subkeys are what it looks like here.
- **v2 only.** `bundleKey` is `{v2, v1}` so that a bundle written before the KDF
  changed can still be opened. Playlists are new; there is no legacy blob and
  therefore no v1 branch. One less thing that can silently take the wrong fork.
- **The nonce is 96 random bits, never a counter.** Two devices of one account
  derive the *same* playlist key — that is the whole point — so a counter would
  repeat. This is the same reasoning already recorded for chat subkeys in
  `CLAUDE.md`'s module table, and it is safe for the same reason.

A passphrase change re-derives the key and every blob must be re-encrypted on
the next write, exactly as the keypair bundle already is ("*re-encrypted on the
next backup*"). With the split of §4 that is now N+1 writes rather than one —
do them lazily, on each playlist's next write, not in a loop at sign-in.

**AAD** = `"user_blob|<kind>|<user_id>"`, mirroring `groupbox.associated_data`.
`<kind>` is the full namespaced kind, so a body sealed for playlist A cannot be
served in place of playlist B's — which a bare `"playlists"` AAD would have
allowed, and which is a real substitution now that there is more than one row.
It does not, and cannot, prevent rollback — §6.4.

---

## 4. Size — the first draft's arithmetic, redone

### 4.1 What an entry actually costs

§5.3 settles which fields have to be stored. With short msgpack keys:

| field | bytes |
|---|---|
| `id` (blake3 hex) | 65 |
| `g` (group UUID, 36 chars) | 37 |
| `n` (filename) | ~31 |
| `p` (path) | ~56 |
| `t` (display title) | ~26 |
| `a` (artist) | ~16 |
| `b` (album) | ~23 |
| `s` (size), `d` (duration), `tn`, `hv` | ~10 |
| 11 keys + map header | ~34 |
| **total** | **~300 B** |

The first draft said 200 B, from a field list that could not play a track.

### 4.2 Three realistic accounts

| | entries | plaintext | base64 (draft) | deflate + BLOB (this draft) |
|---|---|---|---|---|
| draft's own example | 1 000 | 300 KB | **400 KB** — over its own 256 KB cap | ~100 KB |
| ordinary user: Favourites 2 000 + 15 lists | ~4 250 | 1.3 MB | 1.7 MB | ~430 KB |
| heavy user | 20 000 | 6 MB | 8 MB | ~2 MB |

So: **the doubt was correct, and by roughly an order of magnitude.** 256 KB is
not enough for one realistic Favourites list, let alone a collection.

### 4.3 Four fixes, in order of how much they matter

**1. Split the storage per playlist (§3.3's `kind` namespace).** This is the
one that matters, and not mainly for the cap. Under one blob, starring a single
track rewrites and re-uploads *the whole collection* — 1.7 MB, to every node
reached, for one star. Twenty stars in an evening is 34 MB over WebRTC per node.
Split, the same star rewrites Favourites (~200 KB) plus a ~3 KB manifest. The
merge unit was already one playlist (§6.1); this makes the *storage* unit agree
with it, which is also why the merge gets simpler rather than harder.

It has a second payoff, bigger than it looks: the **manifest alone is enough to
draw every menu in §9**. "Add to playlist ▸" lists names, and names are in the
manifest — a few KB, always in IndexedDB, rendered instantly with every node
offline and no body fetched. Under one blob that menu waits on megabytes.

**2. Compress before sealing.** `CompressionStream('deflate-raw')` — native in
Chromium, Firefox 113+, Safari 16.4+, and therefore in the Electron build; no
dependency, and the project has no compression library today (`zipstream.js`
deliberately stores rather than deflates, for a different reason). The shape is
about as compressible as data gets: the 11 keys repeat every entry, `group_id`
repeats across the whole list, and path prefixes repeat per album. 3× is
conservative for this; 4× is typical.

  **Order is compress → pad → seal, never seal → compress.** Ciphertext does not
  compress, and the padding must be inside the AEAD.

**3. `BLOB` not `TEXT`** (§3.3) — a third of every write, free.

**4. Caps that refuse, never truncate.** A truncating cap silently loses tracks,
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 |
| 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 |

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
someone else's disk. Noted in passing, and out of scope here:
`_do_keypair_bundle_store` (`webrtc_server.py:1113`) has **no cap at all**
today. Worth its own line somewhere.

**Padding.** Pad the compressed plaintext up to the next 4 KB before sealing.
The length otherwise tells the operator roughly how many tracks this account
has collected, and — once compression is in — roughly how repetitive its paths
are. Cheap, and it is the only metadata this design leaks to a node that the
node cannot already see. See §12 for the chosen-plaintext angle and why it is
named and then dismissed.

---

## 5. The data model

### 5.1 The manifest — `kind = "playlists"`

Small, always resident, and the only thing every menu needs:

```
{
  "v": 1,
  "playlists": {
    "<playlist_id>": {
      "name":       "Evening",
      "rev":        7,               -- of the manifest entry
      "body_rev":   41,              -- of the body blob, §5.2
      "device":     "<device_pk_ed25519, first 16 hex>",
      "updated_at": 1757000000,      -- display only, never a merge input
      "deleted":    false,
      "count":      312              -- display only; the body is authoritative
    },
    ...
  }
}
```

`playlist_id` is a client-generated UUID, with one reserved value:
`"favorites"`. **Favourites is a playlist**, not a second mechanism — deciding
that now is what stops a parallel store being built next to this one in three
months. It is *implicit*: never created by the user, materialised on first use,
always first in every picker (as asked), and **it cannot be deleted** (§9.3).
Emptying it is fine. Refusing the delete outright is what keeps §6.3's
tombstone rule free of an exception, and a tombstone rule with an exception is
how §6.3's defect ships.

### 5.2 A body — `kind = "playlist:<id>"`

```
{ "v": 1, "id": "<playlist_id>", "rev": 41, "device": "...",
  "tracks": [ <entry>, ... ] }
```

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
`user_blob_delete`d on each node as it is reached; the tombstone in the
manifest is what survives, not the body.

### 5.3 A track reference — and the field the first draft omitted

```
{ "id": <blake3 hex>, "g": <group_id>, "hv": 1|2,
  "n": "03 - A Track.flac",          -- filename
  "s": 41238711,                     -- bytes
  "p": "Some Artist/An Album",       -- directory path, group-relative
  "t": "A Track", "a": "Some Artist", "b": "An Album",
  "d": 214, "tn": 3 }
```

Each field prevents a specific failure:

- **`g`** — the player resolves its connection per track from `entry.groupId`
  (`music-player.js:292`). Without it there is nothing to dial, and a hash alone
  has no meaning outside a group.
- **`s` and `n` — the correction.** The first draft's entry had neither, and
  claimed the result was droppable into `onPlayQueue` unchanged. It is not.
  `fetchTrackBlob` (`music-player.js:289`) computes
  `Math.ceil(entry.size / CHUNK_SIZE)` to know how many chunks to ask for, calls
  `guessMime(entry.name)` for the blob type, and tests `entry.name` against
  `NEEDS_TRANSCODE_RE` to decide whether a `.wma`/`.mpc` needs the node-side
  conversion first. **Without `s` a playlist entry cannot be downloaded at
  all**, and without `n` it is downloaded and then handed to `<audio>` with the
  wrong MIME, or a WMA is handed over raw and fails to decode in every browser.
  The symptom is "playlists don't play", found at integration time and nowhere
  earlier.
- **`t` / `a` / `b` / `d` / `tn`, denormalised** — this is not redundancy, it is
  the core of the design. With them a playlist renders **completely** with every
  node offline, unplayable entries greyed, in the same spirit as the Search page
  reporting its `unreachable` list rather than failing. Without them, an offline
  playlist is a column of hex strings. A display problem, solved by copying four
  small strings.
- **`hv`** — the index already has two hashing schemes (`protocol.py:297`:
  1 = whole file, 2 = 45 MB sample). A re-hash would orphan every entry in every
  playlist, silently and all at once.
- **`p`** — content addressing survives a move; a path survives a re-encode.
  Keeping both means either can repair the other: on a sight of the live index,
  an entry whose `id` is absent but whose `p`+`n` match has its id rewritten in
  place (and vice versa), once, on the client.

**Rehydration, and the whitelist that is not optional.** A stored entry is
mapped to the player's entry shape (`id, name, path, size, duration,
display_title, artist, album, track_no, hash_version, groupId`) on load, and
the reverse map on save is a **strict field whitelist**. The entries the UI
hands over carry live junk: `_tRef` and `_gRef` (a transport and a CryptoKey,
attached by the Search page's merge), `_origPath`, and whatever else a future
view hangs on them. msgpack-encoding a transport object throws at best; storing
a stale one and reading it back a week later is worse, because it looks like a
connection and is not. Whitelist on the way in, in `playlists.js`, once.

**Availability bonus, close to free.** `source-merge.js` exists because the same
content appears in more than one group. At play time, if the entry's own `g` has
no reachable node but the same `id` appears in another cached index whose node
is up, play it from there. That is a global playlist that heals itself when one
operator's machine is off, reusing the fold-on-content-hash logic already
written for Search. Default on; see §12 for the one thing it changes.

---

## 6. Merge — the part that has to be right

The stated fear is correct **for a single blob under last-writer-wins**: node A
is off while an edit is made, node B is off while the next one is, and one edit
disappears with nothing to show for it. Four rules remove it.

### 6.1 The unit is a playlist, not the collection

The manifest is a map keyed by `playlist_id` and merges per key; bodies are
separate rows and merge one against one. Two playlists edited on two devices
never collide, which is the overwhelmingly common case for one person with two
or three devices. §4.3's split means this is now true of the *storage* as well
as the merge, so two devices editing two playlists do not even write the same
row.

### 6.2 `rev`, never the wall clock

Each manifest entry and each body carries a monotonic `rev` and the `device`
that last wrote it. Merge takes the higher `rev`; a tie is broken by the
lexicographically smaller `device`, so every device reaches the same answer
without talking to any other. `updated_at` is carried for display and is
**never** read by the merge — clocks across devices are not trustworthy, and a
clock-based merge fails roughly one time in twenty, which is the frequency at
which this codebase's history says a defect ships.

### 6.3 A deletion is a tombstone, never an absence

`deleted: true` in the manifest, kept. Absence must mean "this copy is older
than the one that created it". Otherwise a node rehomed after three weeks
**resurrects every deleted playlist** — this is the single most likely defect in
the whole design, it looks like a sync working correctly right up until it
doesn't, and it deserves its own named test. Tombstones are collected only when
every known node reports a `rev` at or above the deleting one, which for a
single-node account is immediate and for a multi-node one is eventual; a
tombstone is ~40 bytes, so there is no hurry.

A body arriving for a playlist the manifest tombstones is **dropped, not
resurrected** — the manifest is the authority on existence. That ordering is
the whole reason `count` lives in the manifest rather than being derived from
the body.

### 6.4 Rollback, and why local-first answers it

AEAD authenticates a blob; it does not stop a node handing back an older one it
still has (or a fresh one it never received). The defence is that **the client
is the authority**: the merged state lives in the client's own IndexedDB, and
merge takes the maximum `rev` across *local plus every node answering*. A stale
or lying node can only lose the tie. It can never lower the merged state,
because the local copy is one of the inputs.

This is what makes an offline node a non-event rather than a hazard. Nodes are
backups and a transport. They are not the source of truth, and no node being
reachable at all still leaves every playlist correct and, thanks to §5.3, fully
legible.

### 6.5 What is genuinely lost, stated plainly

Device 1 makes an edit, reaches no node, and is then lost or cleared: that edit
is gone. This is the exposure of any offline-first application, it is not
fixable without a durable always-reachable writer (which is §3.1, and refused),
and it is still enormously better than today, where the same edit is lost on a
cache clear regardless of what was online.

---

## 7. Sync — and the point is that it adds no dialing

A sweep of every group's node costs 10 s per unreachable one; the Search page
does it deliberately, batched three at a time, because the user asked it to.
Playlists must not do that at sign-in.

**Sync rides on connections that were happening anyway.** Whenever a transport
to any node is open for any other reason — opening a group, the Search page's
sweep, the music pool dialing to play a track — the client piggybacks a
`user_blob_fetch` for the **manifest**, merges, and sends `user_blob_store` back
if that node's copy is behind. `ConnectionPool` (`search-page.js:82`) already
holds up to `MAX_POOL_SIZE` (12) live connections and hands them out by group,
so this is a hook, not a new mechanism.

**Bodies are fetched lazily**, and this is what the split buys at sync time as
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:

- **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.

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
the user is looking at is right the whole time. It is also the honest answer to
"I made this playlist inside group A" (§11): the write lands on whatever node
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)

```
user_blob_store   { kind, rev, blob_enc }        client → node
user_blob_fetch   { kind }                       client → node
user_blob_list    { }                            client → node
user_blob_delete  { kind }                       client → node
user_blob_resp    { kind, rev, blob_enc|null }   node   → client
user_blob_list_resp { blobs: [{kind, rev}] }     node   → client
```

`user_blob_list` is new relative to the first draft and is what the split
requires: a client that has lost its local state (a cache clear, a new device)
must be able to discover which bodies exist without guessing UUIDs. It returns
`kind` and `rev` only — never a payload — so it costs one small message and
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.

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
arrival-order matching costs, and the victim is never the request that was
answered wrongly.

### 8.2 Node side

| Piece | Where | What |
|---|---|---|
| Storage | `meshbay_node/bundle_store.py` | `user_blobs` table (§3.3); `store_user_blob` / `fetch_user_blob` / `list_user_blobs` / `delete_user_blob`, same shape as `store_keypair` / `fetch_keypair` |
| Handlers | `transport/webrtc_server.py` | `_do_user_blob_store` / `_fetch` / `_list` / `_delete`; `self._user_id` from the authenticated session (NS6), **never** from the message |
| Caps | same | §4.3's four limits. Refuse with a stated reason; refuse a `kind` that does not match the pattern |
| Audit | same | `user_blob_store` / `_fetch` / `_delete` events, as `keypair_bundle_store` already logs |

`user_id` comes from the session, exactly as `_do_keypair_bundle_store` takes
it — a `user_id` in the message body would let any member read or overwrite any
other member's blob, which is the C5 shape one size down.

**No hub change. No change to indexing, streaming, transcoding, or the GEK.**

### 8.3 What the node can and cannot do with it

It can delete it, lose it with its disk, or serve a stale copy (§6.4 covers the
last). Durability is "the local copy, plus N node copies", with no guarantee
from any single one — which for an account on one node means the local copy
matters. Worth one line in the UI, not a warning dialog.

---

## 9. The queue — the change the UI actually needs

This section answers the question asked directly, because the answer is yes and
the reason is worth stating precisely.

### 9.1 Yes: "load a playlist" replaces the current queue

There is already a current list, it is what shuffle operates over, and loading a
playlist into it is the right shape. Concretely, in `music-player.js`:

```js
const [tracks, setTracks] = useState([]);   // the queue, in insertion order
const [order,  setOrder]  = useState([]);   // indices into tracks — the play order
const [pos,    setPos]    = useState(0);    // index into order
```

`order` is the indirection that makes shuffle work without disturbing `tracks`
(`shuffledOrder`, `toggleShuffle` at `music-player.js:444`), `pos` walks it, and
`prefetchNext(fromPos)` reads `tracks[order[fromPos + i]]`. An album handed over
by `onPlayQueue(album.tracks, i)` and a playlist handed over the same way are
**indistinguishable** below that call. So:

> **Load = `onPlayQueue(playlistTracks, 0, null, 'replace')`.** Confirmed. The
> existing queue is discarded, shuffle applies to the new one on the next
> render, prefetch warms from the new index 0, and auto-advance runs exactly as
> it does for an album — because it *is* the same code path, untouched.

### 9.2 The real work: the queue can only be replaced today

`onPlayQueue` flows to `app.js:738`:

```js
setMusicQueue({ tracks, startIndex, nonce: Date.now() });
```

and the player's effect keyed on `[queue]` (`music-player.js:344`) resets
`tracks`, `order`, `pos`, the error and the failure counter — wholesale, every
time. **There is no way to add to the queue without restarting it**, and "play
next" and "enqueue" are exactly that. This is problem D, and it is the only part
of the UI request that is not a new component over existing plumbing.

**The shape:** one more field, defaulted, so no existing call site changes.

```js
onPlayQueue(tracks, startIndex, source, op)   // op: 'replace' | 'next' | 'append'
```

- `'replace'` (the default, and what every call site today means) — today's path,
  behaviour for behaviour.
- `'next'` — splice the new indices into `order` at `pos + 1`. `pos` does not
  move, the `<audio>` element is not touched, nothing reloads.
- `'append'` — push them at the end of `order`.

### 9.3 Collapse `tracks` / `order` / `pos` into one reducer

Three `useState`s updated from one event is where this ships a defect. `setOrder`
needs the length `setTracks` is about to produce, and cannot see it; read from
the stale closure instead and two enqueue clicks in the same tick produce indices
that both point past the end — a queue with holes, from a double click.

So `queue-ops.js`: a pure reducer over `{ tracks, order, pos }` with actions
`replace / insertNext / append / removeAt / skipTo / reshuffle`. Two reasons,
and the second is the one that counts:

1. It is the only way the append is correct under batching.
2. **It can be executed by a test.** Source-reading tests are weak evidence and
   are most of what this repo can do for the SPA — but a pure function over a
   state object runs, the way `tests/harness/mse_harness.mjs` lifts the real
   player functions and runs them. `tests/harness/queue_ops.mjs` drives: append
   to an empty queue, insert-next at the last position, insert-next while
   shuffled, two appends in one tick, remove the currently playing track,
   toggle shuffle after an append. Model the environment, never the code under
   test.

The regression guard is that `replace` must produce exactly today's
`{ tracks, order, pos }` for the same inputs — it is a mechanical lift of the
existing effect body, and the harness pins it.

### 9.4 Three details that are cheap now and expensive later

- **Shuffle and append.** Appended tracks go at the end of `order`, in the order
  given, *not* reshuffled into the middle. That is what every player does and
  what "add to queue" means. `toggleShuffle` rebuilds `order` from
  `tracks.length` and so stays correct after any number of appends, for free.
- **`prefetchNext(pos)` must be called after an `insertNext`**, or the track the
  user just asked to play next is the one track in the queue that is cold. One
  line, and the only visible regression an enqueue can introduce. Nothing needs
  evicting: `blobCacheRef` is keyed by `entry.id`, so a blob already fetched for
  a track that has moved index is still keyed correctly.
- **`groupTransportRef` must only be set on `'replace'`** (`app.js:728`). It is
  a single-slot fast path for the group currently open; repointing it because
  the user enqueued one track from another group silently drops the playing
  group back to the pool. Not a bug — the pool works — but a pointless one.

### 9.5 Prefetch across groups — the one new consideration

`prefetchDepth()` returns 5 on Wi-Fi, and `MAX_CACHED_BLOBS` is 6 to match.
Unchanged, and **must** stay unchanged: raising either is the obvious thing to
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.

---

## 10. The user interface

Nothing here is a new page. Requirement 6 — that all of this work inside a group
*and* on Search → Music — is satisfied by putting it in `music-app.js`, which
`group-page.js` and `search-page.js` (with `groupId="search"`,
`search-page.js:784`) already both mount. Written once, appears in both.

### 10.1 A shared menu component, because there is none

The codebase has no context menu and no dropdown outside the account menu in
`app.js:99`. `menu.js` provides `<Menu>` and `useMenu()`:

- opens on **right-click** (`onContextMenu`, `preventDefault()`) and on a **dots
  button** — `icon.js` already has `dots` (`icon.js:54`). Both affordances, both
  platforms: the button is visible on hover on a fine pointer and always visible
  under `@media (pointer: coarse)`.
- closes on Escape, on an outside click, and **on scroll**. The last one is not
  optional here: Music's toolbar is a sticky band and the grid scrolls beneath
  it, so a menu that survives a scroll is a menu now anchored to a different
  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.

Videos, Photos and Files all want this component eventually. It is deliberately
generic and lives outside `music-*.js`.

### 10.2 The per-item menu — cover art and track rows

On an album card (`AlbumCard`, `music-app.js:253`), on a detail-modal track row,
and on a flat-list row (`FlatTrackRow`, `music-app.js:370`):

```
Play                     → onPlayQueue(items, 0, source, 'replace')
Play next                → 'next'
Add to queue             → 'append'
────────────────────────
Add to playlist        ▸   Favourites
                           <other playlists, by name>
                           ────────────
                           New playlist…
```

`items` is the album's tracks in track order, or the single track. The submenu
is drawn from the **manifest** (§4.3): instant, offline, no body fetched.
Favourites is always first, as asked, and is materialised on first use so it is
there on a fresh account with nothing created. "New playlist…" at the bottom
costs one item and covers the commonest way a playlist comes to exist — wanting
to put something in one.

**One structural blocker, found in the existing markup.** `music-track-row` is a
`<button>` (`music-app.js:312`, `:373`, `music-player.js:167`) and `music-card`
is a `<div onClick>`. A dots `<button>` cannot nest inside a `<button>` — it is
invalid HTML and browsers reparent it, which breaks the row. The row must become
a `<div class="music-track-row">` holding a title `<button>` (the play action,
keeping today's keyboard behaviour) and a trailing dots `<button>`. Mechanical,
touches three call sites, and is the kind of thing that turns into "why does the
menu open behind the row" if it is discovered during implementation instead of
here.

### 10.3 The single toolbar button

One `tb-btn` in `.video-toolbar` in `music-app.js` — the sticky band
(`style.css` "Sticky chrome": `.sticky-chrome > .video-toolbar` pins under
`--nav-h + --chrome-h`), so it is reachable at any scroll position, as asked.
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:

```
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
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.

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.

### 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.

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
to" means. Entries pass through §5.3's whitelist on the way out.

### 10.5 i18n

Every string through `t()` and into all ten `static/locales/*.js`;
`test_locales.py` holds them to `en.js`'s key set. Favourites' display name is
`t('playlists.favorites')` — the reserved id `"favorites"` is what is stored,
never the localised name, or an account that switches language grows a second
favourites list.

---

## 11. Unavailability — three different things, and the request conflates two

| state | how it is known | already handled? |
|---|---|---|
| **Root unavailable** — a disk not mounted, group up and saying so | `nodeRoots[].available`, filtered into `availableEntries` (`group-page.js:716`) | yes — those entries never reach the UI |
| **Group unreachable** — no node answers | only by dialing | partly — see below |
| **File gone** — indexed, then deleted or moved | only at fetch | yes — as a fetch failure |

### 11.1 Adding is never blocked on reachability

The request asks that a track from an unreachable group not be added. Applied to
**playlists** that is the wrong rule and would make them lossy in exactly the
case they exist for: a playlist is durable, and a group that is off tonight is
back tomorrow. Refusing the add loses the user's intent permanently to recover
from a condition that lasts an evening. Worse, "is this group reachable" is only
answerable by dialing, and dialing to add is precisely the cost §7 refuses.

So: **adding to a playlist never checks reachability, and never dials.** The
entry came from a rendered list, which came from an index this client holds.

### 11.2 Adding to the queue does not dial either — playback skips

Same reasoning, shorter: `play` / `play next` / `append` add what was on screen.
Loading a playlist loads all of it, greying entries whose group is in the known-
down set (the same set `search-page.js:535`'s `isDown` already keeps) without
removing them — a queue whose contents depend on network weather is worse than
one with a few dimmed rows.

The skipping then happens where it can actually be known, at play time, and
**most of it is already built**: `advancePastFailure` (`music-player.js:215`)
was written for exactly this and is why a corrupt file between two good ones
does not stall an album.

### 11.3 The one change: separate a bad file from a down group

`MAX_CONSECUTIVE_FAILURES = 5` is sized for a corrupt file among good ones. A
playlist whose next six tracks all come from one offline group hits the bound
and stops, with an error, and the user reads that as "the playlist is broken".
That is a regression created by playlists, in code that is correct today.

Split the two failures, because they are different properties of different
things:

- **a decode / playback failure** is a property of *that file* — keep today's
  bounded counter exactly as it is;
- **a connect / transport failure** is a property of *that group* — mark the
  `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
within-group case and is untouched.

---

## 12. Security — per adversary

| Claim | Passive hub | Active hub | Malicious node operator | Another member |
|---|---|---|---|---|
| Playlist contents (which tracks, which groups) | ✅ never transmitted to the hub | ✅ never transmitted to the hub | sealed under a key derived from the passphrase; the node holds ciphertext only | ✅ never served to anyone but the authenticated owner (`user_id` from the session) |
| How many playlists, and their relative sizes | ✅ | ✅ | **visible, and more so than in the first draft** — one row per playlist, each with its own `stored_at` and padded length | ✅ |
| Editing timing, per playlist | ✅ | ✅ | visible for writes reaching *that* node | ✅ |
| Integrity of the merged state | — | — | can serve stale or nothing; cannot lower the merged `rev` (§6.4) | — |
| Deleting a playlist | — | — | can delete its own copy; other nodes and the local copy survive it | — |
| Which tracks are actually played | — | — | already visible — the node serves the bytes | — |

**What the split costs, stated rather than buried.** One blob leaked one length
and one timestamp. N+1 blobs leak N+1 of each: an operator can see that this
account has nine playlists, that one of them is much larger than the rest, and
which single one was edited at 23:40. That is more than before. It is accepted
because the alternative is rewriting and re-uploading the entire collection on
every star (§4.3), which leaks the same timing anyway while costing an order of
magnitude more bandwidth — and because playlist *names* and *contents* remain
sealed throughout.

**Compression before encryption, named and dismissed.** Compressing then sealing
makes the ciphertext length a function of the plaintext's redundancy, which is
the CRIME/BREACH shape. It does not apply here in the form that matters: there
is no adversary-chosen input mixed with a secret in one compression context per
request. The residual is a one-bit-per-user-action oracle — an operator who gets
the user to add a file *they named* to a playlist, and watches the length delta,
learns whether a string they chose already appeared. The 4 KB padding (§4.3)
means that oracle needs thousands of coordinated user actions to yield one
string. Written down, not defended further.

**The claim this design supports:** playlists add **no new authorization
boundary and no new key hierarchy**. The node already stores an opaque
per-account object for this same account under this same key material; this is a
second payload of an existing kind, and the hub is not involved at all.

**The claim it must not make:** that a node cannot lose or withhold a playlist.
It can. The property is convergence with a local authority (§6.4/§6.5), not
durability guaranteed by any node.

**One thing §5.3's fallback changes.** Playing a track from group B because
group A's node is offline means operator B, not operator A, sees that play. Both
already host that file for this account and already see its other plays, so
nothing new is learned by anyone — but it is a substitution of *observer*, it is
not obvious from the UI, and so it is written down here rather than discovered
later.

---

## 13. Open items — deliberately deferred

| # | Item | Why not now |
|---|---|---|
| O1 | Sharing a playlist with other group members | **Explicitly out of scope.** Not an extension: a shared playlist is group state, sealed under the GEK, with concurrent writers — which makes §6's per-playlist LWW insufficient and an OR-Set over tracks mandatory. If it is ever wanted, reopen §6, not §3 |
| O2 | A second copy wrapped under the account recovery key | `bundle_enc_recovery` is the exact precedent and the column is reserved in §3.3, so this is a client-side change alone when wanted. Not built now because a forgotten passphrase already strands more than playlists |
| O3 | Smart / auto playlists (by artist, by year, recently added) | Queries over the cached index; they need no storage at all. Genuinely a separate feature, and cheaper than this one |
| 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 |
| 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 |

---

## 14. Client side

### 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` |

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`.

### 14.2 The IndexedDB detail that will otherwise be missed

`hub-client.js` opens `meshbay` at `IDB_VERSION = 1` (`hub-client.js:15`) with a
single store, `group_indexes`. Adding `playlists` means **bumping to 2** and
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.

### 14.3 Checklist, per `MESHBAY_DESIGN.md` §9.4

1. The four new files above, plus the edits.
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.
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
   copy drifts.
6. No `apps.js` registry entry and no `ALLOWED_APPS` change: this is not a group
   application, it is a menu inside one that already exists.

---

## 15. Acceptance before shipping

**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:

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.

**Then the feature:**

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.