aboutsummaryrefslogtreecommitdiffstats
path: root/docs/playlists.md
blob: badbc993b811b7e41404dcf4385a0a55d7876629 (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
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
# MeshBay — Playlists (design)

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

---

## 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).
  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. 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 | §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 fifth 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 ≈ 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
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"`

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

```
{
  "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 **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.

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

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.

### 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
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 (3.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:` 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
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.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`
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, 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.

---

## 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 a scroll of the page**. 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. **Never on a scroll of its
  own panel** — the panel is `overflow-y: auto` and a tracklist is routinely
  taller than the window, so the two must be told apart by where the event came
  from. `scroll` does not bubble, so that listener is on the capture phase,
  which is also what made it hear the panel's own scrolling: the menu closed the
  instant it was wheeled or its scrollbar dragged, and no track below the fold
  could be reached. Measured by `menu_scroll_probe.py`; `playlist_ui_probe.py`
  cannot see it, because `.click()` scrolls nothing.
- **measures where it can go, and is given exactly that much room.** It opens at
  the pointer when the list fits below it, flips above when it does not and there
  is more room there, and in either case its `max-height` is the distance from
  where it was just placed to the bottom of the window. The stylesheet's
  `max-height: calc(100vh - 16px)` is only a floor: it says how *tall* the panel
  may be and nothing about where its *bottom* lands, so a panel opened 300 px
  down the window ran 300 px past the bottom of it. It scrolled — and its last
  rows scrolled into a part of itself that is off the screen, which no further
  scrolling brings back. That was the "the last track cannot be reached" report,
  and it is the second of two defects with that one symptom; the first, above,
  hid it. Both are measured by `menu_scroll_probe.py`.
- **re-measures when a submenu opens.** The height changes inside `MenuItems`'
  own state, and `loadItems` arrives later still, so `MenuItems` reports upward
  rather than `Menu` watching for it: a `ResizeObserver` on the panel would be an
  observer on the very box the callback resizes, and the browser breaks that loop
  by raising an error. Without the report a two-row menu opened low keeps the
  192 px it was measured for and threads sixty tracks through that porthole.
- 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`.

### 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, as built:

```
Load a playlist                    ▸  <list>          → replace the queue, play
New playlist…                         <a field>
Delete a playlist                  ▸  <list>          → confirm()
Remove a track from a playlist     ▸  <list> ▸ <tracks>
────────────────────────────────
Sync now                                              ← §7
```

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, 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

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
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, 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

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

---

## 14. Client side

### 14.1 Modules

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

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

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`) — **`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
   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. 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

These need things a test suite here does not have. They are listed so nobody
reads §15.1 as more than it is.

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.

### 15.3 Still open

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